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 秒),并把服务器时间同步当基础设施来维护

两个反面做法要避免:

时间偏移还有一类更隐蔽的表现:iat 在未来。某些实现会拒绝 iat 晚于当前时间的令牌,
于是「刚刚签发的令牌反而不被接受」。

故障② redirect_uri 未登记:授权端点直接拒掉

项 内容
症状 页面出现 400 错误页,正文就是一句 Invalid parameter: redirect_uri;用户根本没见到登录页
去哪看 授权请求上的 redirect_uri(注意 URL 编码后的值,以及有没有尾斜杠);IdP 里的客户端登记
根因 请求里的地址与登记值不逐字相等。http / https、端口、路径、尾斜杠、大小写都算差异
status = 400
页面上的错误 = Invalid parameter: redirect_uri

它有两个必须理解的行为特征:

  1. 它是整条请求被拒,不是回跳带错误。原因见 01 章:如果 IdP 把「地址不认识」
    也发回那个地址,它就成了一个开放重定向;
  2. 因此它的排查入口在授权请求,不在回跳。看到这个 400,说明链路还没走到登录页。
项 内容
症状 登录页正常显示,填完凭据提交后失败,错误指向「找不到 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

三个结论:

  1. Secure 的判据是「这次请求是不是 https」,与 SameSite 无关。 别把两个属性混着猜;
  2. cookie 被存下来了,只是不回带——所以现象不是「cookie 没设上」,而是「第二个请求丢了它」;
  3. 这条是脚本与浏览器的差异,不是「浏览器也登不上」:浏览器把 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 属性类差异直到上线才暴露

自测题

  1. 你在浏览器里看到一个 400 页面,正文是 Invalid parameter: redirect_uri。
    这时该怎么判断「有没有走到登录页」?为什么它不会出现在回跳参数里?
  2. 一个脚本流程「登录页能开、一提交就报找不到 cookie」,而同一套流程用浏览器手点是通的。
    说出根因,并说明为什么两者不是同一个 bug。
  3. PKCE 失败之后,为什么用正确的 verifier 重试也会失败?
    这个事实会怎么改变你写的「失败自动重试」逻辑?
  4. state 校验失败有哪三种根因?哪一种不会产生任何报错?该怎么发现它?
  5. 为什么「ids 看起来一样」还不够?列举至少三种会导致 iss 字面量不同的生产成因。
  6. 把六类故障按「断在第几步」排一遍,并解释为什么这个顺序能减少猜测。

现在能解释什么

进入 keel 阅读