(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阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容