49.5 登录态与令牌生命周期

一句话

登录态的本质是"服务端记得你",令牌只是它的一张凭证;生命周期设计的目标只有一个——任何时刻都能回答"这张凭证还有效吗",并且在需要时让它立刻失效。

四种形态:状态存哪,决定了撤销有多快

形态 状态存在哪 撤销延迟 每请求成本 水平扩展 适合
服务端 Session Redis / DB 立即 1 次 Redis 读取(亚毫秒) 好,但必须用共享存储(见 37.2 无状态化) 传统 Web、要即时踢人
自包含 JWT 客户端 ≤ TTL 0(本地验签约 80µs) 极好 高 QPS API、跨域、移动端
引用令牌 + 内省 令牌服务器 实时 1 个 RTT(1–5ms) 好 高敏操作、令牌不可自包含
混合:短 JWT + refresh + 会话中心 身份在 JWT、会话在服务端 ≤ access TTL,且可主动失效会话 本地验签 + 偶尔读会话 极好 默认选它

三种形态的成本对比(本地验签 80µs vs 每请求内省 2ms,差 25 倍)在网关侧已经算过一遍,见 47.4 鉴权与限流应放在哪一层。这里只算撤销这笔账。

五组时间参数,每个都在安全与体验之间切一刀

参数 典型值 它决定了什么 取太大的后果 取太小的后果
access_token TTL 5–15 分钟 撤销窗口的上界 被撤销的凭证还能用很久 刷新频繁,QPS 与失败率上升
refresh_token TTL 7–30 天 用户多久不用重新登录 长期离线令牌成为窃取目标 用户天天重登,投诉
refresh 轮转 每次刷新换新 令牌泄漏的暴露时长 — 并发刷新误杀(见下)
绝对会话上限 8 小时 / 30 天 一次登录最长能活多久 会话永不结束 长任务被打断
空闲超时 15–30 分钟 无操作多久登出 共享电脑风险 看一篇文章就被登出
时钟偏移容忍 60 秒 签发方与验证方的时间差 过期令牌被放行 时钟差几十秒就全员失败

一个必须算的容量数字:refresh 请求量 ≈ 日活 × 日均在线时长 / access TTL。以 100 万日活、日均在线 2 小时、access TTL 15 分钟计:1e6 × 120 / 15 ≈ 800 万次/天 ≈ 93 QPS。这个量看起来很小,但它集中在整点(用户同时打开应用)——峰值按均值 5–10 倍设计,见 34.1 性能指标 (延迟 / 吞吐 / P99)。

iam 的实际参数:24 小时 TTL,靠"每用户密钥"补撤销

marmotedu/iam (opens new window) 的 iam-apiserver 配置里,JWT 只有四个参数:

jwt:
  realm: JWT
  key: dfVpOK8LZeJLZHYmHdb1VdyRrACKpqoo   # 服务端签名密钥(校验长度 6–32)
  timeout: 24h                            # token 过期时间
  max-refresh: 24h                        # 允许续期的时间窗

24 小时的 access TTL 是个很大的撤销窗口。iam 用另一套机制把这个窗口补回来:每个用户一把密钥。secret 表里有 secretID(当 kid 用)、secretKey、expires,iam-authz-server 的 cache auth 策略按 kid 从缓存取密钥验签:

机制 作用 撤销粒度
jwt.key(全局) 签发所有 token 全局:换密钥 = 所有人重登
secret.secretKey(每用户) 给该用户签发的 token 验签 单用户:删 / 过期该 secret 即刻失效
max-refresh 限制续期窗口 到点后 refresh 也换不出新 token
变更通知 SecretChanged 事件 → 各节点重载 撤销的生效时间 ≈ 通知延迟(秒级)

🔑 这是"短 TTL vs 可撤销密钥"的经典折中:不想把 TTL 压到 5 分钟,就得让密钥可以被单独吊销。选择哪条路取决于你的密钥分发与热加载能力——iam 能这么干,是因为它有 PolicyChanged / SecretChanged 的广播通道(见 49.3 权限中心与权限矩阵)。

撤销的四条路径,成本差三个数量级

