(Swift) iOS Apps with REST APIs(十) -- 基于OAuth2.0认证(下)

本篇及上两篇都是讲解使用OAuth2.0进行认证的,篇幅比较长,顺便你的有点OAuth2.0的基础知识。

重要说明: 这是一个系列教程,非本人原创,而是翻译国外的一个教程。本人也在学习Swift,看到这个教程对开发一个实际的APP非常有帮助,所以翻译共享给大家。原教程非常长,我会陆续翻译并发布,欢迎交流与分享。

显示查询结果

那么该如何在表视图中显示这些Gists呢?之前我们已经能够在表视图中显示公共的Gists列表,所以这里我们只需要切换它,用来显示我们收藏的Gists列表即可。

这是我们之前用来获取公共Gists的代码(还包含了分页、下拉刷新等功能):

func getGists(urlRequest: URLRequestConvertible, completionHandler: 
  (Result<[Gist], NSError>, String?) -> Void) { 
  alamofireManager.request(urlRequest)
    .validate()
    .responseArray { (response:Response<[Gist], NSError>) in
      guard response.result.error == nil,
        let gists = response.result.value else {
          print(response.result.error) 
          completionHandler(response.result, nil) 
          return
      }

      // need to figure out if this is the last page
      // check the link header, if present
      let next = self.getNextPageFromHeaders(response.response) 
      completionHandler(.Success(gists), next)
  }
}
func getPublicGists(pageToLoad: String?, completionHandler: 
  (Result<[Gist], NSError>, String?) -> Void) {
  if let urlString = pageToLoad {
    getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler) 
  } else {
    getGists(GistRouter.GetPublic(), completionHandler: completionHandler)
  }
}

下面我们需要添加一个名称为getMyStarredGists的函数:

func getMyStarredGists(pageToLoad: String?, completionHandler: 
  (Result<[Gist], NSError>, String?) -> Void) {
  if let urlString = pageToLoad {
    getGists(GistRouter.GetAtPath(urlString), completionHandler: completionHandler) 
  } else {
    getGists(GistRouter.GetMyStarred(), completionHandler: completionHandler)
  }
}

现在我们就可以在loadInitialData中使用它们中间的一个了,就像我们之前使用getPublicGists一样。但这里还有一个问题,就是我们在获取OAuth访问令牌时会发生什么?我们可不想在获取OAuth访问令牌的时候整个APP傻傻的不能做任何操作,因此我们这里需要使用异步加载来实现。但,我们怎么知道什么时候搞定了呢?

通常我们可以使用一个完成处理程序,就像我们在getGists中做的一样。只需要在调用的方法后面增加一个块来响应即可。但,这里我们是通过自定义处理URL方案来做的,也就是说这个动作不是我们直接发起的。这是真真正正的异步。在这里我们要把原本要给startOAuth2Login的回调函数(当我们启动的Safari后就会被遗忘),而是将回调函数赋给GitHubAPIManager。这样GitHubAPIManager就好hold住这段代码,直到获取了一个OAuth访问令牌。

代码看起来如下:

GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = {
 // code that we want to execute when we get an OAuth token
}

更具体一点来说就是,这里我们将检查错误,如果没有任何错误我们将获取相应的数据:

GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = { (error) -> Void in  
  if let error = error {
    print(error)
    self.isLoading = false
    // TODO: handle error
    // Something went wrong, try again 
    self.showOAuthLoginView()
  } else { 
    self.loadGists(nil)
  }
}

当然,只有当我们在GitHubAPIManager类中添加了相应的变量后才能工作:

class GitHubAPIManager 
{
  static let sharedInstance = GitHubAPIManager()
  
  // handlers for the OAuth process
  // stored as vars since sometimes it requires a round trip to safari which 
  // makes it hard to just keep a reference to it
  var OAuthTokenCompletionHandler:(NSError? -> Void)?
  
  ...
}

这样设置后,一旦我们获取了OAuth访问令牌,就可以在loadInitialData()中加载数据了:

func loadInitialData() {
  isLoading = true
  GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler = { (error) -> Void in
    self.safariViewController?.dismissViewControllerAnimated(true, completion: nil) 
    if let error = error {
      print(error)
      self.isLoading = false
      
      // TODO: handle error
      // Something went wrong, try again 
      self.showOAuthLoginView()
    } else { 
      self.loadGists(nil)
    }
  }
  
  if (!GitHubAPIManager.sharedInstance.hasOAuthToken()) { 
    self.showOAuthLoginView()
  } else { 
    loadGists(nil)
  }
}

