(Swift) iOS Apps with REST APIs(
本篇及上一篇和下一篇都是用来讲解使用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流程,直到我们已经确认所得到的令牌可以正常的工作。那么你或许不再关心这些代码。