手段 生效延迟 成本 粒度 代价
短 TTL(什么都不做) ≤ TTL 0 全局 体验与刷新流量
黑名单(按 jti 记 Redis) 立即 每条约 100B × QPS × TTL 单令牌 内存与一次读操作
token_version(用户级版本号) ≤ TTL 或立即 1 次读(可本地缓存) 单用户全部令牌 版本号变更要能传播
会话中心(每请求查 sid) 立即 1 次读或缓存 单会话 每请求一次依赖调用

黑名单的内存账要提前算:10k QPS、access TTL 15 分钟、每个 jti 约 100 字节 → 10000 × 900 = 900 万条 ≈ 858 MB(本机按 100B/条估算)。把"每个签发的令牌都进黑名单"当默认方案,等于用 858MB 内存换一个本来由 TTL 兜住的窗口——黑名单只该装"提前撤销"的那部分,不该装所有令牌。

# 撤销一个具体令牌(TTL 与 access TTL 对齐,到期自动清理,不用扫库)
$ redis-cli -p 6390 SETEX revoked:jti:9f2c 900 1
OK
$ redis-cli -p 6390 TTL revoked:jti:9f2c
900
$ redis-cli -p 6390 EXISTS revoked:jti:9f2c
1

# 用户级版本号:改密 / 登出全部设备时 +1,验签时比对 jwt.tv 与这个值
$ redis-cli -p 6390 SET user:01H8A:tv 7
OK
$ redis-cli -p 6390 INCR user:01H8A:tv
8

# 900 秒后黑名单条目自动消失(本机实测:SETEX 1 秒后 EXISTS 返回 0)
$ redis-cli -p 6390 EXISTS revoked:jti:9f2c
0

推荐组合:短 TTL(兜底)+ token_version(踢单人全部会话)+ 会话表(按设备踢)。黑名单只在"必须立刻让某一个具体令牌失效、又不想影响该用户其他设备"时用——这是最窄的场景,也正好是黑名单唯一不可替代的场景。

刷新轮转与重放检测:能跑的状态机

refresh token 一次性使用、每次刷新换新(rotation),旧令牌被再次使用就判定为泄漏,撤销整个家族:

package main

import "fmt"

// 一次登录 = 一个 family;family 内令牌一次性使用、链式轮转
type token struct {
	id, family string
	used       bool
}

type store struct {
	tokens  map[string]*token
	revoked map[string]bool // family -> 是否整体撤销
	n       int
}

func newStore() *store { return &store{tokens: map[string]*token{}, revoked: map[string]bool{}} }

func (s *store) issue(family string) *token {
	s.n++
	t := &token{id: fmt.Sprintf("rt-%03d", s.n), family: family}
	s.tokens[t.id] = t
	return t
}

// rotate:旧令牌立刻作废并签发新令牌;旧令牌若已被用过 → 判定重放,整族撤销
func (s *store) rotate(oldID string) (next *token, msg string) {
	old, ok := s.tokens[oldID]
	switch {
	case !ok:
		return nil, "令牌不存在"
	case s.revoked[old.family]:
		return nil, "family 已撤销"
	case old.used:
		s.revoked[old.family] = true // 宁可踢掉整族,也不放过攻击者
		return nil, "检测到重放,撤销整个 family"
	}
	old.used = true
	return s.issue(old.family), "ok"
}

func main() {
	s := newStore()
	first := s.issue("fam-A")
	cur := first
	fmt.Println("| 步骤 | 动作 | 结果 |")
	fmt.Println("|---|---|---|")
	fmt.Printf("| 1 | 登录,下发 %s | ok |\n", first.id)
	for i := 2; i <= 5; i++ {
		next, msg := s.rotate(cur.id)
		fmt.Printf("| %d | 轮转 %s → %s | %s |\n", i, cur.id, next.id, msg)
		cur = next
	}
	_, m := s.rotate(first.id) // 攻击者重放第 1 个令牌
	fmt.Printf("| 6 | 攻击者重放 %s | %s |\n", first.id, m)
	_, m = s.rotate(cur.id) // 受害者手里的最新令牌也一起失效
	fmt.Printf("| 7 | 受害者续期 %s | %s,必须重新登录 |\n", cur.id, m)

	const qps, ttl = 10000.0, 900.0
	entries := qps * ttl
	fmt.Printf("\n若每个签发的 access token 都进黑名单:%.0f 条 × 100B ≈ %.0f MB\n",
		entries, entries*100/1024/1024)
}

