access / refresh 凭证续期:提前续、只发一次、只在续期凭证失效时登出
项目 trade-app
1. 背景与问题
这款 App 的 App 账户用的是 OAuth 风格的两张凭证:
- 访问凭证:放在 HTTP 请求的鉴权头里,交易长连接建连时也用它;有效期较短。
- 续期凭证:只用来换新的访问凭证,可能随续期一起轮换。
难点不在「怎么续」,而在「什么时候续、谁来续、续失败了怎么办」:
- 每个请求、每个页面出现、启动、回前台、网络恢复都可能发现凭证快过期,同一时刻会有很多个触发点。
- 交易用户经常在弱网下使用,不能因为一次续期超时就把人踢出去。
- 凭证一变,交易长连接要用新凭证重新鉴权。
- 用户可能在续期请求还没回来时就退出登录。
下面先看凭证分层,再看续期的检查、触发点、并发和失败处理,最后和另一种「不续期」的做法对比。
2. 凭证分层
App 里不止一种凭证。续期只针对第一种:
| 凭证 | 来源 | 用途 | 有效期管理 |
|---|---|---|---|
| 访问凭证 + 续期凭证 | 认证中心登录(密码、短信、三方) | HTTP 鉴权、交易长连接鉴权、H5 | 本地记过期时间,不足 20 分钟就续 |
| 行情凭证(随服务器地址下发) | 账户信息接口 | 行情长连接登录 | 同样 20 分钟阈值,到期重拉账户信息 |
| 交易会话号(期货、证券各一) | 交易网关登录应答 | 交易请求、账户心跳 | 本地不记过期,由网关告知 |
交易会话号怎么保活和被踢,在交易会话一篇;本文只讲第一层。
3. 整体设计
方案可以概括成四句话:
- 提前续:剩余有效期不足 20 分钟就续,而不是等过期。
- 只发一次:续期请求走 HTTP 客户端的单飞,同一时刻只有一个在途。
- 兜底强制续:服务端说认证失败,就不管剩余时间强制续一次。
- 只在续期凭证失效时登出:断网、超时等失败不登出,等下一个触发点再续。
4. 关键实现
4.1 检查有效期
// 示意,非原始代码
static func tokenAvailableCheck(force: Bool = false, completion: (() -> Void)? = nil) {
guard let refresh = user.refreshToken else {
if user.isLogin { logout() } // 数据异常兜底
completion?(); return
}
if !force, let exp = user.expiresAt, exp - now() >= 20 * 60 {
completion?(); return // 还新鲜,不续
}
http.request(refreshTokenAPI(refresh), singleTask: true) { resp in
guard user.isLogin else { completion?(); return } // 期间已登出,丢弃结果
if let access = resp.accessToken {
user.accessToken = access // 触发全局「凭证变化」通知
if let ttl = resp.expiresIn { user.expiresAt = now() + ttl }
}
if let newRefresh = resp.refreshToken { user.refreshToken = newRefresh } // 支持轮换
user.save()
if resp.isRefreshInvalidOrExpired { logout() } // 只有这两种失败才登出
completion?()
}
}
几处值得注意:
- 没有续期凭证时,如果本地还显示「已登录」,说明数据异常,直接登出。
- 过期时间为空时不会走「还新鲜」的分支,等于每次都续。
- 续期回调里先判断用户是否已经登出,已登出就丢弃结果,防止旧回调把凭证写回去。
- 服务端返回了新的续期凭证就覆盖保存,支持轮换。
- 无论成败都会持久化一次用户模型。
4.2 触发点
| 触发点 | 是否强制 | 说明 |
|---|---|---|
| 每个 HTTP 请求之前 | 否 | 取票和两个换手机号接口除外 |
| 服务端返回认证失败 | 是 | 只触发续期,不重放原请求 |
| 页面基类的生命周期 | 否 | 页面出现时 |
| 启动、回前台 | 否 | 启动时续完再拉账户信息 |
| 网络恢复可达 | 否 | 可达性监听里 |
| 交易长连接鉴权失败 | 是 | 续成功后凭证变化会触发重连 |
| H5 取凭证、要求换新凭证 | 视情况 | 通过 JS 桥 |
换手机号的两个接口被排除,是因为它们用的是另一种凭证,请求前的预续期会干扰它们。这是后来补上的安全处理。
4.3 请求前预续期:发了就走,不等
HTTP 客户端在发请求之前调用一次检查,但不等它的结果,当前请求仍然用内存里现有的凭证发出去。
这能成立,靠的是「提前 20 分钟」这个余量:续期的那一刻,旧凭证还有效,原请求不会因此失败。
4.4 并发:单飞的语义
续期请求开了单飞。单飞以地址为键:第一个调用真正发请求;在它回来之前,其余调用立即收到一个「请求中」的本地返回码。
去重做到了,但「等待者」拿到的是旧凭证。对 HTTP 来说问题不大,因为是提前续,旧凭证还有效;对「必须拿到新凭证才能继续」的调用方(例如 H5 明确要求换新凭证),这个语义就不够严格。
4.5 认证失败:强制续,不重放
服务端返回认证失败时,HTTP 客户端先把失败回调给业务,再强制续期一次。原请求不会自动重放,业务看到的就是这次失败。
这套方案把「认证失败」的概率压低在前面(提前续),而不是在后面兜住每一个失败的请求。
4.6 凭证变化如何带动交易长连接
访问凭证的属性观察器在值变化时发一个全局通知。交易长连接收到后,有凭证就连,没有就断:
登出时访问凭证被置空,同一个通知会让交易长连接断开。
4.7 登出清理
登出会清掉期货、证券的交易数据中心,用户资料字段,两张凭证和过期时间,交易账号与交易会话号,交易登录态,以及若干业务状态,然后持久化,并提示「已掉线,请重新登录」。用户模型(包括两张凭证)是归档到文档目录下的缓存文件,不在钥匙串里。
5. 踩过的坑
单飞标志泄漏。 曾有一个缺陷:长时间在后台之后再打开,App 账户已经掉线。修复是在「持有者已释放」的提前返回分支里补上在途标志的复位;修复前,这个分支返回后标志永远是「在途」,之后同一地址的请求都被直接返回「请求中」。续期请求本身没有绑定持有者,不走这个分支,所以这次修复和那次掉线之间的因果,从代码上只能说是同一类问题。
续期失败后的自动重试。 早期续期失败会在短暂延迟后自动再试一次,后来被删掉。触发点本来就很多,再叠加自动重试,弱网下会连续发出续期请求。
认证失败兜底只在一个重载里。 HTTP 客户端有两个请求重载(字典参数和数组参数),请求前的预续期两边都有,但「认证失败就强制续期」只写在字典参数那一个里。
等待者拿到的是旧凭证。 见 4.4。
6. 和「不续期、过期就登出」放在一起看
另一个项目的会话方案几乎是反过来的:登录回包里没有续期凭证,也没有业务会话的过期时间;请求之前不检查,任何一次请求的业务码表示无效、过期、被其他端踢下线或封停,就立刻登出。多个请求同时失败时,靠一个一次性标志只发一次登出通知;登出之后不重放刚才那次请求。
| 维度 | 本项目(续期) | 另一个项目(不续期) |
|---|---|---|
| 凭证 | 访问凭证 + 续期凭证 + 过期时间 | 只有会话标识,没有过期时间 |
| 请求前 | 检查剩余有效期,不足就续 | 不检查 |
| 失效信号 | 认证失败;续期凭证无效或过期 | 业务码:无效、过期、被踢、封停 |
| 失效后 | 强制续;续期凭证失效才登出 | 立即登出 |
| 并发 | 续期单飞 | 登出通知只发一次 |
| 失败请求重放 | 不重放 | 不重放 |
| 弱网 | 网络失败不登出 | 不涉及续期,只看业务码 |
| 本地存放 | 文档目录归档 | 文档目录归档 |
| 客户端复杂度 | 较高:触发点、单飞、轮换、竞态 | 很低:有效性完全交给服务端 |
两种做法背后的取舍:
- 交易类 App 更怕「被踢出去」。用户正在看盘、准备下单,一次过期就要重新输入 App 密码再输交易密码,代价很高。所以值得在客户端承担续期的复杂度。
- 另一个项目更怕「客户端状态不一致」。它有三套凭证各管各的,有效性交给服务端,客户端只要保证「失效了就干净地登出一次」。
- 两边都没有做「挂起请求 → 续期成功后重放」。本项目是靠提前续把失败概率压低;另一个项目是因为根本没有续期这条路。
7. 可以改进的地方
下面每一条都是方向,不是已经做完的改动。
| 现在的做法 | 问题 | 改进方向 |
|---|---|---|
| 单飞让等待者立即返回 | 等待者拿到旧凭证 | 续期用 actor 或串行队列包住,保证唯一在途,等待者挂在同一个结果上 |
| 认证失败只续不重放 | 这次请求对业务表现为失败 | 认证失败的请求先挂起,续期成功后用新凭证重放一次;续期失败统一失败 |
| 兜底只在一个重载里 | 数组参数的请求认证失败不会触发续期 | 两个重载走同一个响应处理 |
| 凭证归档在文档目录 | 和设备标识等不是同一安全级别 | 两张凭证改放钥匙串 |
| 过期时间为空就每次都续 | 异常数据下请求量放大 | 过期时间为空时按默认有效期处理 |
| 阈值写死 20 分钟 | 与服务端有效期强耦合 | 按「有效期的一定比例」计算 |