用 iOS 內建的 ASWebAuthenticationSession 實作 OAuth 2.0 授權流程!


我們在開發 app 的時候,有時會跟 Google 或是 Instagram 等的第三方服務做連結,以提供像是存取雲端硬碟或者分享到社群等等的功能。通常這些服務商都會提供 SDK 給開發者,讓我們的 app 可以存取一些需要使用者登入的服務,但是使用服務商 SDK 有時也會有一些風險,比如說 Facebook SDK 在今年七月就出現 bug 而造成一堆 app 閃退。還好,大部分的服務商都有實作一種叫做 OAuth 的開放授權機制,讓我們不需要透過服務商 SDK 也可以 DIY 授權流程。

什麼是 OAuth?

OAuth 是一個針對第三方授權的開放協定。所謂的第三方授權,就是指像 Google 與 Instagram 這樣的網路服務,將使用者存在上面的資料授權給第三方軟體(又稱為客戶端,比如我們的 app)使用的行為。

OAuth 的宗旨之一,就是要保護使用者的帳號密碼等認證資訊,不讓它們被有惡意的第三方軟體或中間人取得,不然就等於被盜帳號了。所以在 OAuth 的架構底下,客戶端絕對是碰不到使用者帳密的。但這樣的話,客戶端又要怎麼跟服務商拿取只有使用者登入才能存取的資訊呢?解法是透過一個 access token(存取權杖)來代表使用者對客戶端的授權。客戶端只要持有這個 access token,就可以跟服務商要求使用者同意給的資訊,不用知道使用者帳密。

Oauth-access-token

所謂的「授權流程」,其實就是取得 access token 的整個過程。在現在的 OAuth 2.0 標準中,針對不同類型的客戶端有幾種不同的流程定義,而 macOS/iOS app 最常碰到的應該是 Implicit flow 跟 Authorization Code flow 兩種。由於後者的安全性更高,所以我們接下來就以它為主來介紹。

Authorization Code 流程簡介

在 OAuth 定義裡面,服務商被分成兩個不同的角色:授權伺服器與資源伺服器。前者負責的是驗證使用者與 app 以核發 access token,後者則是 app 得到授權(access token)之後要求資源的對象,所以整個授權流程其實都是客戶端與使用者在跟授權伺服器打交道,完成授權之後才輪到資源伺服器出場。這樣把授權抽出來變成單獨的授權層有利於實作 SSO(單一登入),或甚至像 Facebook Login、Google Sign-In、Okta 與 Auth0 等等一樣,變成一個單獨的服務商品來販售。

在圖中,我們可以看到與授權伺服器溝通的過程分成兩大部分。在使用者啟動授權流程之後,客戶端 app 首先要開啟一個瀏覽器,來讓使用者登入並同意授權,以取得一個 authorization code(藍色的第 2 到第 5 步驟);接著,客戶端 app 再拿這個暫時的 authorization code 跟自己的憑證(app ID 與 app secret)去跟授權伺服器要求 access token(灰色的第 6 與第 7 步驟)。

為什麼要多一個 authorization code,而不是由授權伺服器直接回傳 access token 就好呢?答案是安全理由。雖然因為要讓使用者進行登入等動作而必須要開啟瀏覽器,但使用瀏覽器進行通訊是會增加安全性風險的。為了降低風險,這個流程就將 access token 分開來傳送,避免 access token 在透過瀏覽器傳輸時被攔截。而 authorization code 就是一個暫時的替代品,用來跟 app 的憑證一起換取 access token。

ASWebAuthenticationSession

最早的時候,在 iOS 上要實作 OAuth 流程是得用 web view 或直接跳到外部的 Safari 來做的。到了 iOS 9 的時候,出現了 SFSafariViewController 這個類型,使做登入更方便。後來 iOS 11 進一步推出 SFAuthenticationSession,大幅簡化程式碼,卻又在之後的 iOS 12 被移到新的 Authentication Services 框架底下,成為現在的 ASWebAuthenticationSession