输出(本机实测):

| 步骤 | 动作 | 结果 |
|---|---|---|
| 1 | 登录,下发 rt-001 | ok |
| 2 | 轮转 rt-001 → rt-002 | ok |
| 3 | 轮转 rt-002 → rt-003 | ok |
| 4 | 轮转 rt-003 → rt-004 | ok |
| 5 | 轮转 rt-004 → rt-005 | ok |
| 6 | 攻击者重放 rt-001 | 检测到重放,撤销整个 family |
| 7 | 受害者续期 rt-005 | family 已撤销,必须重新登录 |

若每个签发的 access token 都进黑名单:9000000 条 × 100B ≈ 858 MB

第 7 行是这套机制的代价:安全上它做对了(攻击者拿到的那条链全废),体验上它冤枉了受害者(正常用户被踢下线)。

⚠️ 严格一刀切的 rotation 在移动端会大量误杀:弱网下客户端没收到响应会重试同一次刷新,两个并发请求带同一个 refresh token,第二个必然被判为重放。工程上要留 10–30 秒的宽限期——旧令牌在轮转后 N 秒内允许再换一次,只在"用过一次且超过宽限期"时才判重放。宽限期就是误杀率与攻击窗口的交换比:30 秒宽限 = 攻击者多 30 秒窗口,换来弱网用户不被踢。

令牌里放什么:身份固定,权限别放

放进去 理由 不放 理由
sub(uid) 全链路唯一标识 角色列表 加角色要等 TTL 过期才生效
tenant(当前租户) 判定只依赖一处,见 49.4 多租户权限模型 权限码明细 体积线性膨胀;权限变更延迟生效
sid 登出与设备管理能定位会话 手机号 / 邮箱 / 真名 令牌是明文可读的,隐私泄漏面
jti 单令牌撤销 密码哈希、密钥 凭据绝不出现在客户端
iat / exp / iss / aud 验签与串用防护,见 49.2 单点登录 (SSO) 无过期时间的"永久令牌" 撤销只能靠换密钥,等于全员重登

体积的量级参考:一个 7 声明、约 339 字节的 ES256 令牌,每请求多带 339 字节;塞进 30 个权限码后约 800–1200 字节,10k QPS 下多出 5–9 MB/s 的纯头部流量,且移动端弱网首包明显变慢。

落地:指名 / 可抄 / 可验 / 可退

指名:会话与撤销列表用 Redis(SETEX + TTL 天然对齐令牌生命周期,比数据库定时清理表省事得多);想直接用成熟实现,用 Keycloak 的 session 管理或 iam 的 secret + JWT 方案;网关侧的令牌校验与透传见 47.2 API 网关。

可抄(iam 的登录与续期接口,来自项目文档;未验证:本机未部署 iam 实例):

# 登录:Basic 认证换 JWT(admin:Admin@2021 是文档里的示例账号)
curl -XPOST -H "Authorization: Basic $(echo -n 'admin:Admin@2021' | base64)" \
  http://iam.api.marmotedu.com:8080/login
# → {"expire":"2021-10-05T14:34:07+08:00","token":"eyJhbGciOiJIUzI1NiIs..."}

# 续期:Bearer token 换新 token(max-refresh 窗口内有效)
curl -XPOST -H "Authorization: Bearer $TOKEN" http://iam.api.marmotedu.com:8080/refresh

可验(本机真实输出;先起一个临时实例 redis-server --port 6390 --daemonize yes --save ''):三个断言必须成立——TTL 与 access TTL 一致、EXISTS 在撤销后为 1、到期后自动变 0。第三条最重要:如果到期后 EXISTS 还是 1,说明写入时把 TTL 写成了永久,黑名单会无界增长。

