《游戏服务端编程实践》4.2.3 Refresh Token 与黑名单机制

从短 Token 过期后的续期与注销需求出发,讲清 Refresh Token 轮换、token family 重放检测、jti 精确拉黑与 token_version 全局失效的取舍,给出 Redis 键设计与原子操作要点,并用 Go 实现可测试的 RefreshStore 与吊销测试。

一、短 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,会在第一次刷新后掉线,灰度发布时要先确认客户端能力。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist