access / refresh 凭证续期:提前续、只发一次、只在续期凭证失效时登出

项目 trade-app

  • 会话
  • 凭证
  • 续期
  • 并发
  • 登出

1. 背景与问题

这款 App 的 App 账户用的是 OAuth 风格的两张凭证:

  • 访问凭证:放在 HTTP 请求的鉴权头里,交易长连接建连时也用它;有效期较短。
  • 续期凭证:只用来换新的访问凭证,可能随续期一起轮换。

难点不在「怎么续」,而在「什么时候续、谁来续、续失败了怎么办」:

  1. 每个请求、每个页面出现、启动、回前台、网络恢复都可能发现凭证快过期,同一时刻会有很多个触发点。
  2. 交易用户经常在弱网下使用,不能因为一次续期超时就把人踢出去。
  3. 凭证一变,交易长连接要用新凭证重新鉴权。
  4. 用户可能在续期请求还没回来时就退出登录。

下面先看凭证分层,再看续期的检查、触发点、并发和失败处理,最后和另一种「不续期」的做法对比。


2. 凭证分层

App 里不止一种凭证。续期只针对第一种:

凭证 来源 用途 有效期管理
访问凭证 + 续期凭证 认证中心登录(密码、短信、三方) HTTP 鉴权、交易长连接鉴权、H5 本地记过期时间,不足 20 分钟就续
行情凭证(随服务器地址下发) 账户信息接口 行情长连接登录 同样 20 分钟阈值,到期重拉账户信息
交易会话号(期货、证券各一) 交易网关登录应答 交易请求、账户心跳 本地不记过期,由网关告知
认证中心登录访问凭证 + 续期凭证 + 过期 时间HTTP 请求交易长连接鉴权H5 取凭证账户信息接口行情凭证 + 行情服务器行情长连接登录交易登录交易会话号

交易会话号怎么保活和被踢,在交易会话一篇;本文只讲第一层。


3. 整体设计

检查有效期强制续期凭证变化通知用户模型访问凭证续期凭证过期时间戳是否已登录凭证变化时发全局通知检查有效期(是否强制, 完成回调)登出(提示文案)HTTP客户端请求前检查有效期地址 → 是否在途认证失败时强制续期交易连接收到凭证变化通知有凭证连接 / 无凭证断开鉴权失败时强制续期

方案可以概括成四句话:

  1. 提前续:剩余有效期不足 20 分钟就续,而不是等过期。
  2. 只发一次:续期请求走 HTTP 客户端的单飞,同一时刻只有一个在途。
  3. 兜底强制续:服务端说认证失败,就不管剩余时间强制续一次。
  4. 只在续期凭证失效时登出:断网、超时等失败不登出,等下一个触发点再续。

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?()
    }
}

几处值得注意:

  • 没有续期凭证时,如果本地还显示「已登录」,说明数据异常,直接登出。
  • 过期时间为空时不会走「还新鲜」的分支,等于每次都续。
  • 续期回调里先判断用户是否已经登出,已登出就丢弃结果,防止旧回调把凭证写回去。
  • 服务端返回了新的续期凭证就覆盖保存,支持轮换。
  • 无论成败都会持久化一次用户模型。
没有是否有否是否是
是否
是否
检查有效期有续期凭证?本地是已登录?登出回调强制?剩余不少于 20 分钟?续期请求(单飞)期间已登出?写新凭证、过期时间,轮换 续期凭证,持久化续期凭证无效或过期?

4.2 触发点

触发点 是否强制 说明
每个 HTTP 请求之前 否 取票和两个换手机号接口除外
服务端返回认证失败 是 只触发续期,不重放原请求
页面基类的生命周期 否 页面出现时
启动、回前台 否 启动时续完再拉账户信息
网络恢复可达 否 可达性监听里
交易长连接鉴权失败 是 续成功后凭证变化会触发重连
H5 取凭证、要求换新凭证 视情况 通过 JS 桥

换手机号的两个接口被排除,是因为它们用的是另一种凭证,请求前的预续期会干扰它们。这是后来补上的安全处理。

4.3 请求前预续期:发了就走,不等

HTTP 客户端在发请求之前调用一次检查,但不等它的结果,当前请求仍然用内存里现有的凭证发出去。

业务中台认证中心用户模型HTTP 客户端页面业务中台认证中心用户模型HTTP 客户端页面请求查有效期,不等结果不足 20 分钟,续期用当前凭证发原请求成功回调新凭证写入并通知

这能成立,靠的是「提前 20 分钟」这个余量:续期的那一刻,旧凭证还有效,原请求不会因此失败。

4.4 并发:单飞的语义

续期请求开了单飞。单飞以地址为键:第一个调用真正发请求;在它回来之前,其余调用立即收到一个「请求中」的本地返回码。

认证中心HTTP 客户端触发点 B触发点 A认证中心HTTP 客户端触发点 B触发点 A续期地址标记为在途发出续期续期立即返回「请求中」没拿到新凭证,执行完成回调新凭证清在途标记写入新凭证,执行完成回调

去重做到了,但「等待者」拿到的是旧凭证。对 HTTP 来说问题不大,因为是提前续,旧凭证还有效;对「必须拿到新凭证才能继续」的调用方(例如 H5 明确要求换新凭证),这个语义就不够严格。