ASWebAuthenticationSession 基本上就是一個極端簡化的 app 內瀏覽器,只要給它一個視窗、一個請求授權的 URL、回傳 URL 的 scheme、一個處理回傳 URL 的 completion handler 閉包,再呼叫 start(),它就會跳出來給使用者進行登入跟授權。它可以設定成跟 Safari 的資料互通,讓使用者省去二次登入的麻煩。

// 在 iOS 12 中,ASWebAuthenticationSession 如果沒有 strong reference 的話就會消失掉,所以這邊先建立一個變數並放到 completion handler 裡,成為它的 strong reference。
var session: ASWebAuthenticationSession?

// 提供獲取 authorization code 用的 URL 與回傳 URL 的 scheme。
// 當使用者成功授權後,授權伺服器會傳送回傳 URL 給 session,而 session 會直接關掉瀏覽器並呼叫 completionHandler。
session = ASWebAuthenticationSession(url: url, callbackURLScheme: "app", completionHandler:  { callbackURL, error in

    // 使用者成功授權後,從回傳 URL 中抽取 authorization code 以進行後續步驟。

    session = nil
})

// 提供 session 用來顯示的視窗。
session?.presentationContextProvider = presentationContextProvider

// 告訴 session 啟動流程。
session?.start()

之所以要定義回傳 URL 的 scheme,是因為它是靠偵測 scheme,來決定什麼時候要關閉瀏覽器並呼叫 completion handler。這個 scheme 必須是 http 與 https 以外的東西,否則它會直接忽略回傳 URL,永遠不關閉也不呼叫 completion handler。

接下來,我們就以 Dropbox API 為例,並以 ASWebAuthenticationSession 作為給使用者登入與授權的瀏覽器,來實作 Authorization Code 授權流程。

實作

在這個範例當中,我們將會以函數為主來寫整個流程。由於 ASWebAuthenticationSession 不是使用 Delegate 模式,而是使用 closure 來做 callback,所以就不需要特別創造類型來管理它。流程裡面主要會有三個函數,一個用來取得 authorization code,一個是用 authorization code 去換 access token,最後再用一個函數去把前兩個流程包起來做抽象。它們看起來大概會是這樣:

// Pseudo code 

// 1. 取得 authorization code
func retrieveCode(completionHandler:)

// 2. 用 authorization code 換 access token
func retrieveToken(code:completionHandler:)

// 對前兩個函數的包裝
func authorize(completionHandler:) {

    retrieveCode() { code in

        retrieveToken(code: code) { token in

            completionHandler(token)
        }
    }
}

在 App 後台註冊

不過在進入到撰寫程式碼之前,我們需要先到 Dropbox 的後台註冊一個 app。

  1. 登入並進入到 Dropbox 的 App console
  2. 點選 Create app 來註冊一個 app。
  3. 選擇 Scoped access、任一 access type 並為 app 命名。
  4. 進入到 app 後台頁面後,新增一個 Redirect URI,內容是 app://done(或者任意的 URI,只要 scheme 不是 http 或 https 就好)。
  5. 確保 public clients 是被允許的。

retrieveCode 函數

接著在第一步的 retireveCode 函數裡,我們就可以用 Dropbox app console 裡的資訊,來產生一個授權用的 URL:

import UIKit
import AuthenticationServices

