上一篇文章介绍了 OAuth 流程的基础知识:我们如何检查令牌、我们如何要求用户登录、我们如何刷新令牌等等。 在这篇文章中,我们将采用该状态机并将其集成到 HTTPLoader 子类中。
加载器
我们已经为授权流程定义了状态机,但我们需要另一个简单的状态机来描述加载器将如何与之交互。 让我们考虑一下我们的加载器在被要求加载任务时可能处于的各种“状态”:
- 空闲(什么都没有发生,或者状态机失败)→我们应该启动状态机并开始运行授权流程
- 授权(状态机正在运行)→我们应该让任务等待状态机完成
- 授权(我们有有效的凭据)→加载任务
- 授权(我们的凭证已过期)→ 我们需要刷新令牌 如果我们仔细观察一下,我们会发现“空闲”状态实际上与“授权 + 过期令牌”状态是一回事:在任何一种情况下,我们都需要启动状态机,以便 我们可以获得新的令牌(回想一下,状态机已经具有刷新过期令牌的逻辑)。 考虑到这一点,让我们存根我们的加载器:
public class OAuth: HTTPLoader {
private var stateMachine: OAuthStateMachine?
private var credentials: OAuthCredentials?
private var pendingTasks = Array<HTTPTask>()
public override func load(task: HTTPTask) {
// TODO: make everything threadsafe
if stateMachine != nil {
// "AUTHORIZING" state
// we are running the state machine; load this task later
self.enqueueTask(task)
} else if let tokens = credentials {
// we are not running the state machine
// we have tokens, but they might be expired
if tokens.expired == true {
// "AUTHORIZED+EXPIRED" state
// we need new tokens
self.enqueueTask(task)
self.runStateMachine()
} else {
// "AUTHORIZED+VALID" state
// we have valid tokens!
self.authorizeTask(task, with: tokens)
super.load(task: task)
}
} else {
// "IDLE" state
// we are not running the state machine, but we also do not have tokens
self.enqueueTask(task)
self.runStateMachine()
}
}
}
我们可以看到 if 语句中编码的四种可能状态。 我们遗漏了一些部分,所以让我们看一下:
public class OAuth: HTTPLoader {
... // the stuff above
private func authorizeTask(_ task: HTTPTask, with credentials: OAuthCredentials) {
// TODO: create the "Authorization" header value
// TODO: set the header value on the task
}
private func enqueueTask(_ task: HTTPTask) {
self.pendingTasks.append(task)
// TODO: how should we react if the task is cancelled while it's pending?
}
private func runStateMachine() {
self.stateMachine = OAuthStateMachine(...)
self.stateMachine?.delegate = self
self.stateMachine?.run()
}
}
extension OAuth: OAuthStateMachineDelegate {
// TODO: the OAuth loader itself needs a delegate for some of these to work
func stateMachine(_ machine: OAuthStateMachine, wantsPersistedCredentials: @escaping (OAuthCredentials?) -> Void) {
// The state machine is asking if we have any credentials
// TODO: if self.credentials != nil, use those
// TODO: if self.credentials == nil, ask a delegate
}
func stateMachine(_ machine: OAuthStateMachine, persistCredentials: OAuthCredentials?) {
// The state machine has found new tokens for us to save (nil = delete tokens)
// TODO: save them to self.credentials
// TODO: also pass them on to our delegate
}
func stateMachine(_ machine: OAuthStateMachine, displayLoginURL: URL, completion: @escaping (URL?) -> Void) {
// The state machine needs us to display a login UI
// TODO: pass this on to our delegate
}
func stateMachine(_ machine: OAuthStateMachine, displayLogoutURL: URL, completion: @escaping () -> Void) {
// The state machine needs us to display a logout UI
// This happens when the loader is reset. Some OAuth flows need to display a webpage to clear cookies from the browser session
// However, this is not always necessary. For example, an ephemeral ASWebAuthenticationSession does not need this
// TODO: pass this on to our delegate
}
func stateMachine(_ machine: OAuthStateMachine, didFinishWithResult result: Result<OAuthCredentials, Error>) {
// The state machine has finished its authorization flow
// TODO: if the result is a success
// - save the credentials to self.credentials (we should already have gotten the "persistCredentials" callback)
// - apply these credentials to everything in self.pendingTasks
//
// TODO: if the result is a failure
// - fail all the pending tasks as "cannot authenticate" and use the error as the "underlyingError"
self.stateMachine = nil
}
}
大多数对状态机的反应都涉及将信息转发给另一个代表。 这是因为我们的加载器(正确!)不知道如何显示登录/注销 UI,我们的加载器也不知道凭据如何保存或保存在何处。 这是应该的。 显示 UI 和持久化信息与我们的加载器“验证请求”的任务无关。
重置
除了 TODO: 分散在我们代码周围的项目之外,我们缺少的最后一个主要难题是“重置”逻辑。 乍一看,我们可能会认为是这样的:
public func reset(with group: DispatchGroup) {
self.stateMachine?.reset(with: group)
super.reset(with: group)
}
正如上一篇文章中所讨论的,状态机中的每个状态都可以被 reset() 调用中断,这就是发生这种情况的方式。 因此,如果我们的机器当前正在运行,这就是我们可以中断它的方式。
……但是如果它没有运行呢? 如果我们已经通过身份验证并拥有有效令牌,然后我们收到对 reset() 的调用怎么办? (这实际上是常见的情况,因为“重置”在很大程度上类似于“注销”,通常只有在身份验证成功时才会发生)
在这种情况下,我们需要修改我们的状态机。 回想一下我们上次描述这个 OAuth 流程:
此流程中没有任何内容可处理“注销”场景。 我们需要稍微修改一下,以便我们也有办法使令牌无效。 此注销状态已在之前的“注意事项”部分中列出。 包含它后,状态流程图现在大致如下所示:
关于这件事需要注意的两点是:
-
从所有先前状态到新“注销”状态的虚线表示在状态机运行时通过调用 reset() 来“中断”该状态
-
新的“注销”状态是状态机的可能入口点。 也就是说,我们可以在这个状态下启动机器。 我将把“注销”状态的实现留给你,但它需要做一些事情:
-
它需要构造 URL 来显示“注销”页面以显示给用户(之前提到的从浏览器会话中清除 cookie 的页面)
-
它需要联系服务器并告诉他们凭据已被撤销
-
它需要通知其委托人清除任何持久化的凭据 有了这个,我们的 OAuth 加载器应该可以完全正常工作:
public func reset(with group: DispatchGroup) {
if let currentMachine = self.stateMachine {
// we are currently authorizing; interrupt the flow
currentMachine.reset(with: group)
} else {
// TODO: you'll want to pass the "group" into the machine here
self.stateMachine = OAuthStateMachine(...)
self.stateMachine?.delegate = self
// "running" the state machine after we gave it the DispatchGroup should start it in the LogOut state
self.stateMachine?.run()
}
super.reset(with: group)
}
结论
我希望这两篇文章说明 OAuth 不必是这么可怕的东西。 我们有状态机来授权(或取消授权)用户,它有六种可能的状态。 这不是很多,我们可以把它记在脑子里。 同样,加载程序本身只有少数几种可能的状态,具体取决于状态机的情况。 通过将各自的逻辑封装在不同的抽象层中,我们能够将整体复杂性保持在相当低的水平。 我们机器的每个状态子类都是直截了当的; 我们的 StateMachine 类中几乎没有代码; 甚至我们的 OAuth 加载程序也只有几十行。
但由此,我们最终得到了一个功能齐全的 OAuth 流程:
- 我们保证一次只运行一个 OAuth 授权 UI
- 我们允许客户显示他们想要的 OAuth UI
- 我们允许客户以他们想要的方式保留令牌
- 我们允许中断授权
- 我们允许通过重置取消授权 太棒了!
在下一篇文章中,我们将把 BasicAuth 和 OAuth 加载器组合成一个复合身份验证加载器。