4. 安全刷新流程设计:双 Token 模型架构、刷新流程的时序图、关键决策点分析

双 Token 模型,说白了就是给用户发两把钥匙。一把是 Access Token,短命鬼,几分钟就过期;另一把是 Refresh Token,活得久,用来续命。我刚开始接触这个设计时也觉得多此一举,直到在项目里被 Access Token 过期后用户频繁掉线的问题折磨过,才明白这招有多妙。

4.1 双 Token 模型架构

先看整体架构。这个模型的核心思路是:Access Token 负责快速鉴权,Refresh Token 负责安全续期。两者分工明确,互不干扰。

核心原则:Access Token 不存数据库,Refresh Token 必须持久化存储。

我习惯把双 Token 模型拆成三层来看:

  • 客户端层:存两份 Token。Access Token 放内存或 localStorage,Refresh Token 放 httpOnly Cookie。
  • 网关层:拦截请求,校验 Access Token 有效性。过期了?放行到刷新接口。
  • 认证服务层:颁发 Token、验证 Refresh Token、生成新 Token 对。

你想想看,如果只用一个长寿命 Token,泄露了就等于账号被盗。双 Token 的好处是:Access Token 泄露了,攻击者最多用几分钟;Refresh Token 泄露了,因为存在 httpOnly Cookie 里,XSS 拿不到。

我的习惯:Access Token 用 JWT 格式,Payload 里只放 userId、role、过期时间。别塞太多东西,否则每次请求都传个大头,浪费带宽。

4.2 刷新流程的时序图

下面这张图是我自己画的,把整个刷新流程串了一遍。你看完应该能明白每一步在干什么。

双 Token 刷新流程时序图 客户端 网关/API 网关 认证服务 ① 请求业务接口(带 Access Token) 校验 Access Token 过期 / 无效 ② 返回 401 未授权 ③ 发起刷新请求(带 Refresh Token) 验证 Refresh Token 生成新 Token 对 ④ 返回新的 Access Token + Refresh Token ⑤ 用新 Access Token 重试原请求 校验通过 ⑥ 返回业务数据 关键点:整个刷新过程对用户无感知。客户端拦截器自动处理 401,刷新成功后重试原请求。 如果 Refresh Token 也过期了,直接跳转到登录页,让用户重新登录。

嗯,这里要注意:步骤③到④之间,认证服务会做一次 Refresh Token 的校验。我在项目中遇到过一个问题——如果用户短时间内连续刷新多次,认证服务会被刷爆。后来加了限流和 Token 复用检测才解决。

4.3 关键决策点分析

设计双 Token 模型时,有几个决策点特别容易踩坑。我一个个说。

4.3.1 Access Token 的过期时间

这个时间设短了,用户频繁刷新;设长了,泄露风险大。我个人习惯:

场景 推荐过期时间 理由
普通 Web 应用 15 分钟 平衡安全与体验
移动端 App 30 分钟 网络不稳定,减少刷新频率
高安全场景(金融、政务) 5 分钟 降低泄露影响窗口

我曾经踩过的坑:把 Access Token 过期时间设成 1 小时,结果用户反馈“用着用着突然退出登录”。排查发现是 Refresh Token 过期了,但 Access Token 还在有效期,导致状态不一致。后来我强制要求:Access Token 的过期时间必须小于 Refresh Token 的剩余有效期。

4.3.2 Refresh Token 的存储策略

Refresh Token 必须持久化,但存哪里?我见过三种方案:

  • 数据库存储:最安全,但每次刷新都要查库,性能有损耗。
  • Redis 存储:性能好,支持设置过期时间自动清理。我推荐这个。
  • JWT 自包含:不存服务端,但无法主动吊销。不推荐。

你想想看,如果 Refresh Token 用 JWT 自包含,用户修改密码后,旧的 Refresh Token 依然有效,直到过期。这就有安全漏洞了。所以我一直坚持用 Redis 存 Refresh Token,用户修改密码或主动登出时,直接删掉对应的 Key。

