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 过期",而这个窗口恰好是权限治理最不能容忍的。规则是:签发时固定、变更需要重新认证的东西进令牌;随时可能变、且要求实时生效的东西不进令牌。