KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
07 · 生产边界:从单实例到真实部署 — keel 龙骨
前六章的所有结论都有一个共同的前提:一个进程、一份内存、一把密钥、一段 http。 这一章逐条把这个前提拆掉。每一节都长成同一个形状: 教学替身是什么 → 真实替换点是什么 → 不换会出什么事。
前六章的所有结论都有一个共同的前提:一个进程、一份内存、一把密钥、一段 http。
这一章逐条把这个前提拆掉。每一节都长成同一个形状:
教学替身是什么 → 真实替换点是什么 → 不换会出什么事。
一、现场:教学能跑,上线第一周就出事
四个典型现象,都来自「教学环境与生产环境只差一个部署形态」:
- 滚动重启之后,所有人被踢下线。 会话在进程内存里,重启即清空。
- 扩到两个副本之后,登录开始「有时成功有时失败」。 第一步落在副本 A、
第二步落到副本 B,B 说「这个 code / 这个state我不认识」。 - 换了一次签名密钥,所有在途令牌全部失效。 应用的 JWKS 缓存里只有旧密钥,
或者代码只用「唯一那把密钥」验签。 - 加了反向代理之后,所有应用开始报签发者不对。 IdP 生成令牌时用的是内部地址,
应用配置里写的是对外域名。
这四个现象有一个共同点:它们在单实例 + 本地 http 下不可能出现。
所以这一章的问题不是「怎么加固」,而是「哪些默认值在换部署形态时必须一起换」。
二、会话存储:为什么多副本必须共享
链路上有三类「一次性的状态」,最容易被忽略的是它们必须在多个副本之间共享:
| 状态 | 用途 | 生命周期 | 现存哪里(教学) | 真实替换 |
|---|---|---|---|---|
state / nonce / code_verifier |
本次登录的事务上下文 | 一次登录 | 应用进程内存 | 共享缓存或签名过的 cookie,带 TTL |
授权码 code |
换 token 的凭据 | 一次兑换 | IdP 进程内存 | 共享存储 + 单次消费的原子操作 |
| IdP 会话 | 「已登录」的载体 | 会话期 | IdP 进程内存 | 共享存储 / 会话服务,可批量失效 |
flowchart TD
subgraph U["浏览器"]
B1["cookie:已登录载体"]
B2["回跳:state + code"]
end
subgraph LB["入口(HTTPS 终结 + 负载均衡)"]
L1["代理层"]
end
subgraph IDP["IdP(多副本)"]
P1["副本 A"]
P2["副本 B"]
end
subgraph APP["应用(多副本)"]
A1["实例 A"]
A2["实例 B"]
end
S1[("共享会话存储")]
S2[("授权码 / state 存储<br/>TTL + 单次消费")]
B1 --> L1
L1 -->|"① 落到副本 A:发起授权"| P1
P1 -->|"写 code"| S2
P1 -->|"写会话"| S1
B2 --> L1
L1 -->|"② 落到副本 B:换 token"| P2
P2 -->|"读 code(必须读得到)"| S2
P2 -->|"读会话"| S1
P2 -->|"③ 用对外域名生成 iss"| A1
A1 -->|"④ 校验 iss 与 aud"| A2
P1 -.->|"副本 A 内存里的 code:副本 B 看不见"| X1["Code not valid / state 不符<br/>偶发失败"]
L1 -.->|"代理没传对外协议/主机名:iss 变成内部地址"| X2["issuer 不匹配<br/>全体验签失败"]
三类状态里,state 那一类最常被漏掉:它看起来只是「一次性的小数据」,
随手放进了进程内存或 sessionStorage。等到多副本上线,「偶尔失败」就开始了——
而且它偶发、难复现,正好是最贵的故障类型(06 章故障④)。
三、令牌寿命与刷新策略
主案例里两个寿命数字是:
expires_in = 300
refresh_expires_in = 1800
它们的含义不同,不要把它们看成「一个短一个长」这么简单:
| 数字 | 它约束的是 | 它不约束的是 |
|---|---|---|
expires_in = 300 |
access token 的有效期(5 分钟) | 已经登录的会话;会话另有寿命 |
refresh_expires_in = 1800 |
refresh token 的有效期(30 分钟) | 用户「必须重新输密码」的时间——刷新成功的话可以继续 |
设计时要写下来的两句话是:「access token 泄露后的窗口有多长」 与
「用户最长多久必须重新认证一次」。两个问题对应两个不同的数字。
反直觉的那一条:轮换不等于作废
第一次 refresh -> 200
新的 refresh_token 与旧的是同一个吗? False
用【旧】refresh_token 再换一次 -> 200
用【新】refresh_token 再换一次 -> 200 (应该还能用)
读法要准确:第一次刷新确实轮换了 refresh token(新的与旧的不同),
但旧的那个仍然能换成功。 也就是说,这个实现在默认配置下
只做轮换、不做 reuse detection——它不会因为「旧令牌又被用了」而判定泄露。
这条为什么重要:
- 「轮换」解决的是旧令牌泄露面收敛(每次刷新后多一把可撤销的令牌);
- 「检测到旧令牌被重用」是另一件事,它需要实现方显式开启/实现
(RFC 9700 把「公开客户端的刷新令牌必须轮换或绑定发送方」列为要求,
并把「旧令牌再次出现」当作泄露信号去处理——但信号要怎么处理,是实现策略)。 - 所以:如果你指望「刷新轮换」自动发现令牌被盗,它不会。
要么打开 reuse detection,要么把窗口压到足够小,要么两者都做。
反过来的错误也要避免:不要以为「轮换」等于「旧令牌立刻作废」。
本课实验台的默认行为就与你可能预想的不同,而且它是照抄常见实现的默认值,
不是 bug。要验证自己的系统属于哪一种,只有一条路:拿旧的 refresh token 再换一次,看返回什么。
四、密钥轮换与 kid
为什么必须能同时存在两把密钥
kid 的作用(03 章)在轮换这件事上才完全显现:
不换密钥:任何时候只有一把,应用缓存一份就行——但泄露后无法收场
换密钥: 公布新旧两把,应用按 kid 选 —— 但要求应用支持「多把并存」
轮换的正确姿势是三步而不是一步:
① 公布新密钥(旧密钥仍在)→ 新签发的令牌用新 kid
② 等一个「最长令牌寿命」的窗口 ← 等所有在途令牌过期
③ 撤掉旧密钥
跳过第 ② 步就会全体掉线:旧令牌还在用旧 kid,而应用那边已经取不到它了。
同一个时刻本来就有多把密钥
一个容易被忽略的事实:「签名密钥」不止一把。同一个 realm 的 JWKS 里两把是use = sig / use = enc 的分工(03 章),而在实际报文里还能看到更多 kid:
ID Token 的 header: kid = 6sKOrtKwkpycDAQbwZzpRKJEE04b5-Zy3wCoCUI0ff4 (RS256)
refresh token 的 header: kid = c0e1c7b4-b238-40f6-9ca0-a294bd0b1b3a (HS512)
排障含义:看到「kid 不认识」时,先确认你查的是哪一套密钥。
按 kid 去一张表里找、找不到就报错,是常见的实现缺陷;
正确做法是按 kid 去该类型令牌对应的密钥集合里找,未命中时限流刷新一次。
应用侧怎么取密钥
| 做法 | 后果 |
|---|---|
| 每次校验都去取 JWKS | 令牌校验被 IdP 的 JWKS 端点拖住;该端点成为热点与单点 |
| 启动时取一次,永不刷新 | 轮换之后验不过,且要重启应用才能恢复 |
缓存 + 按 kid 未命中时刷新(且限流) |
常态无额外请求,轮换与紧急换密钥都能跟上 |
第三种是能用的那一档。注意「限流」那一半:一个伪造的、带随机 kid 的令牌
会打穿「未命中就刷新」的逻辑,把它变成对 IdP 的放大器。
五、HTTPS 与代理下的 issuer 一致性
iss 是字面量(03、06 章)。上了生产,这个字面量会经过至少三个地方,
它们必须一致:
IdP 配置里的 issuer
== 令牌里的 iss
== 应用配置里的期望值
== 用户实际上访问的地址
最常见的破坏者是代理终止 TLS:用户访问 https://sso.example.com,
代理转发到内部的 http://idp:8080。如果 IdP 按「自己看到的请求」生成 iss,
令牌里就会是内部地址,与应用配置不符——现象与「签名不对」几乎一样。
三件必须一起做的事:
- 让 IdP 知道对外地址(配置固定的 issuer,或正确传递并信任
X-Forwarded-Proto/X-Forwarded-Host); - 确认重定向也不被改写:授权回跳、
post_logout_redirect_uri都要用对外地址; - cookie 属性随之调整:HTTPS 之后
Secure才成立(06 章故障③)。
顺带把 06 章那个最容易被当成「小问题」的替换点收进来:
http → https 不是一个开关,它同时改变了 cookie 的可回带性、iss 的字面量和「哪些地址算可信来源」。联调环境也走 HTTPS,
是让这类差异在生产前暴露的唯一办法。
六、教学替身 → 真实替换点(总表)
| # | 教学替身(本课 / 配套项目) | 真实替换点 | 不换会出什么事 |
|---|---|---|---|
| 1 | MiniOP 单进程实现 |
多副本 IdP(或托管身份服务) | 单点故障:IdP 一停,所有应用登不进 |
| 2 | HS256 对称签名 | RS256 / ES256 + 公开 JWKS | 校验密钥 = 签发密钥,任何持有它的组件都能伪造身份 |
| 3 | 内存密钥环 + 固定 kid |
密钥集轮换,新旧并存,按 kid 选 |
换密钥即全员掉线;或旧密钥永久有效,等于没轮换 |
| 4 | 内存 codes / sessions / state |
共享存储 + TTL + 单次消费的原子操作 | 多副本下偶发失败;重启即掉线 |
| 5 | 固定时钟 Clock |
服务器时间同步 + 偏移监控 | exp / iat 判断成片误判(06 章故障①) |
| 6 | http://localhost + Secure; SameSite=None |
HTTPS + 按场景设对的 cookie 属性 | 脚本与浏览器行为分叉,联调通过、上线才炸 |
| 7 | 应用侧 Browser 类 |
真实浏览器 + 明确的令牌存放位置(内存 / HttpOnly cookie) | 令牌暴露面失控(03 章第五节) |
| 8 | 内存 refresh_tokens 映射 |
轮换 + (按风险)开启 reuse detection | 令牌被盗不会被发现;旧令牌一直可用 |
| 9 | 单 realm、单 issuer | 多 realm / 多 IdP,代理后 issuer 一致 |
iss 不一致的全量验签失败(05 章、本节) |
| 10 | 没有审计与账号生命周期 | 登录/登出/授权变更审计 + 离职回收通道 | 出事查不到人;离职权残留 |
| 11 | 不做限流与容量评估 | 授权/令牌端点限流,JWKS 缓存,容量按峰值登录量估算 | 登录风暴(例如早高峰集中登录)把 IdP 打穿 |
优先级建议(如果只能先换三件):第 4、3、6 项——
它们分别对应「上线就偶发失败」「换密钥就全掉线」「联调过了上线炸」三类最贵的故障。
动手:可观察结果
cd courses/identity/sso-engineering/course/project
python examples/06_token_lifetime_and_rotation.py
要观察并解释的四行:
expires_in = 300与refresh_expires_in = 1800——两种寿命,约束不同的事;- 第一次刷新后
新的 refresh_token 与旧的一样吗 = False——确实轮换了; - 用旧 refresh token 再换一次 → 成功——确实没有 reuse detection;
- 推进 1801 秒后刷新 →
Refresh token expired——refresh_expires_in说的就是这个点。
再补一次密钥轮换(测试 test_rotation_keeps_old_tokens_verifiable):
装入新密钥之后,旧 token 仍然验得过,新签发的 token 用新 kid,JWKS 里有两把。
这就是第四节那三步里的第 ①②步可观察的样子。
故障注入
| 注入 | 改法 | 预期现象 | 属于哪一类 |
|---|---|---|---|
| 清掉 IdP 会话存储(模拟重启) | 让会话只在内存里,然后清空 | 所有人被踢下线 | 会话存储不共享 |
让两个副本各存一半 code |
用两个 MiniOP 实例分别签发与兑换 |
兑换失败:Code not valid |
授权码存储不共享 |
| 只保留一把密钥 | 轮换时删掉旧 kid,再验旧 token |
unknown kid |
密钥轮换窗口 |
| 撤权后继续用旧 access token | 改角色后立刻用旧令牌调接口 | 仍然成功(直到 exp) |
自包含令牌的撤销延迟 |
| 用旧 refresh token 换新令牌 | 刷新后再用旧的换一次 | 成功——默认检测不到 | reuse detection 未开启 |
| 把 issuer 改成内部地址 | 配置里写 http://idp:8080 |
全体 iss 不匹配 |
代理下 issuer 不一致 |
前两行在配套项目里可以直接看到(用两个 MiniOP 实例各持一半状态即可);
后面几行属于部署形态级注入——它们的现象只在多副本、多密钥、有代理的环境里出现,
所以要拿自己的部署拓扑去对。
生产边界
这一章本身就是「生产边界」。落在你手上的动作清单是:
- 把三类一次性状态挪出进程内存(
state/code/ 会话),并给它们 TTL 与原子消费; - 让
expires_in/refresh_expires_in变成经过校准的值,
并明确写下「暴露窗口」与「重新认证周期」两句话; - 刷新策略选一个并验证:只轮换,还是轮换 + reuse detection——
验证方式只有一条:拿旧 refresh token 再换一次; - 密钥轮换做成三步流程(公布 → 等最长寿命 → 撤销),并让应用支持多把密钥;
iss在四处一致(配置 / 令牌 / 应用 / 用户访问的地址),代理下尤其要显式确认;- 把 06 章的六类断点变成可观测的日志字段,而不是出事时靠截图。
自测题
- 为什么
state这种「一次性小数据」也必须放进共享存储?它的失败表现是什么形状? expires_in = 300与refresh_expires_in = 1800分别约束什么?
「用户最长多久必须重新认证」由哪一个决定?- 「刷新令牌轮换了」与「旧刷新令牌作废了」是同一句话吗?给出一个验证方法。
- 密钥轮换的三步是什么?跳过中间那一步会发生什么?
- 加了反向代理之后,
iss不一致的根因是什么?为什么它的现象与「签名不对」很像? - 从本章总表里挑三项,说明为什么它们优先级最高(提示:分别对应哪三类故障)。
现在能解释什么
- 三类一次性状态为什么必须共享,以及不共享时的偶发失败长什么样;
- 两种令牌寿命各自约束什么,以及那段反直觉的刷新行为(轮换 ≠ 作废);
- 密钥轮换的三步与
kid的作用,以及为什么「同一时刻不止一把密钥」; - HTTPS 与代理下
iss一致性的四个必须对齐的地方; - 「教学替身 → 真实替换点」的十一项清单,以及如果只换三件该换哪三件。