对于分页和下拉刷新我们使用loadGists函数来实现了:

func loadGists(urlToLoad: String?) {
  self.isLoading = true
  GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in
    self.isLoading = false
    self.nextPageURLString = nextPage
    // tell refresh control it can stop showing up now
    if self.refreshControl != nil && self.refreshControl!.refreshing {
      self.refreshControl?.endRefreshing() 
    }
    
    guard result.error == nil else { 
      print(result.error) 
      self.nextPageURLString = nil 
      return
    }
    
    if let fetchedGists = result.value { 
      if urlToLoad != nil {
        self.gists += fetchedGists 
      } else {
        self.gists = fetchedGists 
      }
    }
    
    // update "last updated" title for refresh control
    let now = NSDate()
    let updateString = "Last Updated at " + self.dateFormatter.stringFromDate(now) 
    self.refreshControl?.attributedTitle = NSAttributedString(string: updateString)
    
    self.tableView.reloadData() 
  }
}

我们还需要使用getMyStarredGists替换getPublicGists:

func loadGists(urlToLoad: String?) {
  self.isLoading = true
  GitHubAPIManager.sharedInstance.getMyStarredGists(urlToLoad) { (result, nextPage) in
    self.isLoading = false
    self.nextPageURLString = nextPage
    // tell refresh control it can stop showing up now
    if self.refreshControl != nil && self.refreshControl!.refreshing {
      self.refreshControl?.endRefreshing() 
    }
    
    guard result.error == nil else { 
      print(result.error) 
      self.nextPageURLString = nil 
      return
    }
    
    if let fetchedGists = result.value { 
      if urlToLoad != nil {
        self.gists += fetchedGists 
      } else {
        self.gists = fetchedGists 
      }
    }
    
    // update "last updated" title for refresh control
    let now = NSDate()
    let updateString = "Last Updated at " + self.dateFormatter.stringFromDate(now) 
    self.refreshControl?.attributedTitle = NSAttributedString(string: updateString)
    
    self.tableView.reloadData()
  }
}    

我们还要将下拉刷新的代码替换为loadInitialData,这样当身份认证出现问题的时候可以尝试下拉刷新来解决:

func refresh(sender:AnyObject) {
  let defaults = NSUserDefaults.standardUserDefaults() 
  defaults.setBool(false, forKey: "loadingOAuthToken")
  
  nextPageURLString = nil // so it doesn't try to append the results
  loadInitialData()
}

现在我们已经实现了loadGists,那么下面让我们来解决OAuth认证过程,以便当得到一个OAuth访问令牌后就可以启动完成处理程序了。否则用户必须通过下拉刷新才能够加载Gists列表。
我们需要对OAuth 2.0登录过程中每一个可能出现错误的点进行处理,以便最终我们能以得到访问令牌。
didTapLoginButton开始,如果验证URL无效会出现一个错误。这大概有两种可能:一是NSURL初始化器无法创建authURL,二是网页根本没有加载成功。下面来让我们处理这两种情况(在GitHubAPIManager中):

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) 
    }
  } else {
    defaults.setBool(false, forKey: "loadingOAuthToken") 
    if let completionHandler =
      GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler {
      let error = NSError(domain: GitHubAPIManager.ErrorDomain, code: -1,
        userInfo: [NSLocalizedDescriptionKey:
        "Could not create an OAuth authorization URL",
        NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"])
      completionHandler(error)
    }
  }
}

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") 
    if let completionHandler =
      GitHubAPIManager.sharedInstance.OAuthTokenCompletionHandler {
      let error = NSError(domain: NSURLErrorDomain, code:       SURLErrorNotConnectedToInternet,
        userInfo: [NSLocalizedDescriptionKey: "No Internet Connection",
        NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"])
      completionHandler(error)
    }
    controller.dismissViewControllerAnimated(true, completion: nil) 
  }
}

在这两种情况下,一旦URL出现问题,我们将调用完成处理函数,并将loadingAuthToken的状态设置为false
我们还需要为第一种情况定义一个用户自定义错误域:

class GitHubAPIManager { 
  ...
  static let ErrorDomain = "com.error.GitHubAPIManager"
  ...
}

接下来是AppDelegate中的handleOpenURL:

func application(application: UIApplication, handleOpenURL url: NSURL) -> Bool { 
  GitHubAPIManager.sharedInstance.processOAuthStep1Response(url)
  return true
}

这个怎么看都好像不会发生什么错误。

那继续看下面的processOAuthStep1Response:

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)
  }
}