func retrieveCode(

    // 用來顯示的視窗
    in window: UIWindow,

    // 會跟第二步的 retrieveToken 函數共用的變數就做成參數
    // 告訴授權伺服器這是哪個 app
    clientID: String,

    // 在 App 後台裡新增的 redirect URI
    redirectURI: String,

    // 這是用來在行動裝置 app 上補強 Authorization Code flow 的叫做 PKCE 的機制,用一個臨時產生的隨機字串(code verifier)來當 client secret,在請求 authorization code 跟請求 token 時各傳送一次給授權伺服器檢查,以避免 authorization code 在用瀏覽器傳輸時遭到中間人攔截。
    codeVerifier: String,

    completionHandler: @escaping (String) -> Void
) {

    // 用 URLComponents 來操作 query 字串
    // 實際參數都需要參考 Dropbox(或其他服務商)的開發者文件
    var urlComponents = URLComponents(string: "https://www.dropbox.com/oauth2/authorize")!

    urlComponents.queryItems = [

        // 告訴授權伺服器我們要走 authorization code 流程
        URLQueryItem(name: "response_type", value: "code"),

        URLQueryItem(name: "client_id", value: clientID),
        URLQueryItem(name: "redirect_uri", value: redirectURI),

        // 詳細的參數請參見 Dropbox 開發者文件
        URLQueryItem(name: "code_challenge", value: codeVerifier),
        URLQueryItem(name: "code_challenge_method", value: "plain")
    ]

    // 取出建構好的 URL
    let url = urlComponents.url!

接著,再用 ASWebAuthenticationSession 來傳送這個 URL,並在 completionHandler 裡抽取 query 字串中的 authorization code,丟給 retrieveCode 函數的 completionHandler 處理。

    var session: ASWebAuthenticationSession?

    // 依據在 app 後台新增的 Redirect URI,填入相對應的 url scheme
    session = ASWebAuthenticationSession(url: url, callbackURLScheme: "app") { url, error in
        session = nil

        // 抽取 authorization code
        guard let url = url else { return }
        guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return }
        guard let code = queryItems.first(where: { $0.name == "code" })?.value else { return }

        // 回傳 code 並結束第一步驟
        completionHandler(code)
    }

    session?.presentationContextProvider = window
    session?.start()
}

這邊會跳出一個錯誤,因為 UIWindow 並不是 ASWebAuthenticationPresentationContextProviding。這樣的話,我們就把它變成是就好了。

extension UIWindow: ASWebAuthenticationPresentationContextProviding {

    public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {

        // UIWindow 本來就有遵守 ASPresentationAnchor,所以可以直接回傳 self。
        return self
    }
}

第一步的 retrieveCode 就是這樣而已。因為我們使用了 ASWebAuthenticationSession 來充當瀏覽器,所以程式碼與整個手續簡單很多。如果是用外部 Safari 的話,就要特別在 Xcode 裡註冊 URL Scheme;用內部的 web view 或 SFSafariViewController 的話,則是要實作 Delegate 去手動攔截回傳請求。這些都被 ASWebAuthenticationSession 簡化到同一個函數裡面了。

retrieveToken 函數

接下來,第二步的 retrieveToken 就不會用到瀏覽器,而是直接用 URLSession 來發出請求與處理回傳了。這次我們變成用 POST 方法來傳請求,但其它都大同小異:

func retrieveToken(
    code: String,
    clientID: String,
    redirectURI: String,
    codeVerifier: String,
    completionHandler: @escaping (String) -> Void
) {

    // 建構 POST 請求
    let url = URL(string: "https://api.dropboxapi.com/oauth2/token")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")

    // 這邊用 URLComponents 來產生 query 字串並轉成 Data
    var urlComponents = URLComponents()
    urlComponents.queryItems = [
        URLQueryItem(name: "grant_type", value: "authorization_code"),
        URLQueryItem(name: "code", value: code),
        URLQueryItem(name: "client_id", value: clientID),
        URLQueryItem(name: "redirect_uri", value: redirectURI),
        URLQueryItem(name: "code_verifier", value: codeVerifier)
    ]
    request.httpBody = urlComponents.query?.data(using: .utf8)

    let task = URLSession.shared.dataTask(with: request) { data, response, error in

        guard let data = data else { return }

        // 用一個臨時的 Codable struct 來解 response
        struct TokenResponse: Codable {
            var access_token: String
        }
        let tokenResponse = try! JSONDecoder().decode(TokenResponse.self, from: data)

        // 回傳從 response 中解出的 access token
        completionHandler(tokenResponse.access_token)
    }
    task.resume()
}

