四枚 JWT 全部验签通过,两个被算成了同一个人——错在 sub 少了 iss

口径先钉住 :本篇所有判据在跑实验之前 写定并哈希锁定(experiments/jwt-sub-scope/PREREG.sha256)。 协议原文两处,都可匿名复核: - RFC 7519 §4.1.2 (https://www.rfc-editor.org/rfc/rfc7519.txt,第 477--478 行) - OpenID Connect Core 1.0 (https://openid.net/specs/openid-connect-core-1_0.html) 复现脚本、每一版翻车记录都在 experiments/jwt-sub-scope/。 本文没有引用学术文献,见文末引用节。

事情是这样的

我有一套东西,按用户聚合数据。逻辑很简单:令牌来了,解出来,拿到用户标识,归到对应的桶里。

那个桶的 key,我写的是令牌里的 sub。

跑了一段时间,没出过事。直到我为了做对照实验,顺手签了两个不同的签发者------

bash 复制代码
# 签发者 A
{"iss": "iss-a", "sub": "user-42"}
# 签发者 B,别的什么都没变
{"iss": "iss-b", "sub": "user-42"}

两枚令牌都是我自己签的,签名都对,验签都过,sub 都有值 ,也完全合规。

然后我那个聚合判据告诉我:这是一个人。

先说协议怎么说的

我不是看了代码才怀疑的,我去查了规范原文。RFC 7519 §4.1.2,sub 那一条,逐字是这样:

The subject value MUST either be scoped to be locally unique in the context of the issuer or be globally unique.

翻译过来就是:sub 要么在「签发者内部」唯一,要么全局唯一。

注意这句话给了两个选项。第二个选项(全局唯一)要求高,多数人做不到------因为 sub 通常就是邮箱、手机号、用户名这类东西,你控制不了别的签发者给你发什么。

所以实际上大家都在用第一个选项:在签发者内部唯一。

而这正是坑的入口。

OpenID Connect Core 1.0 把这件事说得更直接:

Therefore, the only guaranteed unique identifier for a given End-User is the combination of the iss Claim and the sub Claim.

唯一保证的标识符,是 iss 和 sub 的组合。

这两句话合起来,意思很明确:sub 单独拿出来,它的作用域是一个签发者。你要拿它当全局主键,你得自己保证它全局唯一------而协议并没有保证这一点。

我自己测了一遍

文档说的是文档说的。文档能证明「标准要求如此」,证明不了任何一个具体系统真的会出错。

所以我做了个实验,在 experiments/jwt-sub-scope/,四枚令牌:

臂 / 令牌 / iss / sub

  • A-1 ------ 令牌一 ------ iss-a ------ user-42
  • A-2 ------ 令牌二 ------ iss-a ------ user-99
  • A-3 ------ 令牌三 ------ iss-b ------ user-42
  • A-4 ------ 令牌四 ------ iss-b ------ user-77

A-1 和 A-3 是关键:sub 相同,iss 不同。

跑出来的结果:

css 复制代码
错误判据(只按 sub 聚合):
  user-42  ← A-1, A-3        ← 判成了同一个人
  user-77  ← A-4
  user-99  ← A-2
  → 4 枚令牌被压成 3 个「用户」

正确判据(按 iss + sub 聚合):
  iss-a / user-42  ← A-1
  iss-a / user-99  ← A-2
  iss-b / user-42  ← A-3
  iss-b / user-77  ← A-4
  → 4 枚令牌,4 个用户

四枚令牌全部验签通过。没有一枚是非法的。

最要紧的是它一声不吭

这是我把它写出来的原因。

那个错的判据,从头到尾没有抛异常、没有返回错误码、没有打任何日志。它就是安安静静地,把两个人合并成了一个人,返回了一个看起来完全正常的结果。

而且它大多数时候是对的 ------单签发者的系统里,按 sub 聚合完全正确。

所以它的表现是:跑了很久,一直对,直到某个时刻有人接了第二个签发者。那之后它开始静默出错,而你没有任何地方能看到它出错。

会叫的坏东西不可怕。 这个不叫。

本篇不成立的结论(免得被过度引用)

我把话说全。

它不能证明真实生产系统已经因此合并错了用户。 需要的是一次真实事故,而我没有。本实验的令牌是我自己签的。

它也不能证明现实中一定会出现相同的 sub。 我构造了这个输入。它在协议层完全合法,后果完全可复现,但「一定会出现」是另一回事。

它也不能证明按 iss+sub 聚合在所有场景都对。 它只是 RFC 和 OIDC 明确点名的最小保证组合,不是万能钥匙。

所以这篇能支撑的说法是:这个 bug 长什么样、怎么复现、根因在哪。

不能写成「你们公司可能都有这个 bug」。

怎么改

一行:

scss 复制代码
# 之前
groups[claims["sub"]].append(record)

# 之后
groups[(claims["iss"], claims["sub"])].append(record)

难的不是这一行。

难的是你得知道它错在哪一层。令牌是合法的、验签是过的、字段是有值的、代码逻辑也没写错------四样都对,你不会去怀疑任何一样。

如果你也在做多签发者的东西

三个能立刻做的检查:

  1. 看你按 sub 聚合的地方,有没有把 iss 带上。 如果你的用户表只有一列 sub,那它现在就是个复合主键,只是复合的第二半被扔了。
  2. 看你的历史数据有没有被合并过。 如果有,那不是「可能有风险」,是「已经错了」------去把两个签发者的同名用户拆开。
  3. 加一条断言 :同一个 sub 出现在两个不同 iss 下时,打个警告。它不会拦住你,但会让你知道什么时候该紧张。

第一条我做不到------我的系统只有一个签发者,所以这个 bug 我至今没有真的中过 。我是签了个假的第二签发者才发现的。这件事本身也挺讽刺的:你得先造出问题,才能防住它。

附:本文引用

一、两处协议原文(都可匿名复核)

RFC 7519 §4.1.2 ,https://www.rfc-editor.org/rfc/rfc7519.txt 第 477--478 行,逐字:

The subject value MUST either be scoped to be locally unique in the context of the issuer or be globally unique.

OpenID Connect Core 1.0 ,https://openid.net/specs/openid-connect-core-1_0.html:

Therefore, the only guaranteed unique identifier for a given End-User is the combination of the iss Claim and the sub Claim.

这两处能证明什么 :标准明确要求 sub 的唯一性作用域限于签发者 , 并明确点名「唯一保证 = iss + sub 的组合」。

不能证明什么 :任何一个具体系统真的会因此合并错用户。那一层靠第五节的实验。 两者缺一,任何一个单独拿出来都不足以支撑本篇的结论。

二、实验判据与原始输出

代码在 experiments/jwt-sub-scope/,原始数据见同目录的 runs/ (每轮一份、带时间戳的完整输出),读者可以直接下载复核,不必信我的复述。

判据在跑之前写死并哈希锁定:

复制代码
da2621866d0c7ce8434b857bf55ba769b93057016854497344e6468b00bb159b  PREREG.md

九项判据(P1--P9)全过,其中:

  • P1 四枚令牌验签全部通过 ------ 排除「不合法令牌混进来」这个替代解释
  • P6 阳性对照:同一 iss 不同 sub 时没有误合并

------ 没有这一条,「合并成 1 个」可能只是聚合函数本身写坏了

  • P8 错误判据全程未抛异常、未返回错误码 ------ 这是全篇的重点
  • P9 过期令牌被正确拒绝 ------ 排除「非法令牌也能混进来」

三、这个实验翻车了三次,留着

初版我把 iat 写死成 1700000000(2023-11-14)。 跑到今天,四枚令牌全部已过期 , P1 得 0/4。是自检门第一项就抓住的,正式实验一步没跑。 判据一个字没改,改的是输入。

第二、第三次更难看:我那个「静默性」判据在初版里写的是 p8 = True, 理由是「上面一路跑完没抛异常」。它什么都没测 ------一个恒真的判据, 通过它不代表任何事。 修好之后挪了两次位置还是错的------每次都是别的调用点先崩了, 我准备好的那句「命题不成立」根本说不出口。 最后挪到整个程序最开头才成。

判据的调用点散布在多处时,检测必须提到所有调用点之前, 而不是提到「我以为的第一次」。没想到的地方会替你把程序打死。

所以这三轮是更正,不是重写。 判据(PREREG.md)自始至终没有改过一行, 哈希对得上;被作废的是输入和判据的调用位置 。 如果你只看最后一轮的输出,看不出这些 ------ 所以我把作废的轮次也留在 runs/ 里。

三条反向自测都验证过会失败:错误判据改成恒真(自检门红,退出码 2)、 去掉验签(退出码 2)、让错误判据抛异常(明确打印「命题不成立」,退出码 1)。

四、本文没有引用学术文献

这篇没做文献综述,也没引用任何论文或书籍。 第二节查的是协议规范原文,不是文献。 写清楚是为了避免读者以为我做过文献工作。

五、取到 / 没取到的

来源 / 状态

  • RFC 7519 原文 ------ ✅ 取到,逐字复核
  • OpenID Connect Core 1.0 ------ ✅ 取到,逐字复核
  • JWT 最佳实践类博客 ------ ❌ 二手源,不作为锚点

取不到的东西我不用记忆去补。

相关推荐
用户3058759549311 小时前
单体、微服务、集群、高可用:把软件架构听明白
架构
HackTwoHub1 小时前
REA逆向一切!渗透逆向新利器、MCP 驱动全栈逆向方案,解析原生二进制、EXE、APK,高效挖掘程序底层漏洞
网络·安全·web安全·网络安全·系统安全
小趴菜一只1 小时前
SIEM-wazuh安装过程
运维·windows·安全·网络安全
看着博客敲代码2 小时前
searchsploit illegal option -- - 与 Exploit-DB 本地漏洞检索排查
安全·网络安全·系统安全
智驭未来掌门人2 小时前
架构师笔记:问卷调查的重要性
架构
吴佳浩 Alben2 小时前
一文讲透推理引擎性能指标:TTFT、TPOT、KV命中率、并发,到底对应 vLLM / SGLang 的哪个参数?
人工智能·python·语言模型·架构·ai编程·vllm·sglang
ting94520002 小时前
Kilo Code for JetBrains 深度技术剖析:原生开源编程智能体内部实现与工程实践
人工智能·架构·开源
EasyGBS2 小时前
社区、校园安防难统一?国标GB28181公网平台EasyGBS视频系统,打破安全监管难题!
深度学习·安全·自动化
pt10432 小时前
Cisco设备网络安全系列-3:远程访问虚拟专用网、站点间虚拟专用网与Cisco SD-WAN安全
网络·安全