如果该函数在返回的数据中找不到code参数,这时候应该通知完成处理函数调用失败。我们为此将自定义一个错误:

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")
    
    if let completionHandler = self.OAuthTokenCompletionHandler {
      let noCodeInResponseError = NSError(domain: GitHubAPIManager.ErrorDomain, code: -1,
        userInfo: [NSLocalizedDescriptionKey: "Could not obtain an OAuth code",
        NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"])
      completionHandler(noCodeInResponseError)
    }
  }
}

Ok,在OAuth认证流程中还有一个步骤swapAuthCodeForToken:

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() 
    }
  }
}  

这里我们有成功和失败两种情况。当前当我们获得OAuth访问令牌,我们获取并打印收藏的Gists列表。现在我们将替换为调用完成处理函数,当成功时可以加载Gists列表并显示。如果有错误我们会知道。

let defaults = NSUserDefaults.standardUserDefaults() 
defaults.setBool(false, forKey: "loadingOAuthToken")
if let completionHandler = self.OAuthTokenCompletionHandler { 
  if (self.hasOAuthToken()) {
    completionHandler(nil) 
  } else {
    let noOAuthError = NSError(domain: GitHubAPIManager.ErrorDomain, code: -1, userInfo: 
      [NSLocalizedDescriptionKey: "Could not obtain an OAuth token", 
      NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"])
    completionHandler(noOAuthError)
  }
}  

这里还有一个错误路径需要处理。就是我们在构建URL请求后,我们检查了是否有错误,如果有我们将返回一个错误。这里我们也得调用完成处理函数:

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")
      
      if let completionHandler = self.OAuthTokenCompletionHandler { 
        completionHandler(error)
      }
      return
    } 
    ...  

下面就是swapAuthCodeForToken的全部代码了:

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")
        
        if let completionHandler = self.OAuthTokenCompletionHandler { 
          completionHandler(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 let completionHandler = self.OAuthTokenCompletionHandler { 
        if self.hasOAuthToken()) {
          completionHandler(nil) 
        }else {
          let noOAuthError = NSError(domain: GitHubAPIManager.ErrorDomain, code: -1, 
            userInfo: [NSLocalizedDescriptionKey: "Could not obtain an OAuth token", 
            NSLocalizedRecoverySuggestionErrorKey: "Please retry your request"])
          completionHandler(noOAuthError)
        }
      }
  }
}

嗯,到这里为止,我们已经将这个超级异步的OAuthTokenCompletionHandler和我们的表格视图及GitHubAPIManager整合起来。并可以在表格视图中显示通过OAuth 2.0 API
所获取的数据。

保存并测试它。尝试在你的GitHub账户中撤销OAuth的访问,然后运行。如果不是第一次运行那么需要删除该应用重新来。

完成你的认证API与表视图的整合。

这里有完成的代码:oauth。

刷新访问令牌

有些OAuth实现会在获取访问令牌的时候,给出一个过期时间及一个refresh token,这样你就可以在一段时间后获取一个新的访问令牌(还需要你的client IDclient secret)。但GitHub没有这么做。如果你是使用另外的一个OAuth API那么你首先得查询开发文档看是否返回了refresh token。如果是,你必须保存它以便后面使用。

更新访问令牌和我们使用码来换取访问令牌差不多一样,就是发起一个GET请求,并提供refresh token和你的client IDclient secret

刷新令牌的一个优点就是,访问令牌只能在一个时间段内使用。refresh token在没有client ID和'client secret'时是没有任何用的。所以最好能安全保存。但在本章节中我们并没有构建一个web服务器来存储这些值。

检查你的API文档看是否需要刷新令牌。如果是把它像保存OAuth访问令牌一样保存到Keychain中。然后再添加一个方法来检查是否已经过期,就像我们检查未认证方法一样。当你需要刷新令牌时,你需要像之前通过码来获取OAuth访问令牌一样进行处理。

未认证响应:404 vs 401