可退:撤销列表是"提前失效"的增强手段,不是唯一防线——它挂了应该 fail-open 并告警,而不是 fail-close。理由是 TTL 仍在兜底:最坏情况退化成"撤销延迟 ≤ TTL",而 fail-close 会把全站打成 503。这个判断与授权判定的选择相反(授权必须 fail-close),因为两者的兜底不同:授权没有"自动过期"这层保险。

反模式

反模式 后果
access token TTL 设 24 小时且无撤销机制 撤销只能等过期;改密后旧令牌还能用一天
refresh token 永不过期、不轮转 一次泄漏 = 永久访问权,且无法检测
登出只清前端 localStorage 服务端令牌仍有效,抓包即可继续用
把权限列表塞进 JWT 权限收回要等 TTL;token 膨胀到几 KB
黑名单不设 TTL Redis 无界增长,最终 OOM 或被淘汰策略静默清空
撤销列表查询失败时 fail-close 一次 Redis 抖动 = 全站 503
严格 rotation 无宽限期 弱网重试被判重放,大量用户被误踢
用同一个全局密钥签所有用户 撤销一个人 = 换密钥 = 全员重登

自检

Q1 为什么"access token TTL"和"refresh token TTL"要分开设?各自由什么决定?

▸ 答案:它们服务两个相反的目标。access TTL 决定撤销窗口——越短越安全,代价是刷新频繁;refresh TTL 决定用户多久不用重新认证——越长体验越好,代价是长期令牌成为窃取目标。把两者合成一个值,等于强迫"最好的体验"和"最坏的撤销窗口"绑在一起:TTL 24 小时意味着撤销最长延迟 24 小时。正确做法是 access 5–15 分钟 + refresh 7–30 天 + refresh 轮转。

Q2 10k QPS、access TTL 15 分钟,把每个令牌都进黑名单要多少内存?这个数字说明什么?

▸ 答案:10000 × 900 = 9,000,000 条,每条约 100 字节 → 约 858MB(本机按 100B/条估算)。它说明"黑名单"不能当作常规撤销手段:常规撤销应该由短 TTL 承担,黑名单只装"必须提前失效"的少数令牌(比如检测到泄漏的那一个会话)。另一种更省的方案是 token_version:每个用户一个整数,改密时 +1,内存是 O(用户数) 而不是 O(QPS×TTL)。

Q3 严格 rotation 为什么会误杀正常用户?工程上怎么修?

▸ 答案:移动端弱网下,客户端发起刷新后没收到响应会重试,两个请求携带同一个 refresh token;服务端先处理的那个把它标记为已用,第二个就被判为重放,整族撤销,用户被踢下线。修法是给轮转加宽限期:旧令牌在轮转后 10–30 秒内允许再兑换一次(返回同一个新令牌或同一家族的另一个),超过宽限期才判重放。代价是攻击者的可用窗口从 0 秒变成 30 秒,换来的是误杀率从"弱网用户量级"降到接近 0。宽限期长度要按客户端重试策略定,不能拍。

Q4 撤销列表(黑名单)查询失败时该 fail-open 还是 fail-close?为什么与授权判定相反?

▸ 答案:fail-open。黑名单只是"提前失效"的加速器,最终防线是令牌自带的 exp——黑名单不可用时,最坏结果退化成"撤销延迟 ≤ TTL",业务仍可用。授权判定没有这种"自动过期"兜底:一旦放行,这次请求的越权就已经发生且不可撤回,所以必须 fail-close。判断标准是有没有一个不依赖该组件的兜底,而不是"安全组件一律 fail-close"。

Q5 为什么 tenant 可以放进令牌,而"权限列表"不应该放?

▸ 答案:两者的变更频率差三个数量级。tenant 在签发时确定,切换租户要重新签发(见 49.4 多租户权限模型),令牌内的值永远与签发时一致;权限会随审批、调岗、策略调整随时变化,放进令牌就意味着"收回权限要等 TTL 过期",而这个窗口恰好是权限治理最不能容忍的。规则是:签发时固定、变更需要重新认证的东西进令牌;随时可能变、且要求实时生效的东西不进令牌。

关联