一、短 Token 之后还缺什么
access token 的有效期通常只有十几分钟,这是为了缩小泄露窗口。但短寿命会带来两个必须补上的能力:
- 续期:玩家不可能每十五分钟重新输一次密码,客户端要能静默换到新 Token。
- 立即失效:封号、踢人、改密、注销之后,旧 Token 不能继续用到自然过期。
Refresh Token(下称 RT)解决续期,黑名单或版本号解决立即失效。两者经常被放在一起讲,但它们的存储结构、失效范围和失败模式完全不同,不能用一个“Token 表”糊弄过去。
先明确一个前提:access token 是无状态验签的,网关不查存储就能放行。这意味着任何“立即失效”的能力都必须来自额外的一层有状态检查。区别只在于这层检查是按单枚 Token 查、按账号查,还是按版本号查。
二、Refresh Token 轮换
最朴素的 RT 实现是“一枚长期有效的随机字符串,刷新时原样再用”。它有两个致命问题:RT 泄露后攻击者可以长期使用;服务端无法区分“正常刷新”和“攻击者重放”,因为两者带的是同一枚 Token。
轮换(rotation)把 RT 变成一次性凭证:每次刷新成功,服务端签发一枚新的 RT,同时把旧 RT 标记为已使用,旧 RT 再次出现就视为异常。为了让“异常”可以级联,还需要引入 token family 的概念:一次登录产生的所有 RT 属于同一条 family,任何一枚旧 RT 被重放,整条 family 立即吊销,包括当前最新的那一枚。
| 事件 | 服务端动作 | 客户端看到的结果 |
|---|---|---|
| 正常刷新 | 旧 RT 标记已用,签发同 family 的新 RT | 拿到新 access token 与新 RT |
| 旧 RT 重放 | 吊销整条 family,记录重放原因 | 刷新失败,必须重新登录 |
| RT 过期 | 拒绝刷新,删除该记录 | 刷新失败,必须重新登录 |
| 主动注销 | 吊销当前 family 或账号全部 family | 当前设备或全部设备退出 |
| 改密、封号 | 吊销账号全部 family,并拉黑当前 access token | 所有设备立即失效 |
轮换的关键不在“换”,而在旧 RT 必须真的作废。如果服务端只是签发新 RT,却仍然接受旧 RT,那么 family 检测永远不会触发,轮换只是增加了存储量。
有一个工程边界要提前考虑:客户端网络抖动可能导致“服务端已经轮换成功,但响应在回程丢失”,客户端重试时带的是旧 RT,看起来像重放。常见做法是客户端对刷新请求做单飞(single-flight)并缓存结果;服务端可以给旧 RT 一个很短的“幂等窗口”,窗口内重复提交返回同一枚新 RT,窗口外才判定为重放。这个窗口必须很小,否则会削弱重放检测。
三、两种黑名单
“立即失效”在工程上有两种主流实现,选择取决于要失效的粒度。
| 维度 | 按 jti 精确拉黑 | 按账号 token_version 全局失效 |
|---|---|---|
| 粒度 | 单枚 access token | 一个账号的所有 access token |
| 存储键 | auth:jti:<jti> | auth:ver:<account_id> |
| TTL | 等于该 Token 剩余有效期 | 长期保存,或随账号缓存 |
| 写入成本 | 每次注销写一个键 | 改密、封号时自增一次 |
| 读取成本 | 每次请求查一次键 | 每次请求读一次版本号,可本地缓存 |
| 生效范围 | 只影响被点名的 Token | 影响该账号全部设备 |
| 典型场景 | 单设备登出、单枚 Token 泄露 | 改密、封号、全端下线 |
按 jti 拉黑最精确:Token 里带唯一 jti,注销时把 jti 写入 Redis,TTL 设为 exp - now。过期后键自动消失,不会无限增长。代价是每个请求都要查一次 Redis,或者网关本地缓存一小段时间,接受“最多缓存时长内仍放行”的窗口。
按 token_version 失效更粗,但读放大更低:账号表里存一个整数,签发 Token 时写进 payload,验签后比较 Token 里的版本与当前版本是否一致。改密、封号时把版本加一,所有旧 Token 同时失效。它不需要为每一枚 Token 建键,但需要保证版本号在网关侧足够新鲜,通常用短 TTL 本地缓存加变更通知。
实际系统往往两者并用:token_version 处理账号级安全事件,jti 处理单设备登出与“我看到可疑登录,踢掉那一台”。只选一种也能跑,但要清楚它覆盖不了哪些场景。
四、存储设计与原子性
RT 与黑名单都适合放 Redis,但用法和普通缓存不同。
键设计要带业务前缀和明确的生命周期。例如 auth:rt:<token> 存 RT 记录,auth:family:<family_id> 存 family 状态,auth:jti:<jti> 存拉黑标记,auth:ver:<account_id> 存版本号。RT 记录的值至少包含 account_id、family_id、expires_at、used;family 记录至少包含 account_id、revoked、reason。
TTL 必须显式设置。RT 按业务寿命设置,jti 按 Token 剩余寿命设置,版本号长期保留。没有 TTL 的黑名单迟早会把 Redis 撑满,而且这些键永远不会被访问,属于纯粹的垃圾。
轮换必须原子。“读取旧 RT、判断是否已用、标记已用、写入新 RT”四步如果分开执行,并发刷新会同时通过检查,签发两枚新 RT,轮换语义被破坏。生产实现用 Lua 脚本或 Redis 事务把这四步包在一起;脚本里只做键的读写与条件判断,不调用外部服务。
不要只靠数据库。数据库当然能存 RT 和黑名单,但每次请求都查库会把认证路径的延迟抬到毫秒级,并且给主库带来写放大。Redis 的价值不只是快,还有原生 TTL 和原子脚本。数据库适合做审计与最终事实,不适合做每次请求都要命中的在线判断。
Redis 的淘汰策略要单独配置。认证键不能被 allkeys-lru 随便淘汰,否则内存压力一上来,黑名单先丢,封号立刻失效。生产环境通常给认证数据独立实例或独立逻辑库,设置 noeviction,并监控内存水位。持久化方面要接受“Redis 丢数据会导致部分吊销失效”的现实,关键封号同时写数据库并触发全量重连。
五、Go 实现 RefreshStore
下面的实现把 family、轮换、重放检测和账号级吊销放在一个进程内存储里。它不依赖 Redis,目的是把状态机写清楚;生产替换时,把互斥锁换成 Redis Lua,把 map 换成键空间即可。
// file: ch04/token/refresh.go
// tier: L1
// verify: go test ./ch04/token/
package token
import (
"crypto/rand"
"encoding/base64"
"errors"
"sync"
"time"
)
var (
ErrRefreshNotFound = errors.New("token: refresh token not found")
ErrRefreshExpired = errors.New("token: refresh token expired")
ErrRefreshReused = errors.New("token: refresh token reused")
ErrFamilyRevoked = errors.New("token: refresh token family revoked")
)
// IssuedRefresh 是签发给客户端的刷新凭证及其归属信息。
type IssuedRefresh struct {
Token string
FamilyID string
AccountID uint64
ExpiresAt time.Time
}
type refreshRecord struct {
FamilyID string
AccountID uint64
ExpiresAt time.Time
Used bool
}
type refreshFamily struct {
AccountID uint64
Revoked bool
Reason string
}
// RefreshStore 保存 refresh token 的轮换状态。
// 生产环境应放在 Redis:记录有 TTL,轮换用 Lua 或事务保证原子性。
type RefreshStore struct {
mu sync.Mutex
ttl time.Duration
now func() time.Time
records map[string]*refreshRecord
families map[string]*refreshFamily
}
func NewRefreshStore(ttl time.Duration, now func() time.Time) *RefreshStore {
if now == nil {
now = time.Now
}
return &RefreshStore{
ttl: ttl,
now: now,
records: make(map[string]*refreshRecord),
families: make(map[string]*refreshFamily),
}
}
// Issue 为一个账号开启新的 token family,并返回第一枚 refresh token。
func (s *RefreshStore) Issue(accountID uint64) (IssuedRefresh, error) {
if s.ttl <= 0 {
return IssuedRefresh{}, errors.New("token: invalid refresh ttl")
}
familyID, err := randomID()
if err != nil {
return IssuedRefresh{}, err
}
s.mu.Lock()
defer s.mu.Unlock()
return s.issueLocked(familyID, accountID)
}
// Rotate 用旧 RT 换新 RT。旧 RT 立即标记为已使用:
// 正常客户端只持有最新一枚,重复使用旧 RT 视为泄露或重放。
func (s *RefreshStore) Rotate(token string) (IssuedRefresh, error) {
if token == "" {
return IssuedRefresh{}, ErrRefreshNotFound
}
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.records[token]
if !ok {
return IssuedRefresh{}, ErrRefreshNotFound
}
fam := s.families[rec.FamilyID]
if fam == nil || fam.Revoked {
return IssuedRefresh{}, ErrFamilyRevoked
}
if rec.Used {
fam.Revoked = true
fam.Reason = "refresh token reuse"
return IssuedRefresh{}, ErrRefreshReused
}
if !s.now().Before(rec.ExpiresAt) {
delete(s.records, token)
return IssuedRefresh{}, ErrRefreshExpired
}
rec.Used = true
return s.issueLocked(rec.FamilyID, rec.AccountID)
}
// RevokeFamily 立即吊销整条 family,用于注销、改密与踢下线。
func (s *RefreshStore) RevokeFamily(familyID string) error {
s.mu.Lock()
defer s.mu.Unlock()
fam, ok := s.families[familyID]
if !ok {
return ErrRefreshNotFound
}
fam.Revoked = true
fam.Reason = "revoked by caller"
return nil
}
// RevokeAccount 吊销一个账号下的所有 family,返回受影响的 family 数。
func (s *RefreshStore) RevokeAccount(accountID uint64) int {
s.mu.Lock()
defer s.mu.Unlock()
n := 0
for _, fam := range s.families {
if fam.AccountID == accountID && !fam.Revoked {
fam.Revoked = true
fam.Reason = "account revoked"
n++
}
}
return n
}
func (s *RefreshStore) issueLocked(familyID string, accountID uint64) (IssuedRefresh, error) {
token, err := randomID()
if err != nil {
return IssuedRefresh{}, err
}
expiresAt := s.now().Add(s.ttl)
s.records[token] = &refreshRecord{
FamilyID: familyID,
AccountID: accountID,
ExpiresAt: expiresAt,
}
if _, ok := s.families[familyID]; !ok {
s.families[familyID] = &refreshFamily{AccountID: accountID}
}
return IssuedRefresh{
Token: token,
FamilyID: familyID,
AccountID: accountID,
ExpiresAt: expiresAt,
}, nil
}
func randomID() (string, error) {
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf[:]), nil
}
几个设计点:
Issue 每次登录开启一条新 family,返回第一枚 RT。同一账号在多台设备登录会产生多条 family,这样“踢掉手机”不会影响平板。
Rotate 先查 family 是否已吊销,再判断 RT 是否已使用。已使用的 RT 再次出现时,直接把 family 标记为吊销并返回 ErrRefreshReused;调用方不需要自己记得“重放要连坐”,状态机内部完成。
Rotate 在标记旧 RT 已用之后才签发新 RT,且新 RT 继承同一个 family_id。如果签发失败(例如随机源出错),旧 RT 已经被消费,客户端只能重新登录;这是安全的失败方向,不能为了“体验”把旧 RT 恢复成可用。
RevokeFamily 用于单设备注销,RevokeAccount 用于改密、封号、全端下线。两者都只改 family 状态,不删除记录,保留审计线索;真正的清理交给 TTL。
随机数使用 crypto/rand 生成 32 字节再 base64url 编码。不要用 math/rand、时间戳或自增 ID,RT 是 bearer 凭证,可预测就等于可伪造。
六、失败场景:RT 不轮换,攻击者长期潜伏
某项目的 RT 有效期为 180 天,刷新时直接返回同一枚 RT,服务端只校验它是否存在。玩家在网吧登录后,设备上的恶意软件复制了 RT;攻击者从此可以随时刷新,拿到新的 access token。
因为 RT 不变,服务端日志里同一枚 RT 同时出现在两个城市,也没有任何机制把它判为异常。玩家改了一次密码,系统只更新了密码哈希,没有吊销 RT;攻击者继续用旧 RT 刷新,玩了三个月。直到玩家发现异地登录记录并申诉,运营才手动封号,但封号只删了账号缓存,RT 仍然有效。
复盘时的根因链很清楚:
- RT 不轮换,泄露后无法通过“旧凭证再次出现”发现异常。
- 没有 family 概念,无法在发现异常时一次性吊销整条链。
- 改密、封号没有级联到 RT 与 access token,安全事件只改了账号表。
- 日志记录了 RT 的使用,却没有做“同一 RT 多 IP”的检测与告警。
修复方案是轮换加 family:每次刷新签发新 RT,旧 RT 标记已用;旧 RT 再次出现时吊销整条 family;改密与封号调用 RevokeAccount;同时给 access token 加 jti 黑名单,保证从发现到生效不超过一个请求周期。
这个案例说明,RT 的寿命长不是问题,可重复使用才是问题。长寿命 RT 必须配合一次性使用、轮换和重放检测,否则它只是一枚有效期更长的密码。
七、上线检查清单
把上面的设计压缩成一份可以逐项勾选的清单:
- access token 有效期在 10 到 15 分钟,RT 有效期按业务设定且服务端可配。
- 每次刷新都签发新 RT,旧 RT 立即标记已用,不能“宽限期无限延长”。
- 同一次登录的 RT 属于同一 family,重放时吊销整条 family。
- 注销、改密、封号、全端下线都能触发对应范围的吊销,并有测试覆盖。
jti黑名单的 TTL 等于 Token 剩余有效期,不写永久键。token_version在改密、封号时自增,网关缓存有上限和失效通知。- Redis 认证键使用独立实例或
noeviction,内存与命中率有监控。 - 刷新接口有速率限制与异常检测,同一 RT 多 IP、多设备并发刷新要告警。
小结
Refresh Token 负责“不用重新输密码也能续期”,黑名单与版本号负责“出事之后立即止损”。RT 的核心不是长寿命,而是一次性使用、轮换和 family 级联吊销;黑名单的核心不是“记下来”,而是 TTL 正确、粒度匹配、读写成本可接受。
存储上优先 Redis,利用原生 TTL 与原子脚本;数据库只做审计与最终事实。实现上让状态机内部完成“重放即连坐”,调用方只负责把错误翻译成重新登录或提示,不要把安全判断散落在业务代码里。
常见错误与边界
- RT 不轮换:同一枚 RT 可以反复使用,泄露后无法检测。必须一次性使用并签发新 RT。
- 轮换但旧 RT 仍可用:只是增加了存储,重放检测永远不会触发。旧 RT 必须真正作废。
- 黑名单永不过期:键越积越多,最终撑爆 Redis,还会拖慢查询。TTL 应等于 Token 剩余有效期。
- 注销只删本地缓存:网关本地删了,其他节点仍然认可旧 Token。吊销必须落到共享存储并通知所有验签方。
- 只做账号级失效:无法单独踢掉某一台设备;只做
jti又无法低成本处理全端下线。两者通常要配合。 - 刷新接口没有并发控制:同一 RT 并发刷新会签发多枚新 RT,轮换语义被破坏。服务端要原子化,客户端要单飞。
- 把 RT 放进 URL 或日志:RT 是长期 bearer 凭证,泄露后果比 access token 更严重。只放在请求体或安全存储中。
边界方面,Redis 不是绝对可靠:主从切换可能丢少量写入,导致部分吊销短暂失效。对封号这类高安全操作,除了写 Redis,还要写数据库并触发网关全量重连。跨区部署时,family 与版本号要按账号归属区存放,避免同一账号在不同区各自维护一套吊销状态。最后,轮换会带来客户端兼容成本,老版本客户端如果不支持保存新 RT,会在第一次刷新后掉线,灰度发布时要先确认客户端能力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。