如果用户撤销了我们的访问权限会怎么样?我们必须重新进行OAuth认证。那假如我们的OAuth访问令牌是无效的呢?这个比较容易进行测试,你只需要在这里撤销就可以了。

当我们下次运行APP的时候将会引发麻烦。因为,我们认为我们所持有的OAuth访问令牌是有效的,但其实不是,因此,我们会调用失败。并会得到一个错误:

Error Domain=com.alamofire.error Code=-1 "The operation couldn't be completed. (com.alamofire.error error -1.)"

但当我们检查我们请求所返回的响应,会发现HTTP状态码是401 Unauthorized。也就是说我们可以判定访问令牌已经失效了。

我们下面将增加一个方法当没有认证时返回一个错误:

func checkUnauthorized(urlResponse: NSHTTPURLResponse) -> (NSError?) { 
  if (urlResponse.statusCode == 401) {
    self.OAuthToken = nil
    let lostOAuthError = NSError(domain: NSURLErrorDomain,
      code: NSURLErrorUserAuthenticationRequired,
      userInfo: [NSLocalizedDescriptionKey: "Not Logged In",
        NSLocalizedRecoverySuggestionErrorKey: "Please re-enter your GitHub credentials"]) 
    return lostOAuthError
  }
  return nil
}

该方法有一个NSHTTPURLResponse参数,这样我们可以在我们的响应序列化器中访问,并可以检查HTTP的状态码。如果是401 Unauthorized,我们将产生一个错误并返回。否则返回nil。既然在NSURLErrorDomain中已经定义了用户认证错误代码:NSURLErrorUserAuthenticationRequired,我们这里就使用它好了。

然后,我们就可以在Alamofire的响应系列化器中使用了:

func getGists(urlRequest: URLRequestConvertible, completionHandler: 
  (Result<[Gist], NSError>, String?) -> Void) { 
  alamofireManager.request(urlRequest)
    .validate()
    .responseArray { (response:Response<[Gist], NSError>) in
      if let urlResponse = response.response,
        authError = self.checkUnauthorized(urlResponse) {  
        completionHandler(.Failure(authError), nil) 
        return
      }
      
      ...
  }
}  

我们使用'urlResponse = response.response'从Alamofire的Response获取一个NSHTTPURLResponse。然后调用我们刚才定义的方法checkUnauthorized,如果检测到错误我们立刻返回。

我们还需要在加载Gists列表的时候也要处理这个错误:

func loadGists(urlToLoad: String?) {
  self.isLoading = true
  GitHubAPIManager.sharedInstance.getPublicGists(urlToLoad) { (result, nextPage) in
    self.isLoading = false 
    self.nextPageURLString = nextPage
    
    // tell refresh control it can stop showing up now
    if self.refreshControl != nil && self.refreshControl!.refreshing { 
      self.refreshControl?.endRefreshing()
    }
    
    guard result.error == nil else { 
      print(result.error) 
      self.nextPageURLString = nil
      
      self.isLoading = false
      if let error = result.error {
        if error.domain == NSURLErrorDomain &&
          error.code == NSURLErrorUserAuthenticationRequired { 
          self.showOAuthLoginView()
        }
      }
      return
    }  
  
    if let fetchedGists = result.value { 
      if urlToLoad != nil {
        self.gists += fetchedGists 
      } else {
        self.gists = fetchedGists 
      }
    }
      
    // update "last updated" title for refresh control
    let now = NSDate()
    let updateString = "Last Updated at " + self.dateFormatter.stringFromDate(now) 
    self.refreshControl?.attributedTitle = NSAttributedString(string: updateString)
      
    self.tableView.reloadData() 
  }
}

可以进行测试了。首先打开APP并等待加载Gists列表完毕。然后到GitHub上取消授权,并下拉刷新。这时候App将带你去GitHub重新进行授权。

小结

在本章我们讲解了并添加基础认证、基于报头认证及OAuth 2.0等认证方法。这样当我们进行更多的API调用时就可以复用这些代码,然后只需要关心怎么进行错误处理就可以了。

你可以在这里auth下载本章代码。

现在我们已经搞定认证了,下面就可以增进更多的API调用了。下一章我们将讲解用户怎么样在表格视图中切换显示这三个列表。

你可能感兴趣的:((Swift) iOS Apps with REST APIs(十) -- 基于OAuth2.0认证(下))