KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 故障注入与排障:五类断点,一张表 — keel 龙骨
前面五章建立的每一条结论,都可以反着用一次:把它破坏掉,看现象。 这一章不给新概念,只做两件事:把五类故障做成「症状 → 去哪看 → 根因」的表, 再给一个固定的查看顺序——让排障从「逐个配置项比对」变成「按顺序排除」。
前面五章建立的每一条结论,都可以反着用一次:把它破坏掉,看现象。
这一章不给新概念,只做两件事:把五类故障做成「症状 → 去哪看 → 根因」的表,
再给一个固定的查看顺序——让排障从「逐个配置项比对」变成「按顺序排除」。
一、现场:为什么排障总是低效
一次登录失败,你能看到的信息分散在三处,而默认状态下没有一处是完整的:
| 你在哪 | 你能看到什么 | 你看不到什么 |
|---|---|---|
| 浏览器(Network) | 跳转序列、状态码、回跳参数、cookie 变化 | 令牌端点返回的 JSON 错误详情(除非看响应体)、IdP 内部为什么拒绝 |
| IdP 侧 | 会话、客户端配置、事件日志 | 应用侧的 state 是否比对、令牌被存到哪里 |
| 应用侧 | 自己的日志、state 的来源 |
IdP 内部状态、浏览器 cookie 的真实内容 |
低效的根源不是「信息少」,而是顺序错:从配置项开始比对,会同时怀疑五个地方;
而一次登录的链路是顺序的,坏点几乎总能用「它在第几步断」定位。
所以第一个要建立的习惯是:先确定断在第几步,再去查那一步对应的东西。
二、固定的查看顺序
flowchart TD
START["登录失败"] --> Q1{"第一步 GET<br/>状态码是 200 还是 302?"}
Q1 -->|"302"| Q1A["有可复用会话<br/>继续看回跳参数"]
Q1 -->|"200"| Q1B["没有会话:正常<br/>继续提交凭据"]
Q1 -->|"400"| Q1C["授权请求被拒<br/>→ 故障② redirect_uri"]
Q1B --> Q2{"提交凭据后<br/>状态码?"}
Q2 -->|"200 又是登录页"| Q2A{"错误文本里<br/>出现 cookie 相关?"}
Q2A -->|"是"| Q2B["→ 故障③ cookie 属性<br/>脚本没回带 Secure cookie"]
Q2A -->|"否"| Q2C["凭据错 / 或该用户被禁用"]
Q1A --> Q3{"回跳参数<br/>state 与发出的一致吗?"}
Q1B --> Q3
Q3 -->|"不一致或缺失"| Q3A["→ 故障④ state 丢失或可预测"]
Q3 -->|"一致"| Q4{"iss 与预期<br/>字面量相等吗?"}
Q4 -->|"不等"| Q4A["→ 故障⑥ issuer 字面量<br/>(含代理/端口/协议差异)"]
Q4 -->|"相等"| Q5{"换 token<br/>返回什么?"}
Q5 -->|"invalid_grant<br/>PKCE verification failed"| Q5A["→ 故障⑤ verifier 不符<br/>(且此次已消耗 code)"]
Q5 -->|"invalid_grant<br/>Code not valid"| Q5B["code 已用过<br/>检查是否失败重试过"]
Q5 -->|"invalid_client"| Q5C["客户端凭证错<br/>公开/机密客户端配错"]
Q5 -->|"200"| Q6{"ID Token 校验<br/>通过吗?"}
Q6 -->|"签名不符 / unknown kid"| Q6A["→ 密钥或 kid 不匹配"]
Q6 -->|"exp passed"| Q6B["→ 故障① 时间偏移"]
Q6 -->|"aud / nonce / at_hash 不符"| Q6C["会话与令牌被错配<br/>或校验顺序写错"]
Q6 -->|"通过"| OK["登录成功:记录 sub 与 sid"]
这张图的用法是从上往下走,每一步只回答一个是非题。
下面五节逐条展开,每条都给「症状 → 去哪看 → 根因」。
三、五类故障
故障① 时间偏移:exp / iat 校验
| 项 | 内容 |
|---|---|
| 症状 | 令牌刚发出来就报过期;或者只在某几台机器上失败;或者「偶发」,重试有时能过 |
| 去哪看 | ID Token 的 exp / iat 与校验方的当前时间;两台机器的系统时间差 |
| 根因 | 校验方与服务方的时钟不同步。exp / iat 是绝对时间戳,时钟快的一方会提前判过期,慢的一方会接受本该过期的令牌 |
| 怎么修 | 给校验留一个显式、很小的容差窗口(例如 60 秒),并把服务器时间同步当基础设施来维护 |
两个反面做法要避免:
- 把容差开到很大(几分钟以上)。那不是修时钟,是把令牌寿命偷偷延长了——
实验台里容差是MiniRP(..., leeway=60)这样一个显式参数,就是为了让这个决定写在明处; - 只修一台机器。偏移是两端之间的差,换一台校验方可能又出现。
时间偏移还有一类更隐蔽的表现:iat 在未来。某些实现会拒绝 iat 晚于当前时间的令牌,
于是「刚刚签发的令牌反而不被接受」。
故障② redirect_uri 未登记:授权端点直接拒掉
| 项 | 内容 |
|---|---|
| 症状 | 页面出现 400 错误页,正文就是一句 Invalid parameter: redirect_uri;用户根本没见到登录页 |
| 去哪看 | 授权请求上的 redirect_uri(注意 URL 编码后的值,以及有没有尾斜杠);IdP 里的客户端登记 |
| 根因 | 请求里的地址与登记值不逐字相等。http / https、端口、路径、尾斜杠、大小写都算差异 |
status = 400
页面上的错误 = Invalid parameter: redirect_uri
它有两个必须理解的行为特征:
- 它是整条请求被拒,不是回跳带错误。原因见 01 章:如果 IdP 把「地址不认识」
也发回那个地址,它就成了一个开放重定向; - 因此它的排查入口在授权请求,不在回跳。看到这个 400,说明链路还没走到登录页。
故障③ cookie 属性:登录页能打开,一提交就说找不到 cookie
| 项 | 内容 |
|---|---|
| 症状 | 登录页正常显示,填完凭据提交后失败,错误指向「找不到 cookie」 |
| 去哪看 | 第一步 GET 的响应头里的 Set-Cookie,特别是 Secure / SameSite 属性;提交请求的 Cookie 头里到底有没有那个值 |
| 根因 | IdP 给会话 cookie 加了 Secure,而当前访问是 http;脚本型 HTTP 客户端严格按协议不会在 http 上回带它 |
用标准库的 cookie 策略可以把这件事看得很干净(不需要起服务):
cd courses/identity/sso-engineering/course/project
python examples/07_secure_cookie_over_http.py
=== 下一个请求发给 http(IdP 现在就跑在 http 上) ===
无 Secure,无 SameSite -> OP_SESSION=sid-0007
加 Secure -> (不回带;jar 里存着 ['OP_SESSION=sid-0007'])
Secure + SameSite=None -> (不回带;jar 里存着 ['OP_SESSION=sid-0007'])
=== 下一个请求发给 https ===
无 Secure,无 SameSite -> OP_SESSION=sid-0007
加 Secure -> OP_SESSION=sid-0007
Secure + SameSite=None -> OP_SESSION=sid-0007
三个结论:
Secure的判据是「这次请求是不是 https」,与SameSite无关。 别把两个属性混着猜;- cookie 被存下来了,只是不回带——所以现象不是「cookie 没设上」,而是「第二个请求丢了它」;
- 这条是脚本与浏览器的差异,不是「浏览器也登不上」:浏览器把
http://localhost
当可信来源处理,会接受并回传这种 cookie。所以你会看到「本机用浏览器点得进去、
用脚本跑同样的流程却卡在提交」——两边看到的不是同一个 bug 的两种说法,
而是两个客户端对同一个属性的不同处理。
生产上的正确处理不是「去掉 Secure」,而是换成 HTTPS,并同时把 SameSite
按场景设对(跨站需要 None,但那要求 Secure)。
故障④ state 丢失或不可预测
| 项 | 内容 |
|---|---|
| 症状 | 回跳后应用报 state 不符 / 缺失;或者一切都好但「偶尔」失败;或者安全审计指出 CSRF 防护无效 |
| 去哪看 | 回跳 URL 上的 state、应用存储里的那一份、多副本部署时两次请求落到哪个副本 |
| 根因 | 三种:没保存(存在内存、多副本不共享)、被丢在中间(URL 编码/截断/改写)、生成了可预测值(时间戳、自增 id、固定串) |
三种根因的区分方法很简单:
每次都失败 -> 没保存,或值与请求没绑定
偶尔失败 -> 存储不共享(多副本 / 多标签页竞争)
能被猜出来 -> 可预测:这是安全问题,不是稳定性问题
注意第三种:它不会造成任何报错。系统照常工作,只是 CSRF 防护形同虚设。
这也是「排障」和「审计」的分界——没有报错的故障要靠主动检查发现。
故障⑤ PKCE 校验失败
| 项 | 内容 |
|---|---|
| 症状 | 换 token 返回 400 {"error": "invalid_grant", "error_description": "PKCE verification failed: Code mismatch"} |
| 去哪看 | 授权请求里的 code_challenge / code_challenge_method 与换 token 时送出的 code_verifier;这两者在同一份存储里吗 |
| 根因 | verifier 与生成 challenge 时用的那个不是同一个值:存丢了、重新生成了一次、或者存进了浏览器端而服务端拿不到 |
用被篡改的 verifier -> 400 {"error": "invalid_grant", "error_description": "PKCE verification failed: Code mismatch"}
同一 code 再用正确 verifier -> 400
{"error": "invalid_grant", "error_description": "Code not valid"}
第二条是这一类故障里最容易把人带偏的地方:修好 verifier 之后重试,
看到的却是 Code not valid。因为换 token 的顺序是「先消耗 code,再校验 PKCE」——
失败的那一次已经把 code 烧掉了。
所以这一类的正确排障动作是:不要去重试那个 code,重新发起一次登录。
如果你在代码里写了「失败自动重试」而重试用的是同一个 code,这个重试永远不会成功,
而且会把真正的原因(PKCE 不符)伪装成「code 失效」。
故障⑥ issuer 字面量不一致
| 项 | 内容 |
|---|---|
| 症状 | ID Token 校验失败,错误看起来像「签发者不对」;或是回跳被应用侧拒绝(invalid_issuer) |
| 去哪看 | 令牌里的 iss、回跳参数里的 iss、应用配置里的期望值、反向代理传下来的协议与主机名 |
| 根因 | 三者中存在字面量差异。http vs https、有没有端口、末尾斜杠、内部地址 vs 外部地址 |
这一类最典型的生产成因是代理终止 TLS:IdP 以为自己是 http://internal:8080,
应用与用户看到的是 https://sso.example.com,于是 iss 与配置不符。
它和「签名不对」在现象上非常像,所以排障时先打印 iss 再怀疑密钥。
四、把六类合并成一张排障表
| # | 症状(最先看到的现象) | 去哪看 | 根因一句话 |
|---|---|---|---|
| ① | 令牌刚发就过期 / 只有某些机器失败 | exp / iat 与两端时钟 |
校验方与服务方时间不同步 |
| ② | 400 页面,正文 Invalid parameter: redirect_uri |
授权请求里的 redirect_uri + 客户端登记 |
地址没有逐字匹配 |
| ③ | 登录页能开、提交后报找不到 cookie | 第一步响应的 Set-Cookie 属性 + 提交请求的 Cookie 头 |
http 上不回带 Secure cookie(脚本) |
| ④ | state 不符 / 偶发失败 / 审计不过 |
回跳 state、应用存储、生成方式 |
没保存、被丢弃、或可预测 |
| ⑤ | PKCE verification failed: Code mismatch |
code_challenge 与 code_verifier |
两者不是同一次生成的值;且失败已消耗 code |
| ⑥ | 「签发者不对」但密钥看着没错 | iss(令牌 / 回跳 / 配置) |
字面量差异,常见于代理终止 TLS 后 |
补充一条不属于这六类、但同样常见的:Code not valid。
它有两个来源——code 被用过(包括你自己失败的那一次),或code 从没存在过。
实现刻意不区分这两者(02 章),所以你能做的是查「这个 code 之前有没有被送进过令牌端点」。
动手:可观察结果
cd courses/identity/sso-engineering/course/project
python examples/02_tampered_verifier.py # 故障⑤(含 code 被消耗)
python examples/03_reused_code.py # Code not valid 的两种来源之一
python examples/07_secure_cookie_over_http.py # 故障③
python -m pytest -q tests -k failure # 六类断点各有一条断言
测试那一条会一次跑过状态不符、iss 缺失、签名篡改、未知 kid、
时钟容差、at_hash 不符、cookie 属性——每一条都断言具体的错误串,
所以「跑过了」等价于「这一类的错误信息就是这条」。
故障注入
按本节表里的顺序,把六类各注入一次,并记录它在第几步断:
| 注入 | 一句话改法 | 断点在第几步 |
|---|---|---|
| ① 时间偏移 | 把校验方时钟推进过 exp(实验台:op.clock.advance(400)) |
第 4 步(校验 ID Token) |
② redirect_uri 未登记 |
把回跳端口从 8090 改成 9999 |
第 1 步(授权请求) |
| ③ cookie 属性 | 让提交请求不带 Cookie 头 |
第 2 步(提交凭据) |
④ state 可预测 |
把 state 换成固定字符串 |
第 3 步(回跳校验;不报错,但防护失效) |
| ⑤ PKCE 不符 | 用改过的 verifier 换 token |
第 3.5 步(换 token) |
⑥ iss 差异 |
把配置里的 issuer 端口从 8081 改成 8080 |
第 4 步(校验 ID Token) |
注入完之后要能回答一个问题:这个断点,我在真实系统里去哪里看?
答不上来,说明这一步的可观测性还没建起来——那不是排障技巧问题,是日志与指标问题。
生产边界
| 教学里这样 | 真实部署要换成 | 不换会出什么事 |
|---|---|---|
| 靠人肉看现象分步 | 关键节点打结构化日志:每次授权请求/回调都记 state、iss、code 的部分指纹、结果 |
出事时只能靠前端截图猜 |
| 错误信息只给用户看 | 应用侧保留 IdP 的原始 error / error_description(脱敏后) |
把 invalid_grant 一律转成「登录失败」,等于丢掉唯一线索 |
单副本、内存存 state |
共享存储 + TTL,并允许并发登录 | 「偶尔失败」类问题在多副本上线后立刻变多 |
| 不做时钟同步 | 服务器时间同步 + 监控偏移量 | 偏移类故障只在部分机器出现,最难复现 |
| 容差随手给 | 明确写出容差值与其理由 | 容差被当成「修好了」的手段,实际延长了令牌寿命 |
| 只用 http 联调 | 联调环境也走 HTTPS(本地可用自签证书) | cookie 属性类差异直到上线才暴露 |
自测题
- 你在浏览器里看到一个 400 页面,正文是
Invalid parameter: redirect_uri。
这时该怎么判断「有没有走到登录页」?为什么它不会出现在回跳参数里? - 一个脚本流程「登录页能开、一提交就报找不到 cookie」,而同一套流程用浏览器手点是通的。
说出根因,并说明为什么两者不是同一个 bug。 - PKCE 失败之后,为什么用正确的
verifier重试也会失败?
这个事实会怎么改变你写的「失败自动重试」逻辑? state校验失败有哪三种根因?哪一种不会产生任何报错?该怎么发现它?- 为什么「ids 看起来一样」还不够?列举至少三种会导致
iss字面量不同的生产成因。 - 把六类故障按「断在第几步」排一遍,并解释为什么这个顺序能减少猜测。
现在能解释什么
- 一次登录失败的六类断点,各自的症状、该看的地方与根因;
- 一个固定的查看顺序:先状态码序列,再回跳参数,再令牌端点错误,最后令牌校验;
- 「cookie 属性」这一类的确切机制,以及脚本与浏览器为什么表现不同;
- 为什么
PKCE verification failed之后紧跟的Code not valid不是第二个故障; - 哪些故障会自己报错、哪些不会——不报错的那一类要靠主动注入才能发现。