4.5 认证失败:强制续,不重放

服务端返回认证失败时,HTTP 客户端先把失败回调给业务,再强制续期一次。原请求不会自动重放,业务看到的就是这次失败。

剩余不足 20 分钟续期成功网络失败,等下一个触发点服务端认证失败续期成功(原请求不重放)续期凭证无效或过期续期凭证无效或过期
正常预续期强制续期已登出

这套方案把「认证失败」的概率压低在前面(提前续),而不是在后面兜住每一个失败的请求。

4.6 凭证变化如何带动交易长连接

访问凭证的属性观察器在值变化时发一个全局通知。交易长连接收到后,有凭证就连,没有就断:

交易网关交易连接通知中心用户模型交易网关交易连接通知中心用户模型用旧凭证鉴权鉴权失败记下失败时间,冷却期内忽略重连强制续期写入新凭证凭证变化收到用新凭证重新建连与鉴权鉴权成功,开柜台命名空间

登出时访问凭证被置空,同一个通知会让交易长连接断开。

4.7 登出清理

登出会清掉期货、证券的交易数据中心,用户资料字段,两张凭证和过期时间,交易账号与交易会话号,交易登录态,以及若干业务状态,然后持久化,并提示「已掉线,请重新登录」。用户模型(包括两张凭证)是归档到文档目录下的缓存文件,不在钥匙串里。


5. 踩过的坑

单飞标志泄漏。 曾有一个缺陷:长时间在后台之后再打开,App 账户已经掉线。修复是在「持有者已释放」的提前返回分支里补上在途标志的复位;修复前,这个分支返回后标志永远是「在途」,之后同一地址的请求都被直接返回「请求中」。续期请求本身没有绑定持有者,不走这个分支,所以这次修复和那次掉线之间的因果,从代码上只能说是同一类问题。

续期失败后的自动重试。 早期续期失败会在短暂延迟后自动再试一次,后来被删掉。触发点本来就很多,再叠加自动重试,弱网下会连续发出续期请求。

认证失败兜底只在一个重载里。 HTTP 客户端有两个请求重载(字典参数和数组参数),请求前的预续期两边都有,但「认证失败就强制续期」只写在字典参数那一个里。

等待者拿到的是旧凭证。 见 4.4。


6. 和「不续期、过期就登出」放在一起看

另一个项目的会话方案几乎是反过来的:登录回包里没有续期凭证,也没有业务会话的过期时间;请求之前不检查,任何一次请求的业务码表示无效、过期、被其他端踢下线或封停,就立刻登出。多个请求同时失败时,靠一个一次性标志只发一次登出通知;登出之后不重放刚才那次请求。

另一个项目:不续期
是否
照常请求,不检查业务码表示失效?一次性标志,登出一次照常回调
本项目:续期
是否
请求前检查剩余时间不足 20 分钟?续期(单飞)照常请求认证失败强制续期,不重放续期凭证失效登出
维度 本项目(续期) 另一个项目(不续期)
凭证 访问凭证 + 续期凭证 + 过期时间 只有会话标识,没有过期时间
请求前 检查剩余有效期,不足就续 不检查
失效信号 认证失败;续期凭证无效或过期 业务码:无效、过期、被踢、封停
失效后 强制续;续期凭证失效才登出 立即登出
并发 续期单飞 登出通知只发一次
失败请求重放 不重放 不重放
弱网 网络失败不登出 不涉及续期,只看业务码
本地存放 文档目录归档 文档目录归档
客户端复杂度 较高:触发点、单飞、轮换、竞态 很低:有效性完全交给服务端

两种做法背后的取舍:

  • 交易类 App 更怕「被踢出去」。用户正在看盘、准备下单,一次过期就要重新输入 App 密码再输交易密码,代价很高。所以值得在客户端承担续期的复杂度。
  • 另一个项目更怕「客户端状态不一致」。它有三套凭证各管各的,有效性交给服务端,客户端只要保证「失效了就干净地登出一次」。
  • 两边都没有做「挂起请求 → 续期成功后重放」。本项目是靠提前续把失败概率压低;另一个项目是因为根本没有续期这条路。

7. 可以改进的地方

下面每一条都是方向,不是已经做完的改动。

现在的做法 问题 改进方向
单飞让等待者立即返回 等待者拿到旧凭证 续期用 actor 或串行队列包住,保证唯一在途,等待者挂在同一个结果上
认证失败只续不重放 这次请求对业务表现为失败 认证失败的请求先挂起,续期成功后用新凭证重放一次;续期失败统一失败
兜底只在一个重载里 数组参数的请求认证失败不会触发续期 两个重载走同一个响应处理
凭证归档在文档目录 和设备标识等不是同一安全级别 两张凭证改放钥匙串
过期时间为空就每次都续 异常数据下请求量放大 过期时间为空时按默认有效期处理
阈值写死 20 分钟 与服务端有效期强耦合 按「有效期的一定比例」计算
认证中心续期协调者请求认证中心续期协调者请求建议(未做):挂起并重放认证失败,挂起唯一一次续期新凭证用新凭证重放一次成功或最终失败