阅读视图
iOS响应式编程Combine——简介
TCP/UDP介绍及区别
Swift中的HTTP(十八) 总结
Swift中的HTTP(十七) 头脑风暴
Swift中的HTTP(十六)复合加载器
Swift中的HTTP(十五) 自动鉴权
上一篇文章介绍了 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 加载器组合成一个复合身份验证加载器。
Swift中的HTTP(十四) 自动鉴权设置
Swift中的HTTP(十三) 基础鉴权
对 web api 的 HTTP 请求通常需要有某种凭据。 最简单的身份验证类型是基本访问身份验证,在这篇文章中,我们将把这个功能添加到我们的库中。
当我们阅读基本身份验证的规范(或维基百科文章)时,我们看到它只是添加了一个授权标头,以及一个 base64 编码的用户名和密码。 添加标头值是我们之前见过的,因此我们的 BasicAuth 加载器在原则上与我们的 ApplyEnvironment 加载器相似。
配置
我们需要的第一件事是一个结构来表示用户名和密码:
public struct BasicCredentials: Hashable, Codable {
public let username: String
public let password: String
public init(username: String, password: String) { ... }
}
我们还需要一个 HTTPRequestOption 以便单个请求可以指定唯一的凭据:
extension BasicCredentials: HTTPRequestOption {
public static let defaultOptionValue: BasicCredentials? = nil
}
extension HTTPRequest {
public var basicCredentials: BasicCredentials? {
get { self[option: BasicCredentials.self] }
set { self[option: BasicCredentials.self] = newValue }
}
}
顺便说一句,让我们转向加载器。
加载器
加载器的行为很简单:当请求进来时,加载器会查看它是否指定了自定义凭据。 如果是这样,它将它们转换为正确的授权标头并将请求发送到链中。 如果没有,它会将任何本地存储的凭据应用于请求,然后继续发送。 如果它没有任何凭据,它会在检索一些请求时暂停传入请求。
让我们把它存根:
public protocol BasicAuthDelegate: AnyObject {
func basicAuth(_ loader: BasicAuth, retrieveCredentials callback: @escaping (BasicCredentials?) -> Void)
}
public class BasicAuth: HTTPLoader {
public weak var delegate: BasicAuthDelegate?
private var credentials: BasicCredentials?
private var pendingTasks = Array<HTTPTask>()
public override func load(task: HTTPTask) {
if let customCredentials = task.request.basicCredentials {
self.apply(customCredentials, to: task)
} else if let mainCredentials = credentials {
self.apply(mainCredentials, to: task)
} else {
self.pendingTasks.append(task)
// TODO: ask delegate for credentials
}
}
private func apply(_ credentials: BasicCredentials, to task: HTTPTask) {
let joined = credentials.username + ":" + credentials.password
let data = Data(joined.utf8)
let encoded = data.base64EncodedString()
let header = "Basic (encoded)"
task.request[header: "Authorization"] = header
}
}
这是基本的实现,但不太正确。 例如,如果同时收到许多请求,我们最终会多次向委托人询问凭据。 我们需要在管理某些状态方面做得更好:
public class BasicAuth: HTTPLoader {
public weak var delegate: BasicAuthDelegate?
private enum State {
case idle
case retrievingCredentials(Array<HTTPTask>)
case authorized(BasicCredentials)
}
private var state = State.idle
public override func load(task: HTTPTask) {
if let customCredentials = task.request.basicCredentials {
self.apply(customCredentials, to: task)
super.load(task: task)
return
}
// TODO: make this threadsafe
switch state {
case .idle:
// we need to ask for credentials
self.state = .retrievingCredentials([task])
self.retrieveCredentials()
case .retrievingCredentials(let others):
// we are currently asking for credentials and waiting for the delegate
self.state = .retrievingCredentials(others + [task])
case .authorized(let credentials):
// we have credentials
self.apply(credentials, to: task)
super.load(task: task)
}
}
private func retrieveCredentials() {
if let d = delegate {
// we've got a delegate! Ask it for credentials
// these credentials could come from storage (eg, the Keychain), from a UI ("log in" page), or somewhere else
// that decision is entirely up to the delegate
DispatchQueue.main.async {
d.basicAuth(self, retrieveCredentials: { self.processCredentials($0) })
}
} else {
// we don't have a delegate. Assume "nil" credentials
self.processCredentials(nil)
}
}
private func processCredentials(_ retrieved: BasicCredentials?) {
// TODO: make this threadsafe
guard case .retrievingCredentials(let pending) = state else {
// we got credentials, but weren't waiting for them; do nothing
// this could happen if we were "reset()" while waiting for the delegate
return
}
if let credentials = retrieved {
state = .authorized(credentials)
for task in pending {
self.apply(credentials, to: task)
super.load(task: task)
}
} else {
// we asked for credentials but didn't get any
// all of these tasks will fail
state = .idle
pending.forEach { $0.fail(.cannotAuthenticate) }
}
}
private func apply(_ credentials: BasicCredentials, to task: HTTPTask) {
...
}
}
这看起来好多了,但它仍然缺少一些关键的东西,我将留给你来实现: 我们需要注意一个任务在挂起时被取消(即 .retrievingCredentials 状态) 这些都不是线程安全的 我们需要 reset(with:) 逻辑来使挂起的任务失败并返回到 .idle 状态 还有一些其他场景值得考虑:
-
此加载程序假定请求将始终经过身份验证。 您将如何更改它以允许请求完全绕过身份验证,因为他们不需要它?
-
在 URLComponents 上有一种方法可以指定用户和密码。 由于 HTTPRequest 在后台使用 URLComponents,您如何更改此加载器以在那里查找可能的授权信息,而不是(或除此之外)basicCredentials 请求选项? 你认为这应该是 API 吗? 为什么或者为什么不?
-
此加载程序不会检测请求何时未通过身份验证,例如返回 401 Unauthorized 或 403 Forbidden 的响应。 这个装载机应该尝试检测吗? 为什么或者为什么不?
正如我们所见,基本身份验证归结为向我们的传出请求简单添加一个标头。 通过延迟传入请求,我们可以转身询问应用程序的另一部分(通过代理)是否有任何凭证供我们使用。
这种模式将在下一篇文章中继续为我们服务,届时我们将研究通过 OAuth 2.0 进行身份验证的更复杂的场景。