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 刷新流程的时序图
下面这张图是我自己画的,把整个刷新流程串了一遍。你看完应该能明白每一步在干什么。
嗯,这里要注意:步骤③到④之间,认证服务会做一次 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 就失效了。
我习惯的做法是:
- 用户发起刷新请求。
- 服务端验证旧 Refresh Token 有效。
- 生成全新的 Refresh Token(不是旧 Token 的延长版)。
- 删除旧 Refresh Token,保存新 Refresh Token。
- 返回新 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 每次刷新都换新的,防止重放攻击。
- 锁:刷新接口加分布式锁,保证幂等性。
做到这三点,你的刷新流程基本就稳了。剩下的就是根据业务场景调整过期时间和重试策略。嗯,这部分内容比较多,但都是实战中摸爬滚打总结出来的,你照着做,能少踩不少坑。