(Swift) iOS Apps with REST APIs(九) -- 基于OAuth2.0認證(中)

本篇及上一篇和下一篇都是用來講解使用OAuth2.0進行認證。特別是本篇,特長...

重要說明: 這是一個系列教程,非本人原創,而是翻譯國外的一個教程。本人也在學習Swift,看到這個教程對開發一個實際的APP非常有幫助,所以翻譯共享給大家。原教程非常長,我會陸續翻譯并發布,歡迎交流與分享。

OAuth2.0

OAuth2.0在當前是使用非常普遍的認證方式。它可以讓你在不用把密碼給每一個應用,也不用為每一個應用創建新的賬號情況下登錄。如果你對OAuth2.0還不熟悉的話,可以看看這篇文檔OAuth-2-with-swift-tutorial。這里我們大致講一下OAuth2.0的工作原理。
假如你要讓一個iOS App可以訪問你Twitter賬戶中的一些權限,那么OAuth2.0的認證流程如下:

  1. App將帶你去Twitter進行登錄
  2. 你在Twitter上進行登錄并授權給App(或許只授有限的權限)
  3. 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()

我們要實現的工作如下:

  1. 使用hasOAuthToken檢查是否已經持有訪問令牌
  2. 創建一個登錄視圖
  3. 通過startOAuth2Login啟動一個OAuth認證流程
  4. 一旦我們獲取了訪問令牌,發起一個包含認證信息的請求并打印我們收藏的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應用的:

  1. 重定向用戶到GitHub的訪問請求
  2. GitHub將重新返回一個編碼給你的網站(這里是我們的APP)
  3. 使用該編碼獲取一個訪問令牌
  4. 使用該訪問令牌來訪問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=paramValgrokGithub://是一個自定義的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 IDsecret存放在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 SchemesItem 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 IDclient 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流程,直到我們已經確認所得到的令牌可以正常的工作。那么你或許不再關心這些代碼。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,837評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,196評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,688評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,654評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,456評論 6 406
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,955評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,044評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,195評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,725評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,608評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,802評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,318評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,048評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,422評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,673評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,424評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,762評論 2 372

推薦閱讀更多精彩內容