本篇及上一篇和下一篇都是用來講解使用OAuth2.0進行認證。特別是本篇,特長...
重要說明: 這是一個系列教程,非本人原創,而是翻譯國外的一個教程。本人也在學習Swift,看到這個教程對開發一個實際的APP非常有幫助,所以翻譯共享給大家。原教程非常長,我會陸續翻譯并發布,歡迎交流與分享。
OAuth2.0
OAuth2.0在當前是使用非常普遍的認證方式。它可以讓你在不用把密碼給每一個應用,也不用為每一個應用創建新的賬號情況下登錄。如果你對OAuth2.0還不熟悉的話,可以看看這篇文檔OAuth-2-with-swift-tutorial。這里我們大致講一下OAuth2.0的工作原理。
假如你要讓一個iOS App可以訪問你Twitter賬戶中的一些權限,那么OAuth2.0的認證流程如下:
- App將帶你去Twitter進行登錄
- 你在Twitter上進行登錄并授權給App(或許只授有限的權限)
- Twitter將帶你再次回到原來的App,并返回一個令牌(Token)給App使用
這個流程對你來說可能有些迷惑(事實上,這里還有一些其它額外步驟,后面我們會添加進來),但這意味著iOS App永遠都不會知道你的密碼。而且也允許你在不修改Twitter密碼的情況下取消對App的授權。
所以,當你打算使用OAuth2.0進行認證的時候,第一件事就是構建一個登錄流程來獲取令牌。因此,下面讓我們完成這件事。
我們通過GitHub gists API獲取所收藏的Gists列表,如果在沒有認證的時候,調用https://api.github.com/gists/starred
將會得到下面的錯誤:
{
"message":"Bad credentials",
"documentation_url":"https://developer.github.com/v3"
}
這個錯誤告訴我們需要一個OAuth認證令牌,并和請求一起發送過來。因此,我們接下來實現這個OAuth認證流程,并獲取所收藏Gists的列表,然后像前面使用基礎認證的時候一樣,把這個列表輸出到控制臺。
溫馨提示:接下來的這個章節會非常長,或許你應該先去一下洗手間或者吃點什么。
沒有認證時的API調用:
// MARK: - OAuth 2.0
func printMyStarredGistsWithOAuth2() -> Void {
alamofireManager.request(GistRouter.GetMyStarred())
.responseString { response in
guard response.result.error == nil else {
print(response.result.error!)
return
}
if let receivedString = response.result.value {
print(receivedString)
}
}
}
如果你之前在你的路由器中添加了基礎認證的代碼,那么現在先刪掉。我們很快就會把它替換為OAuth令牌。下面是沒有添加任何認證的代碼:
enum GistRouter: URLRequestConvertible {
static let baseURLString:String = "https://api.github.com"
case GetPublic() // GET https://api.github.com/gists/public
case GetMyStarred() // GET https://api.github.com/gists/starred
case GetAtPath(String) // GET at given path
var URLRequest: NSMutableURLRequest {
var method: Alamofire.Method {
switch self {
case .GetPublic:
return .GET
case .GetMyStarred:
return .GET
case .GetAtPath:
return .GET
}
}
let result: (path: String, parameters: [String: AnyObject]?) = {
switch self {
case .GetPublic:
return ("/gists/public", nil)
case .GetMyStarred:
return ("/gists/starred", nil)
case .GetAtPath(let path):
let URL = NSURL(string: path)
let relativePath = URL!.relativePath!
return (relativePath, nil)
}
}()
let URL = NSURL(string: GistRouter.baseURLString)!
let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path))
let encoding = Alamofire.ParameterEncoding.JSON
let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters)
encodedRequest.HTTPMethod = method.rawValue
return encodedRequest
}
}
1. 獲取OAuth訪問令牌
在App啟動的時候,如果我們沒有OAuth訪問令牌,那么我們需要先獲取一個。所以,在調用printMyStarredGistsWithOAuth2
之前我們先要判斷是否已經有了OAuth訪問令牌,如果沒有要先獲取一個。
在MasterViewController
中,我們增加一個方法進行初始數據的加載。如果我們已經有了一個訪問令牌,它將獲取這個令牌,并打印獲取到的收藏Gists列表。后面我們將把printMyStarredGistsWithOAuth2
替換為loadGists
,但現在先讓我們來確認OAuth能夠正常進行工作:
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
loadInitialData()
}
func loadInitialData() {
if (!GitHubAPIManager.sharedInstance.hasOAuthToken()) {
showOAuthLoginView()
} else {
GitHubAPIManager.sharedInstance.printMyStarredGistsWithOAuth2()
}
}
判斷我們是否已經有了一個OAuth訪問令牌是GitHubAPIManager
的責任,因此,我們添加一個方法來判斷:GitHubAPIManager.sharedInstance.hasOAuthToken()
。
如果我們還沒有訪問令牌,那么將發起一個OAuth認證流程。我們通過showOAuthLoginView()
方法顯示一個視圖,在該視圖中用戶可以點擊登錄按鈕進行登錄。當用戶點擊登錄按鈕時,我們將調用URLToStartOAuth2Login()
方法,讓MasterViewController
可以獲取URL并開始登錄流程。接下來我們將創建這個視圖,并實現上面說的兩個方法。
假如我們有了認證令牌,我們就可以打印Gists列表了:
GitHubAPIManager.sharedInstance.printMyStarredGistsWithOAuth2()
我們要實現的工作如下:
- 使用
hasOAuthToken
檢查是否已經持有訪問令牌 - 創建一個登錄視圖
- 通過
startOAuth2Login
啟動一個OAuth認證流程 - 一旦我們獲取了訪問令牌,發起一個包含認證信息的請求并打印我們收藏的Gists列表
我們可以先把大致需要實現的框架寫出來,后面慢慢實現,以免忘記:
import Foundation
import Alamofire
class GitHubAPIManager {
static let sharedInstance = GitHubAPIManager()
...
func hasOAuthToken() -> Bool {
// TODO: implement
return false
}
// MARK: - OAuth flow
func URLToStartOAuth2Login() -> NSURL? {
// TODO: implement
// TODO: get and print starred gists
}
func printMyStarredGistsWithOAuth2() -> Void {
alamofireManager.request(GistRouter.GetMyStarred())
.responseString { response in
guard response.result.error == nil else {
print(response.result.error!)
return
}
if let receivedString = response.result.value {
print(receivedString)
}
}
}
}
2. 登錄視圖
最好的方式就是讓用戶總是知道發生了什么。因此,我們不會直接把用戶帶到GitHub登錄頁面,而是彈出一個視圖可以讓用戶確認他們是否愿意登錄。
打開故事板,并拖拽一個新的視圖控制器(View Controller)到故事板中:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_210.png?imageView2/0/w/480" style="width:480px"/>
</div>
在新的視圖控制器中添加一個按鈕:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_220.png?imageView2/0/w/400" style="width:320px"/>
</div>
設置標題為:Login to GitHub
:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_230.png?imageView2/0/w/400" style="width:320px"/>
</div>
確保按鈕的寬度足夠寬:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_240.png"/>
</div>
選中按鈕并添加相對視圖水平居中和垂直居中約束:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_250.png?imageView2/0/w/400" style="width:400px"/>
</div>
為了添加該按鈕的處理函數,我們需要創建一個新的Swift類文件來處理該視圖控制器。創建一個新的Swift文件并命名為:LoginViewController.swift
。
在新的登錄視圖控制器代碼文件中,我們需要增加一個IBAction
來響應這個按鈕的處理:
import UIKit
class LoginViewController: UIViewController {
@IBAction func tappedLoginButton() {
// TODO: implement
}
}
然后我們切換到故事板。設置故事板的ID并將視圖控制器的類設置為LoginViewController
:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_260.png?imageView2/0/w/480" style="width:480px"/>
</div>
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_270.png?imageView2/0/w/480" style="width:480px"/>
</div>
并將按鈕的touch up inside
事件與我們剛剛添加的代碼連接起來:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_280.png?imageView2/0/w/480" style="width:480px"/>
</div>
現在我們就可以在啟動App時,如果檢測到還沒有一個OAuth令牌,就可以轉到登錄視圖。在MasterViewController
中修改代碼如下:
func loadInitialData() {
if(!GitHubAPIManager.sharedInstance.hasOAuthToken()) {
showOAuthLoginView()
} else {
GitHubAPIManager.sharedInstance.printMyStarredGistsWithOAuth2()
}
}
func showOAuthLoginView() {
let storyboard = UIStoryboard(name: "Main", budle: NSBundle.mainBundle())
if let loginVC = storyboard.instantiateViewControllerWithIdentifier(
"LoginViewController") as? LoginViewController {
self.presentViewController(loginVC, animated: true, completion: nil)
}
}
為了能夠顯示該視圖,我們首先需要從故事板中創建一個實例(使用故事板的ID:LoginViewController
)。然后我們就可以使用導航控制器,就是在之前使用master-detail
創建的,將視圖控制器壓到視圖棧中。
這樣就會交給登錄視圖控制器來負責。但接下來該怎么處理呢?如果用戶點擊了登錄按鈕,我們需要啟動一個OAuth登錄流程并把登錄視圖隱藏。IBAction
在登錄視圖控制器中,但我么希望能夠轉回到主視圖并啟動OAuth流程。
幸運的是,代理模式可以解決這種需求。我們將定義一個協議,用了定義登錄視圖的代理在登錄按鈕被按下時應該做些什么。我們可以在LoginViewController
中來定義這個協議,協議的名稱為:LoginViewDelegate
:
import UIKit
protocol LoginViewDelegate: class {
func didTapLoginButton()
}
class LoginViewController: UIViewController {
weak var delegate: LoginViewDelegate?
@IBAction func tappedLoginButton() {
if let delegate = self.delegate {
delegate.didTapLoginButton()
}
}
}
那么,當登錄按鈕被按下時,我們將檢查是否有代理存在,如果有我們將告訴它發生了什么。
這里將代理聲明為一個
weak var
,這樣登錄視圖控制器就不會擁有該委托。否則我們就創建了一個retain cycle
,因為代理(也就是MasterViewController
)擁有LoginViewController
,而LoginViewController
也擁有這個代理。在這種情況下兩個視圖控制器都不能夠被釋放,就會造成內存泄漏。
我們主視圖控制器需要來實現該協議,從而能干處理相應的事件:
class MasterViewController: UITableViewController, LoginViewDelegate {
...
}
在顯示登錄視圖之前,我們需要把登錄視圖的代理設置為自己:
func showOAuthLoginView() {
let storyboard = UIStoryboard(name: "Main", bundle: NSBundle.mainBundle())
if let loginVC = storyboard.instantiateViewControllerWithIdentifier(
"LoginViewController") as? LoginViewController {
loginVC.delegate = self
self.presentViewController(loginVC, animated: true, completion: nil)
}
}
最后,我們需要在MasterViewController
中實現該協議,從而能夠處理登錄按鈕的點擊事件:
func didTapLoginButton() {
self.dismissViewControllerAnimated(false, completion: nil)
if let authURL = GitHubAPIManager.sharedInstance.URLToStartOAuth2Login() {
// TODO: show web page
}
}
當用戶點擊登錄按鈕時,我們將銷毀登錄視圖,并啟動OAuth流程。下面,我們將先來了解一個GitHub的OAuth處理流程,然后再回來完成該代碼。
3. OAuth登錄流程
使用GitHub的API請求一個令牌可以按照下面的流程,盡管文檔中說該流程是為Web應用的:
- 重定向用戶到GitHub的訪問請求
- GitHub將重新返回一個編碼給你的網站(這里是我們的APP)
- 使用該編碼獲取一個訪問令牌
- 使用該訪問令牌來訪問API
第三步,在前面開頭我說過是額外的一個步驟。因為用戶不會看到這個步驟,因此我們可以考慮不把該步驟作為OAuth 2.0流程的一步,但對于編碼來說是必須要實現的。
第一步:將用戶重新定位到GitHub
首先我們要做的就是將用戶重新定位到GitHub網頁。我們需要定位到的端點為:
GET https://github.com/login/oauth/authorize
需要的參數為:
- client_id
- redirect_uri
- scope
- state
這里的參數只有client_id
為必須參數,但是我們會提供除了redirect_uri
參數之外的所有參數信息,因為redirect_uri
可以在Web接口中設定。
為了獲取一個client ID你必須先在GitHub中建立一個應用:Create a new OAuth app。
如果你還沒有GitHub帳號,那么首先你要去注冊一個免費帳號。而且,你還需要先去關注幾個gists,這樣后面你的API調用才能獲取這些數據。
填寫登錄表單。對于認證回調的URL(也就是在redirect_uri
中指定的值),為你的APP構建一個URL格式(URL format),該格式以一個唯一的ID開頭。如,在這里我使用grokGitHub://?aParam=paramVal
,grokGithub://
是一個自定義的URL協議。?aParam=paramVal
部分對于我們的代碼來熟不是必須的,但GitHub是不接受沒有沒有參數的回調URL。
認證回調URL在第二步中當GitHub重新將用戶發送回我們的APP時使用。對于第一步,我們需要從GitHub中拷貝client_id
。我們后面也會需要client_secret
,所以這里也一起拷貝:
class GitHubAPIManager
{
static let sharedInstance = GitHubAPIManager()
var alamofireManager:Alamofire.Manager
let clientID: String = "1234567890"
let clientSecret: String = "abcdefghijkl"
...
}
一般我們不會將client ID
和secret
存放在APP代碼中,這樣一些不懷好意的家伙就可以從這里獲取。但現在對于我們做個演示如何使用OAuth來說是可以的,而且也大大簡化我們的處理。
現在我們已經獲取了client ID
,接下來我們就可以實現URLToStartOAuth2Login()
:
// MARK: - OAuth flow
func URLToStartOAuth2Login() -> NSURL? {
let authPath:String = "https://github.com/login/oauth/authorize" +
"?client_id=\\(clientID)&scope=gist&state=TEST_STATE"
guard let authURL:NSURL = NSURL(string: authPath) else {
// TODO: handle error
return nil
}
return authURL
}
在didTapLoginButton()
中我們將調用該函數將用戶帶到網頁進行登錄。在iOS9中有一個非常好的新的類SFSafariViewController
,我們可以用來將用戶帶到OAuth登錄頁面。
為了可以使用SFSafariViewController
我們需要在我們的工程中增加Safari Services
框架。在organizer
面板(左上角)中選擇你的工程。然后選擇target
并在第一個頁簽向下滾動直到找到Linked Frameworks and Libraries
。在該區段的下面點擊添加按鈕:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_290.png?imageView2/0/w/480" style="width:480px"/>
</div>
然后選擇SafariServices.framework
:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_300.png?imageView2/0/w/480" style="width:480px"/>
</div>
這樣你可以看到已經添加到你的工程中了:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_310.png?imageView2/0/w/480" style="width:480px"/>
</div>
現在我們可以引入該框架了。我們需要將該視圖控制器作為一個變量(同時將MasterViewController
作為它的代理)。這樣我們就可以為用戶顯示網頁,處理當沒有網絡連接時的錯誤,并在用戶完成時隱藏它。
import SafariServices
class MasterViewController: UITableViewController, LoginViewDelegate,
SFSafariViewControllerDelegate {
var safariViewController: SFSafariViewController?
...
}
這樣我們就可以創建該視圖控制器,并顯示:
func didTapLoginButton() {
self.dismissViewControllerAnimated(false, completion: nil)
if let authURL = GitHubAPIManager.sharedInstance.URLToStartOAuth2Login() {
safariViewController = SFSafariViewController(URL: authURL)
safariViewController?.delegate = self
if let webViewController = safariViewController {
self.presentViewController(webViewController, animated: true, completion: nil)
}
}
}
然后確保網頁進行加載,如果加載失敗,我們將銷毀并返回:
// MARK: - Safari View Controller Delegate
func safariViewController(controller: SFSafariViewController,
didCompleteInitialLoad didLoadSuccessfully: Bool) {
// Detect not being able to load the OAuth URL
if (!didLoadSuccessfully) {
// TODO: handle this better
controller.dismissViewControllerAnimated(true, completion: nil)
}
}
后面如果用戶完成了登錄,我們也會銷毀該視圖。
那么這里當我們使用UIApplication.sharedApplication().openURL(authURL)
為用戶打開Safari,并讓他們使用GitHub帳號來認證時會顯示界面如下:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_320.png?imageView2/0/w/480" style="width:320px"/>
</div>
所以注意第一步。當我們點擊Authorize
按鈕時我們將得到一個錯誤:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_330.png?imageView2/0/w/480" style="width:240px"/>
</div>
這是因為當用戶認證后GitHub將嘗試使用我們給出的回調URLgrokGitHub://?aParam=paramVal
進行回調。但iOS根本不知道如何處理grokGitHub://
URL。因此我們必須告訴iOS,我們的APP將會處理這些以grokGitHub://
開頭的URL。
第二步:處理GitHub回調
在iOS中,任何APP都可以注冊一個URL方案。也就是說,我們會告訴操作系統我們的APP將會處理grokGitHub://
開頭的URL。這樣,GitHub能夠將用戶重定位回我們的APP并返回一個驗證碼,后面可以根據該驗證碼來換取訪問令牌。
為什么我們需要先得到一個碼然后再換取令牌,而不是直接獲取令牌?不知道你是否注意到第一個步驟中
state
參數。這樣實現是為了安全。我們發送一個state
參數,并確保我們得到了返回。如果我們沒有得到返回,那么我們可以終止OAuth認證流程,這樣訪問令牌就不會生成。那么再使用第二步就可以確保是我們自己發送的,而不是一個隨機的人或者機器嘗試獲取我們的GitHub賬戶信息。
要注冊一個自定義URL方案,我們需要打開工程中的info.plist
文件:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_340.png?imageView2/0/w/480" style="width:480px"/>
</div>
右擊并選擇Add Row
:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_350.png?imageView2/0/w/480" style="width:360px"/>
</div>
將標識符(identifier)更該為:URL types
:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_360.png?imageView2/0/w/480" style="width:240px"/>
</div>
將類型更改為Array
并添加一個子行(sub-row)Item 0
并包含URL identifier
:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_370.png?imageView2/0/w/480" style="width:480px"/>
</div>
URL標識符(URL identifier)必須唯一。最好的方法就是你的APP ID:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_380.png?imageView2/0/w/480" style="width:480px"/>
</div>
在Item 0
中右擊并在下面添加一行,名稱為:URL Schemes
:
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_390.png?imageView2/0/w/480" style="width:480px"/>
</div>
設置URL Schemes
的Item 0
為你的URL方案,并且不包含://
(設置為:grokGitHub
,而不是grokGitHub://
):
<div style="text-align: center">
<img src="http://o6pjjbgcb.bkt.clouddn.com/rest_400.png?imageView2/0/w/480" style="width:480px"/>
</div>
然后切換回AppDelegate
文件并添加application:handleOpenURL
函數,使得我們可以處理我們需要打開的URL(你可以把Xcode所產生的代碼都刪除,只保留下面這些):
import UIKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, ISplitViewControllerDelegate {
var window: UIWindow?
func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions:
[NSObject: AnyObject]?) -> Bool {
// Override point for customization after application launch.
let splitViewController = self.window!.rootViewController as! UISplitViewController
let navigationController = splitViewController.viewControllers[
splitViewController.viewControllers.count-1] as! UINavigationController
navigationController.topViewController!.navigationItem.leftBarButtonItem
= splitViewController.displayModeButtonItem()
splitViewController.delegate = self
return true
}
func application(application: UIApplication, handleOpenURL url: NSURL) -> Bool {
return true
}
// MARK: - Split view
func splitViewController(splitViewController: UISplitViewController,
collapseSecondaryViewController secondaryViewController:UIViewController,
ontoPrimaryViewController primaryViewController:UIViewController) -> Bool {
guard let secondaryAsNavController = secondaryViewController as?
UINavigationController else { return false }
guard let topAsDetailController = secondaryAsNavController.topViewController as?
DetailViewController else { return false }
if topAsDetailController.detailItem == nil {
// Return true to indicate that we have handled the collapse by doing nothing
// the secondary controller will be discarded.
return true
}
return false
}
}
這就是自定義URL方案所需要的全部。現在可以啟動APP進行測試了。APP將先帶你去Safari進行認證,然后再返回我們的APP。如果你測試有問題,重新設置一下GitHub訪問,Authorized Applications tab,這樣就可以重新進行認證了。我們后面將修改代碼這樣就不用每次啟動APP的時打開Safari進行驗證了,但現在為了測試自定義URL方案可以執行先不管這些。
第三步:換取訪問令牌(Token)
當GitHub回調我們自定義的URL時回傳回一個碼。我們需要處理該URL并解析出該碼,然后使用該碼去換取OAuth訪問令牌。首先我們需要把傳回的URL交給GitHubAPIManager
,因為它來負責這些事項。因此我們需要修改AppDelegate
中的函數:
func application(application: UIApplication, handleOpenURL url: NSURL) -> Bool {
GitHubAPIManager.sharedInstance.processOAuthStep1Response(url)
return true
}
然后在GitHubAPIManager
中實現processOAuthStep1Response
:
class GitHubAPIManager
{
static let sharedInstance = GitHubAPIManager()
...
func processOAuthStep1Response(url: NSURL)
{
// TODO: implement
}
}
我們接收的URL格式如下:
grokgithub://?aParam=paramVal&code=123456789&state=TEST_STATE
不相信我,你可以在processOAuthStep1Response
中打印出來看看。
我們所需要解析的就是code
參數。幸運的是,iOS中提供了工具來解析URL組件,并能夠訪問它們的名稱和值:
func processOAuthStep1Response(url: NSURL) {
let components = NSURLComponents(URL: url, resolvingAgainstBaseURL: false)
var code:String?
if let queryItems = components?.queryItems {
for queryItem in queryItems {
if (queryItem.name.lowercaseString == "code") {
code = queryItem.value
break
}
}
}
}
因此,我們將URL轉換為queryItems
數組(它們每一個都有一個名稱和值),然后我們將循環它們,直到找到一個名稱為code
的項,然后獲取它的值。
當我們獲取碼后,我們就可以通過Alamofire的請求來獲取OAuth訪問令牌。GitHub docs中指出POST
的地址為:
https://github.com/login/oauth/access_token
參數為client ID
、client secret
及我們剛剛解析得到的碼。我們將在報頭中來指定這些參數,并返回JSON格式數據:
if let receivedCode = code {
let getTokenPath:String = "https://github.com/login/oauth/access_token"
let tokenParams = ["client_id": clientID, "client_secret": clientSecret,
"code": receivedCode]
let jsonHeader = ["Accept": "application/json"]
Alamofire.request(.POST, getTokenPath, parameters: tokenParams,
headers: jsonHeader)
.responseString { response in
// TODO: handle response to extract OAuth token
}
}
一旦我們得到了響應,我們就進行檢查是否有錯誤(如果有我們將退出)并查看返回的結果樣式然后找出如何解析OAuth認證令牌(這里假設沒有錯誤):
if let error = response.result.error {
print(rror)
return
}
print(response.result.value)
// like "access_token=999999&scope=gist&token_type=bearer"
如果我們得到了OAuth訪問令牌,我們將存儲它。在這里,我們將把它存放到GitHubAPIManager
的一個變量中。后面我們在把它持久化并進行加密存儲,使得可以在多次運行中可以使用:
class GitHubAPIManager
{
static let sharedInstance = GitHubAPIManager()
var OAuthToken: String?
...
}
為了解析OAuth令牌,我們將遍歷返回中的每個參數:
if let receivedResults = response.result.value, jsonData =
receivedResults.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: false) {
let jsonResults = JSON(data: jsonData)
for (key, value) in jsonResults {
switch key {
case "access_token":
self.OAuthToken = value.string
case "scope":
// TODO: verify scope
print("SET SCOPE")
case "token_type":
// TODO: verify is bearer
print("CHECK IF BEARER")
default:
print("got more than I expected from the OAuth token exchange")
print(key)
}
}
}
當我們將返回的結果轉換為JSON后,我們將遍歷每一個key-value
值對并找出對應的處理方式。為了方便理解,我這里先把每一個需要處理的增加一個TODO
標識,但實際上這里我們不需要每個都實現。如果你打算把這個部署到你的APP中,你必須確定你得到了正確類型的令牌及響應類型的scope
。
好了,我們現在得到了OAuth令牌并保存(如果我們得到了的話):
self.OAuthToken = value
現在我們就可以來獲取我們所關注的gists:
if let receivedCode = code {
let getTokenPath:String = "https://github.com/login/oauth/access_token"
let tokenParams = ["client_id": clientID, "client_secret": clientSecret,
"code": receivedCode]
let jsonHeader = ["Accept": "application/json"]
Alamofire.request(.POST, getTokenPath, parameters: tokenParams,
headers: jsonHeader)
.responseString { response in
if let error = response.result.error {
print(error)
return
}
print(response.result.value)
if let receivedResults = response.result.value, ... {
...
}
if (self.hasOAuthToken()) {
self.printMyStarredGistsWithOAuth2()
}
}
這段代碼看出問題了么?我們沒有更新self.hasOAuthToken()
以便反應我們是否真的得到一個令牌。最好趕快做,否則我們每次都會得到一個錯誤:
func hasOAuthToken() -> Bool {
if let token = self.OAuthToken {
return !token.isEmpty
}
return false
}
現在如果我們已經得到一個令牌,并且不為空,那么hasOAuthToken()
將返回true
。
4. 處理多次運行
現在我們運行這個APP會發生什么?嗯,每次當MasterViewController
顯示的時候:
- 判斷是否有OAuth令牌
- 如果我們沒有,那么會顯示登錄視圖
- 如果已經有了,那么將嘗試獲取我們收藏的Gists
但是,在我們每次運行APP的時候MasterViewController
都會顯示,包括Safari使用我們自定義的URL方案重新打開了APP。問題就在于在這個時候我們有的是碼,而不是訪問令牌。因此登錄視圖每次都會顯示。
為了解決這個問題,我們可以檢查我們是否已經啟動了一個OAuth認證流程。因此,在當我們啟動OAuth認證流程的時候,我們在NSUserDefaults
中保存一個布爾值,來表明當前我們正在加載OAuth訪問令牌:
func didTapLoginButton() {
let defaults = NSUserDefaults.standardUserDefaults()
defaults.setBool(true, forKey: "loadingOAuthToken")
self.dismissViewControllerAnimated(false, completion: nil)
if let authURL = GitHubAPIManager.sharedInstance.URLToStartOAuth2Login() {
safariViewController = SFSafariViewController(URL: authURL)
safariViewController?.delegate = self
if let webViewController = safariViewController {
self.presentViewController(webViewController, animated: true, completion: nil)
}
}
}
然后,在當獲取了OAuth訪問令牌(或者我們調用失敗的時候)將它設置為false
。在我們獲取一個沒有碼的URL時,在POST過程中遇到錯誤時,或者我們解析響應中的碼并獲取訪問令牌后,需要對該標志進行設置。
func processOAuthStep1Response(url: NSURL) {
let components = NSURLComponents(URL: url, resolvingAgainstBaseURL: false)
var code:String?
if let queryItems = components?.queryItems {
for queryItem in queryItems {
if (queryItem.name.lowercaseString == "code") {
code = queryItem.value
break
}
}
}
if let receivedCode = code {
let getTokenPath:String = "https://github.com/login/oauth/access_token"
let tokenParams = ["client_id": clientID, "client_secret": clientSecret,
"code": receivedCode]
let jsonHeader = ["Accept": "application/json"]
Alamofire.request(.POST, getTokenPath, parameters: tokenParams, headers: jsonHeader)
.responseString { response in
if let error = response.result.error {
let defaults = NSUserDefaults.standardUserDefaults()
defaults.setBool(false, forKey: "loadingOAuthToken")
// TODO: bubble up error
return
}
print(response.result.value)
if let receivedResults = response.result.value, jsonData =
receivedResults.dataUsingEncoding(NSUTF8StringEncoding,
allowLossyConversion: false) {
let jsonResults = JSON(data: jsonData)
for (key, value) in jsonResults {
switch key {
case "access_token":
self.OAuthToken = value.string
case "scope":
// TODO: verify scope
print("SET SCOPE")
case "token_type":
// TODO: verify is bearer
print("CHECK IF BEARER")
default:
print("got more than I expected from the OAuth token exchange")
print(key)
}
}
}
if (self.hasOAuthToken()) {
self.printMyStarredGistsWithOAuth2()
}
}
}
}
這個的確變得有點長。那么讓我們將通過碼來交換獲取訪問令牌剝離到它自己的方法中:
func swapAuthCodeForToken(receivedCode: String) {
let getTokenPath:String = "https://github.com/login/oauth/access_token"
let tokenParams = ["client_id": clientID, "client_secret": clientSecret,
"code": receivedCode]
let jsonHeader = ["Accept": "application/json"]
Alamofire.request(.POST, getTokenPath, parameters: tokenParams, headers: jsonHeader)
.responseString { response in
if let error = response.result.error {
let defaults = NSUserDefaults.standardUserDefaults()
defaults.setBool(false, forKey: "loadingOAuthToken")
// TODO: bubble up error
return
}
print(response.result.value)
if let receivedResults = response.result.value, jsonData =
receivedResults.dataUsingEncoding(NSUTF8StringEncoding,
allowLossyConversion: false) {
let jsonResults = JSON(data: jsonData)
for (key, value) in jsonResults {
switch key {
case "access_token":
self.OAuthToken = value.string
case "scope":
// TODO: verify scope
print("SET SCOPE")
case "token_type":
// TODO: verify is bearer
print("CHECK IF BEARER")
default:
print("got more than I expected from the OAuth token exchange")
print(key)
}
}
}
let defaults = NSUserDefaults.standardUserDefaults()
defaults.setBool(false, forKey: "loadingOAuthToken")
if (self.hasOAuthToken()) {
self.printMyStarredGistsWithOAuth2()
}
}
}
func processOAuthStep1Response(url: NSURL) {
let components = NSURLComponents(URL: url, resolvingAgainstBaseURL: false)
var code:String?
if let queryItems = components?.queryItems {
for queryItem in queryItems {
if (queryItem.name.lowercaseString == "code") {
code = queryItem.value
break
}
}
}
if let receivedCode = code {
swapAuthCodeForToken(receivedCode)
} else {
// no code in URL that we launched with
let defaults = NSUserDefaults.standardUserDefaults()
defaults.setBool(false, forKey: "loadingOAuthToken")
}
}
這樣我們就可以改變MasterViewController
,在啟動OAuth認證流程前判斷是否我們是否已經是否已經擁有了一個OAuth訪問令牌:
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
let defaults = NSUserDefaults.standardUserDefaults()
if (!defaults.boolForKey("loadingOAuthToken")) {
loadInitialData()
}
}
并且我們需要在我們無法加載OAuth網頁的時候進行更新:
func safariViewController(controller: SFSafariViewController,
didCompleteInitialLoad didLoadSuccessfully: Bool) {
// Detect not being able to load the OAuth URL
if (!didLoadSuccessfully) {
let defaults = NSUserDefaults.standardUserDefaults()
defaults.setBool(false, forKey: "loadingOAuthToken")
controller.dismissViewControllerAnimated(true, completion: nil)
}
}
NSUserDefault
是字典類型,可以用來在多次運行之間保存一些數據。它適合放比較小、無需加密的數據。
5. 使用OAuth訪問令牌進行API調用
現在終于得到訪問令牌了,那么該怎么使用它呢?這個需要在每次進行GitHub的API調用中設置到Authorization
報頭。
使用Alamofire路由我們是很容易在API請求中包含這個報頭的。只需要在返回NSMutableURL
之前添加進去即可:
enum GistRouter: URLRequestConvertible {
...
var URLRequest: NSMutableURLRequest {
...
let URL = NSURL(string: GistRouter.baseURLString)!
let URLRequest = NSMutableURLRequest(URL: URL.URLByAppendingPathComponent(result.path))
// Set OAuth token if we have one
if let token = GitHubAPIManager.sharedInstance.OAuthToken {
URLRequest.setValue("token \\(token)", forHTTPHeaderField: "Authorization")
}
let encoding = Alamofire.ParameterEncoding.JSON
let (encodedRequest, _) = encoding.encode(URLRequest, parameters: result.parameters)
encodedRequest.HTTPMethod = method.rawValue
return encodedRequest
}
}
但這里還有一個問題,我們并沒有在APP多次運行期間保存該OAuth認證令牌,因此在每次啟動APP的時候都會彈出登錄視圖。對于該值我們希望能夠保存的更加安全一些,所以不會使用NSUserDefaults
進行保存。
6. 安全保存OAuth訪問令牌
在iOS應用中能夠安全保存數據的是Keychain
。但是使用Keychain
的代碼是否非常丑陋的,因此我們打算使用另外一個非常友好的庫Locksmith。
使用CocoaPods將Locksmith v2.0添加到你的工程中。
當你做完這些并重新打開Xcode時。在GitHubAPIManager
的頂部添加import Locksmith
:
import Foundation
import Alamofire
import Locksmith
class GitHubAPIManager {
...
}
現在我們可以使用Locksmith保存和獲取OAuth訪問令牌了:
var OAuthToken: String? {
set {
if let valueToSave = newValue {
do {
try Locksmith.updateData(["token": valueToSave], forUserAccount: "github")
} catch {
let _ = try? Locksmith.deleteDataForUserAccount("github")
}
} else { // they set it to nil, so delete it
let _ = try? Locksmith.deleteDataForUserAccount("github")
}
} get {
// try to load from keychain
Locksmith.loadDataForUserAccount("github")
let dictionary = Locksmith.loadDataForUserAccount("github")
if let token = dictionary?["token"] as? String {
return token
}
return nil
}
}
在上面這段代碼中有些地方是值得我們解釋一下的:
newValue
是Swift在設置方法中傳遞過來的用戶需要設置為的值。如果我們OAuth訪問令牌的值為:GitHubManager.sharedInstance().OAuthToken="abcd1234"
。那么在set
段落中newValue
的值將會是abcd1234
。
我們在這里使用Locksmith.updateData
那是因為如果我們在Keychain
中已經有值的時候會保存新的值進取。假如,我們使用Locksmith.saveData
,那么當在Keychain
中已經有值的時候就會拋出一個錯誤,這當然不是我們所需要的。
在Swift2.0中引入了do-try-catch
。因為Locksmith中標識了throws
,所以我們需要允許這種錯誤拋出。但有時候我們需要進行一些特殊處理,比如,當我們無法把新的值保存進取的時候,也要確保舊值也不可以使用。
do {
try Locksmith.updateData(["token": valueToSave], forUserAccount: "github")
} catch {
// Handle exception
}
并且大部分時候,我們希望我只需要能夠執行該項動作而不想關心出了什么錯誤:
let _ = try? Locksmith.deleteDataForUserAccount("github")
7. 進行已認證調用
Ok,現在GitHubAPIManager
已經修改完畢,但怎么樣來使用呢?還記得之前的printMyStarredGistWithOAuth2
函數么?
func printMyStarredGistsWithOAuth2() -> Void {
alamofireManager.request(GistRouter.GetMyStarred())
.responseString { response in
guard response.result.error == nil else {
print(response.result.error!)
return
}
if let receivedString = response.result.value {
print(receivedString)
}
}
}
因為,我們在Alamofire的路由中已經使用了OAuth認證令牌,所以當我們調用GistRouter.GetMyStarred()
也會自動添加。那么下面可以保存并測試。
我們之前的付出現在終于有收獲了,使得我們簡單和優雅。只要是使用我們的路由(并且我們在請求OAuth的訪問令牌指定了正確的scope),那么都可以很方便的擴展這些需要OAuth訪問令牌的API調用。
構建你的工程并確保可以進行需要認證的調用。
8. 這就是基于OAuth2.0的登錄驗證
我知道這個需要很多步驟。如果你在測試的時候遇到了什么問題,首先要做的就是撤銷訪問權,使得OAuth流程可以進行刷新。對于GitHub你可以參考Authorized Appliations tab。或許你還想在當printMyStarredGistsWithOAuth2
調用失敗時將OAuth訪問令牌清除掉:
func printMyStarredGistsWithOAuth2() -> Void {
alamofireManager.request(.GET, "https://api.github.com/gists/starred")
.responseString { _, _, result in
guard result.error == nil else {
print(result.error)
GitHubAPIManager.sharedInstance.OAuthToken = nil
return
}
if let receivedString = result.value {
print(receivedString)
}
}
}
如果你得到了一個認證鑒權錯誤的話,你可以使用debugPrint
嘗試把請求打印出來(也包含報頭)來確認那里有問題:
func printMyStarredGistsWithOAuth2() -> Void {
let starredGistsRequest = alamofireManager.request(GistRouter.GetMyStarred())
.responseString { _, _, result in
guard result.error == nil else {
print(result.error)
GitHubAPIManager.sharedInstance.OAuthToken = nil
return
}
if let receivedString = result.value {
print(receivedString)
}
}
debugPrint(starredGistsRequest)
}
如果你得到了其它的錯誤,你可以嘗試iOS模擬器中的Reset Content an Settings
選項重新獲取一個初始的環境。或許你需要嘗試前面所說的三種或更多方式來調適OAuth流程,直到我們已經確認所得到的令牌可以正常的工作。那么你或許不再關心這些代碼。