普通视图
iOS响应式编程Combine——简介
TCP/UDP介绍及区别
Swift中的HTTP(十八) 总结
Swift中的HTTP(十七) 头脑风暴
Swift中的HTTP(十六)复合加载器
Swift中的HTTP(十五) 自动鉴权
Swift中的HTTP(十四) 自动鉴权设置
虽然基本访问身份验证适用于“基本”情况,但现在更常见的是使用某种形式的 OAuth。 与 Basic 身份验证相比,OAuth 有一些有趣的优势,例如:
该应用永远无法访问用户的用户名和密码 用户可以在不影响应用程序访问的情况下更改用户名或密码 用户可以远程撤销应用程序的访问权限 这些优势是以增加复杂性为代价的,而这正是我们将在本文中探讨的复杂性。
自动鉴权流程
基本(无错误)OAuth 流程如下所示:
当我们决定开始身份验证时,我们首先检查是否有任何已保存的令牌。 这里有三种可能的结果:
- 我们有凭据,而且还没有过期
- 我们有凭据,但它们已过期
- 我们没有凭据
第一种情况很简单。 如果我们有未过期的凭据,那么我们不需要做任何事情。 第二种情况也很容易。 如果我们有过期的凭据,我们可以将它们发送到身份验证服务器以获取“新”版本的令牌(假设用户没有撤销访问权限),然后我们就完成了。
如果我们没有任何凭据,则需要要求用户登录。这是通过构建登录网页的 URL,然后将该页面显示给用户来完成的。 用户登录网页,显示该页面的服务器验证凭据的正确性。 假设用户名和密码正确,网页将重定向到一个新的 URL,浏览器会拦截该 URL 并将其重定向到应用程序。
此重定向 URL 具有由服务器生成的特殊代码,应用程序可使用该代码获取访问凭据。 因此,有了代码,应用程序现在转身询问服务器:“鉴于此代码,我需要授权令牌”。 服务器用令牌响应,过程完成。
OAuth 流程定义非常明确,是状态机的一个很好的例子。 有几件具体的事情要做,并且它们完成的顺序是严格定义的,并且只允许该顺序。
如果我们要在 Swift 中实现这个流程,一种常见的方法可能是使用某种 State 枚举,就像我们在 Basic Authentication 帖子中看到的那样。 但是,考虑到状态的数量和允许的非常明确的流程,我认为有必要采用更正式的方法。
自动鉴权状态机
首先,我们将定义一个代表整个过程的“状态机”类:
class OAuthStateMachine {
func run() { }
}
接下来,我们将定义一个代表上图中“圆”的 OAuthState 类,并为状态机提供一个状态。 该状态将有一个 enter() 方法,当我们“进入”该状态时将调用该方法,它应该开始执行其逻辑:
class OAuthState {
func enter() { }
}
class OAuthStateMachine {
private var currentState: OAuthState!
}
一个状态需要一种方法来告诉机器它何时准备好继续前进,所以它需要一个对机器的引用,以及一种移动状态的方法:
class OAuthState {
unowned var machine: OAuthStateMachine!
func enter() { }
}
class OAuthStateMachine {
private var currentState: OAuthState!
func move(to newState: OAuthState) {
currentState?.machine = nil
newState.machine = self
currentState = newState
currentState.enter()
}
}
现在我们可以定义对应于我们的图表的状态:
class GetSavedCredentials: OAuthState { }
class LogIn: OAuthState { }
class GetTokens: OAuthState { }
class RefreshTokens: OAuthState { }
class Done: OAuthState { }
对于其中的每一个,我们都需要实现它们的 enter() 方法。 让我们看看每一个。
检索凭证
GetSavedCredentials 状态是当我们需要转身并询问应用程序是否在钥匙串(或其他安全存储位置)中为我们保存了任何凭据时。 在基本访问帖子中,我们通过委托完成了此操作。 我们将在这里采用类似的方法。
class GetSavedCredentials: OAuthState {
override func enter() {
// we need to ask someone if there are any save credentials
// let's assume the state machine itself has a delegate we can ask
let delegate = machine.delegate
DispatchQueue.main.async {
// it's always polite to invoke delegate methods on the main thread
delegate.stateMachine(self.machine, wantsPersistedCredentials: { credentials in
// this closure will be called with either the credentials that were saved, or "nil"
self.processCredentials(credentials)
})
}
}
private func processCredentials(_ credentials: OAuthCredentials?) {
let nextState: OAuthState
if let credentials = credentials, credentials.expired == false {
// we got credentials and they're not expired
nextState = Done(credentials: credentials)
} else if let credentials = credentials {
// we got credentials but they are expired
nextState = RefreshTokens(credentials: credentials)
} else {
// we did not get credentials
nextState = LogIn()
}
machine.move(to: nextState)
}
}
这真的就是它的全部。 当我们“进入”状态时,我们询问代理是否有任何已保存的凭据。 在某个时候它会返回给我们,于是我们检查结果并决定下一步去哪里。
刷新Tokens
我们从身份验证服务器获得的令牌包含两部分:“刷新”令牌和“访问”令牌。 访问令牌是我们用来对每个请求进行身份验证的东西,它的生命周期往往很短。 它的有效期从几分钟到几天不等。
在某个时候,它会“过期”(这个过期日期包含在我们作为令牌的一部分获得的数据中)。 发生这种情况时,我们使用另一个令牌(“刷新”令牌)向服务器请求新的访问令牌。 这是 OAuth 尝试为用户提供尽可能多的控制权的方式之一。 当用户撤销对应用程序的访问时,它不仅会使访问令牌失效,还会使刷新令牌失效。 这意味着应用程序不能只获取新令牌并仍然保持访问权限,而是完全失去访问权限。
RefreshTokens 状态使用此“刷新令牌”来获取新凭据。 假设 OAuthStateMachine 有一个 HTTPLoader,我们可以使用它来请求新的凭据(例如,这可能是整个 OAuth 加载程序的 .nextLoader)。
class RefreshTokens: OAuthState {
let credentials: OAuthCredentials
override func enter() {
var request = HTTPRequest()
// TODO: construct the request to point to our OAuth server
request.body = FormBody([
URLQueryItem(name: "client_id", value: "my_apps_client_id"),
URLQueryItem(name: "client_secret", value: "my_apps_client_secret"),
URLQueryItem(name: "grant_type", value: "refresh_token"),
URLQueryItem(name: "refresh_token", value: credentials.refreshToken)
])
machine.loader.load(request: request, completion: { result in
self.processResult(result)
})
}
private func processResult(_ result: HTTPResult) {
let nextState: OAuthState
switch result {
case .failure(let error):
// TODO: do we give up here? Or maybe we could ask the user to log in?
nextState = Done(credentials: nil)
case .success(let response):
// this could be any response, including a "401 Unauthorized" response
if let credentials = OAuthCredentials(response: response) {
// TODO: notify the delegate that we have new credentials to save
nextState = Done(credentials: credentials)
} else {
// TODO: do we give up here? Or maybe we could ask the user to log in?
nextState = Done(credentials: nil)
}
}
machine.move(to: nextState)
}
}
鉴于我们现有的 OAuthCredentials,我们使用其中的 .refreshToken 向服务器请求新的访问令牌。 如果我们得到它,我们可以告诉委托人保存它并移动到“完成”状态。 如果出现问题,那么我们可以放弃(在没有凭据的情况下转到“完成”),或者我们可以直接进入“登录”状态并要求用户再次登录。 这个特定的选择是我们的,做出这个改变是实例化一个 LogIn 实例而不是 Done 实例的问题。
登录
如果我们未能获得有效的访问令牌(无论是没有保存还是无法理解响应),我们可能需要要求用户登录。此状态将与以下一样简单 GetSavedCredentials 状态:
class LogIn: OAuthState {
let state = UUID()
override func enter() {
var loginURL = URLComponents()
// construct a URL according to the specification for the server. This will likely be something like this:
loginURL.scheme = "https"
loginURL.host = "example.com"
loginURL.path = "/oauth/login"
loginURL.queryItems = [
URLQueryItem(name: "client_id", value: "my_apps_client_id"),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: "space separated list of permissions I want"),
URLQueryItem(name: "state", value: state.uuidString)
]
let url = loginURL.url! // this should always succeed
// TODO: what if in some alternate reality this fails. Then what?
DispatchQueue.main.async {
let delegate = self.machine.delegate
delegate.stateMachine(self.machine, displayLoginURL: url, completion: { callbackURL in
self.processCallbackURL(callbackURL)
})
}
}
private func processCallbackURL(_ url: URL?) {
let nextState: OAuthState
// TODO: if url is nil, then the user cancelled the login process → Done(credentials: nil)
// TODO: if we got a url but its "state" query item doesn't match self.state, the app called the wrong callback → Done(credentials: nil)
// TODO: if we see a "code" query item in the URL → GetTokens(code: code)
machine.move(to: nextState)
}
}
这在概念上是一个非常简单的状态。 与 GetSavedCredentials 一样,这是一种我们“等待应用程序做某事,当它完成后会告诉我们”的状态。
“应用程序做某事”的那部分可以是几件不同的事情。 这个状态所做的就是给应用程序一个 URL,应用程序需要以某种方式使用它来让用户登录。这可以通过 WKWebView(不推荐),弹出到 Safari(可能会破坏),或者使用 ASWebAuthenticationSession(可能是最好的体验)。
显示 WKWebView 很容易,但它会让用户面临风险,因为应用程序可能会看到用户在这样的 Web 视图中输入的内容。 因此,您不应该将这些用于敏感场景,例如登录。如果您选择使用其中之一(您不应该这样做),您将使用 Web 视图的 WKNavigationDelegate 来查看用户何时完成以及服务器何时尝试 将流程重定向回应用程序。 您将拦截重定向的 URL,并使用该 URL 调用提供给状态机委托方法的回调。 当然,如果用户决定取消,你会用 nil 调用回调。 但是,不要使用这种方法。
如果您决定将用户弹出到 Safari,您将使用 UIApplication(或 NSWorkspace)打开 URL。 该应用程序还需要将回调保存在某处。 在 Safari 中,服务器将重定向到应用程序注册处理的 URL(通过其 Info.plist),此时应用程序将再次激活,您的 application(_:open:options:) 委托方法将是 调用,然后将 URL 传回回调。 当然,使用这种方法,您无法知道用户是否已取消。
最好的方法是使用 ASWebAuthenticationSession 在您的应用程序中显示安全浏览器,同时在会话结束时提供回调。 例如,如果您的应用程序中有一个 UIWindowSceneDelegate,您可以将其用作呈现上下文:
extension MySceneDelegate: ASWebAuthenticationPresentationContextProviding {
// this is the method that would get called by way of the state machine delegate method
func displayLoginSession(_ url: URL, completion: @escaping (URL?) -> Void) {
self.authSession = ASWebAuthenticationSession(url: url, callbackURLScheme: Bundle.main.bundleIdentifier!, completionHandler: { [weak self] url, error in
self?.authSession = nil
completion(url)
})
self.authSession?.prefersEphemeralWebBrowserSession = true
self.authSession?.presentationContextProvider = self
self.authSession?.start()
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
// which window are we presenting the session in? the scene's window!
return window!
}
}
由于我们已经将“登录所需的 UI”与“整个身份验证过程”分离,我们实际上最终在整个体验中获得了相当大的灵活性,并且可以让应用程序开发者为他们的应用程序选择最佳体验,而无需 我们(作为图书馆作者)必须为他们做出决定。
获取Tokens
我不会在这里列出完整的状态,但这在概念上与 RefreshTokens 状态相同。 在这种状态下,我们将收到的代码作为登录回调 URL 的一部分,并将其与其他所需的位(例如我们的客户端 ID 和客户端密码)一起发送到服务器。 假设一切都检查完毕,我们将取回一个不错的新刷新令牌和有效访问令牌,我们可以要求应用程序为我们保存它们,然后转到我们最终的“完成”状态。
Done
正如我们到目前为止所见,可以使用一组有效的凭据或 nil(表示出现问题)调用 Done 状态。 当我们进入这个状态时,它需要以某种方式向机器发出信号表明整个过程已经完成(可能是 OAuthStateMachine 上的另一个新方法)并且机器可以获取 Done 状态获得的任何值并将其返回给 调用状态机的库。
两大坑
我从这个状态机中遗漏了两个明显的遗漏。
我遗漏的第一件事,就像我在所有实现中遗漏的一样,是线程安全的概念。 我把它排除在外是因为它是相当多的样板代码,我的目标是这些帖子的可读性,而不是“完全正确”。
另一件事是由问题提示的:如果我们在这个状态机的中间并且我们被要求重置()整个加载链会发生什么? 为了适应这一点,每个状态可能还需要有一个 reset() 方法,它可以用来执行任何清理(例如取消网络请求),然后立即转移到新的 LogOut 状态。 LogOut 状态将负责告诉委托人保存一组新的凭据(nil,意思是“删除你拥有的”),可能会显示注销网页,使服务器的凭据过期等等。 为简洁起见,我将其省略,但如果您最终实施 OAuth,则明智的做法是考虑这种情况。
总结
这是我们为我们的库实现 OAuth 加载程序所需的整体设置。 在下一篇文章中,我们将使用此 OAuthStateMachine 自动获取和刷新令牌以用于经过身份验证的请求。