authorize 函數

最後再用一個 authorize 函數來包裝整個流程,並且提供一些共通的變數:

func authorize(in window: UIWindow, completionHandler: @escaping (String) -> Void) {

    // 產生一個長度 128 的隨機字串
    let codeVerifier = String(
        String(repeating: "a", count: 128).compactMap { _ in
            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".randomElement()
        }
    )

    // 在 app 後台中的資訊
    let clientID = "你的 app 的 clientID"
    let redirectURI = "app://done"

    // 1. 使用 ASWebAuthenticationSession 獲取 authorization code
    retrieveCode(in: window, clientID: clientID, redirectURI: redirectURI, codeVerifier: codeVerifier) { code in

        // 2. 使用 URLSession 拿 authorization code 去交換 access token
        retrieveToken(code: code, clientID: clientID, redirectURI: redirectURI, codeVerifier: codeVerifier) { token in

            // 3. 回傳 access token
            completionHandler(token)
        }
    }
}

使用方法

使用方法很簡單,在處理按鈕事件的方法裡加入這幾行就可以了:

import UIKit

class ViewController: UIViewController {

    var accessToken: String?

    // 畫面上的「與 Dropbox 連結」之類的按鈕按下時會呼叫
    @IBAction func authButtonPressed(_ sender: UIButton) {

        authorize(in: view.window!) { token in

            // 儲存與使用 token
            self.accessToken = token
        }
    }

}

要存取資源伺服器上的使用者資料時,通常要把這個 access token 放到 HTTP header 裡面給伺服器驗證,像是底下這個方法:

// 檢查 access token 是否有效
func checkUser(token: String, completionHandler: @escaping (Bool) -> Void) {

    // 建構請求
    let url = URL(string: "https://api.dropboxapi.com/2/check/user")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    struct Parameters: Codable {
        var query: String
    }

    // 如果 access token 有效的話,伺服器會回傳跟 query 一模一樣的字串
    let query = "一些亂數字串"
    request.httpBody = try! JSONEncoder().encode(Parameters(query: query))

    let task = URLSession.shared.dataTask(with: request) { data, response, error in

        // 解出回傳字串
        struct Response: Codable {
            var result: String
        }
        let result = try! JSONDecoder().decode(Response.self, from: data!).result

        // 回傳 token 是否有效
        let tokenIsValid = result == query
        completionHandler(tokenIsValid)
    }
    task.resume()
}

checkUser(token: token) { tokenIsValid in

    print(tokenIsValid)
}

另外,想儲存 access token 的時候,建議將 access token 存放在 Keychain 裡面,避免用明文的方式存在 User Defaults 之類的地方,否則任何人只要拿到 access token,就可以以你的 app 的名義去存取使用者資料。

結論

OAuth 這個東西說難不難、說簡單也不簡單,因為它雖然基本原理不難懂,但因為它是一個開放標準,每個服務商選擇的流程與實作的方式可能都有些不同,再加上要跟服務商的 app 後台與開發者文件打交道,整個實作的複雜度是很高的。還好 ASWebAuthenticationSession 幫我們簡化了流程中與瀏覽器互動的部分,讓自己做 OAuth 流程的難度大大下降。以往可能要新增幾個類型或設定 URL Scheme 才能處理的事情,現在只要幾個函數就搞定。如果你的服務商 SDK 不好用或常出問題的話,或許真的可以考慮自己來寫呢!


iOS 開發者、寫作者、filmmaker。現正負責開發 Storyboards by narrativesaw 此一故事板文件 app 中。深深認同 Swift 對於程式碼易讀性的重視。個人網站:lihenghsu.com。電郵:[email protected]

blog comments powered by Disqus
Shares
Share This