4.3.3 刷新接口的幂等性

刷新接口必须幂等。什么意思?就是同一个 Refresh Token 多次请求刷新,结果应该一样。我在项目中遇到过:用户网络抖动,前端发了两次刷新请求,结果生成了两个不同的 Token 对,导致用户状态混乱。

解决方案:

// 伪代码:刷新接口幂等处理
function refreshToken(oldRefreshToken) {
  // 开启分布式锁,防止并发
  lockKey = "refresh:" + oldRefreshToken
  if (!acquireLock(lockKey)) {
    // 没拿到锁,说明有其他请求在处理
    // 等待 100ms 后重试
    return waitAndRetry()
  }
  
  // 校验旧 Refresh Token
  if (!validateRefreshToken(oldRefreshToken)) {
    return error("invalid_token")
  }
  
  // 生成新 Token 对
  newAccessToken = generateAccessToken(userId)
  newRefreshToken = generateRefreshToken(userId)
  
  // 删除旧 Refresh Token,写入新 Refresh Token
  deleteRefreshToken(oldRefreshToken)
  saveRefreshToken(newRefreshToken)
  
  // 释放锁
  releaseLock(lockKey)
  
  return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}

我的习惯:刷新接口返回新 Token 对的同时,把旧的 Refresh Token 标记为已使用。这样即使攻击者拿到了旧的 Refresh Token,也无法再次刷新。

4.3.4 Token 轮换策略

每次刷新时,不仅要换 Access Token,Refresh Token 也要换。这叫 Token 轮换。为什么?

假设攻击者偷到了用户的 Refresh Token,同时用户自己也在正常使用。如果不轮换,攻击者和用户拿着同一个 Refresh Token,都能刷新。一旦轮换,用户刷新后,攻击者手里的旧 Refresh Token 就失效了。

我习惯的做法是:

  1. 用户发起刷新请求。
  2. 服务端验证旧 Refresh Token 有效。
  3. 生成全新的 Refresh Token(不是旧 Token 的延长版)。
  4. 删除旧 Refresh Token,保存新 Refresh Token。
  5. 返回新 Token 对。

嗯,这里有个细节:如果用户连续刷新,旧的 Refresh Token 还没用就被新的覆盖了,这没问题。但如果攻击者抢在用户刷新之前用了旧 Token,那用户刷新时就会失败。这种情况我遇到过,解决方案是:允许旧 Refresh Token 在短时间内(比如 30 秒)仍然可用,但只能使用一次。

4.3.5 刷新失败的处理策略

刷新失败怎么办?我总结了几种情况:

失败原因 处理方式 用户体验
Refresh Token 过期 清除本地 Token,跳转登录页 需要重新登录
Refresh Token 被吊销 清除本地 Token,跳转登录页 需要重新登录
网络超时 重试 2-3 次,间隔递增 短暂等待后自动恢复
服务端异常(5xx) 重试 1 次,失败后跳转登录页 可能被强制登出

我曾经踩过的坑:刷新失败后直接跳转登录页,用户正在填表单呢,突然被踢出去,气得骂娘。后来我改成:先尝试静默刷新,如果连续 3 次失败才跳转登录页,并且保留用户当前页面的状态(比如表单内容),登录后自动恢复。

4.4 总结一下关键点

双 Token 模型的设计,说白了就是三个字:短、换、锁。

  • 短:Access Token 寿命短,减少泄露风险。
  • 换:Refresh Token 每次刷新都换新的,防止重放攻击。
  • 锁:刷新接口加分布式锁,保证幂等性。

做到这三点,你的刷新流程基本就稳了。剩下的就是根据业务场景调整过期时间和重试策略。嗯,这部分内容比较多,但都是实战中摸爬滚打总结出来的,你照着做,能少踩不少坑。