这一讲原文只有 7.5KB,读起来像一篇 dex 的介绍。它真正在教的不是 dex,有五处值得停下来:
- “这个选择与业务无关。所以很自然地,我们决定评估一下,看看是否有开源项目和我们想得一样” —— "与业务无关"这个判据怎么用? 什么时候帐号系统反而是业务?
- “dex 并不是一个包(package),而是一个可执行程序(application)” —— 这句话看着像实现细节,其实是 dex 架构上最重要的一个决定。
- “看到我们熟悉的微信、支付宝、新浪微博了,所以想到点子并不难,但看架构设计就会看到两者巨大的差距” —— 差距具体在哪? 原文点到就走了。
- “ID Token 是一个 JSON Web Token (JWT),支持你对 Token 进行解码并验证用户身份” —— JWT 不是加密的,这是关于它最常见的误解。
- “具体 QPaint 业务怎么对接 dex,就比较简单了,我们这里就不详细展开” —— 它真的展开了吗? 见下。
★ 而这一讲最好的抓手,是两个我 diff 分支才发现的事实:
原文给的链接 实际情况 github.com/qiniu/qpaint/compare/v42...v44★ 是空的。0 个 commit—— v44这个 tag 的内容和v42完全相同compare/v42...v44-bear(原文称"最常规的做法")1 个 commit,真的有代码: paintweb/main.go+158/−1、paintweb/www/dom.js+26/−6也就是说:原文正文推荐的那条路(接 dex)没有提交代码;有代码的是它一开始就说"但我们要 OAuth 所以不这么做"的那条路。
而 v44-bear 那 158 行代码里最重要的事实是:
paintdom/(业务服务,全部 5 个文件) ← ★ 与 v42 字节相同,一个字都没改 paintweb/main.go(网关) ← ★ 35 → 192 行,全部新代码在这★ 帐号授权这件事,一行都没进业务服务。它全部落在网关上。
而 42 讲那个 mock 的Authorization: QPaintStub <uid>——格式一个字没改,
从"客户端说的话"变成了"网关说的话"。这份带读的主体,就是把这个结构讲透。它比 dex 本身值钱得多。
怎么读这份带读
| 这一部分是 | 建议 | |
|---|---|---|
| 第一部分 · 地基(一) | 那个网关结构,以及"可信性来自谁能写" | 先读这个 |
| 第二部分 · 原文(二~五) | "买还是造"的四步 · dex 的形状 · ID Token / JWT | 主体 |
| 第三部分 · 引申(六~八) | JWT 的三个坑 · 信任内网的代价 · 2026 年的选型 | 标了 ⚠ 非原文 |
只想搞懂某一件事:
代码到底改了哪 → 一 | "与业务无关"怎么判 → 二 | dex 凭什么比 union 好 → 三
ID Token / JWT → 四 | JWT 不是加密的 → 4.2 | 信任内网的代价 → 七
这一讲的演示脚本(推荐按这个顺序跑):
| # | 脚本 | 演什么 | 配合 |
|---|---|---|---|
| 1 | 代码/JWT不是加密的.py |
30 行手写 JWT · ★ payload 谁都能读 · alg:none · 算法混淆 · aud 挡什么 |
四 · 六 |
| 2 | 代码/网关翻译授权.py |
跑 v44-bear 的网关 · 三个攻击 · ★ Header.Del("Cookie") 为什么关键 · 信任边界 |
一 · 七 |
这一讲的源码(v44-bear 那条真的有代码的路):
| 你要的东西 | 路径 |
|---|---|
网关(v44-bear 的 paintweb/main.go,192 行) |
代码/qpaint源码/v44-bear-网关/main.go |
↳ 浏览器端的改动(dom.js) |
v44-bear-网关/dom.js |
↳ 新增的依赖(jwt-go) |
v44-bear-网关/go.mod |
| v42 → v44-bear 的两份 diff | diff-main · diff-dom |
| 业务服务(与 v42 字节相同,去 42 讲那边看) | ../42-…/代码/qpaint源码/v42-服务端/ |
第一部分 · 地基
一 · ★ 抓手:帐号授权一行都没进业务服务
python3 代码/网关翻译授权.py
1.1 那个结构
┌──────────┐ Cookie: session=<JWT> ┌───────────────────┐
│ 浏览器 │ ───────────────────────▶ │ paintweb :8888 │ ← ★ 158 行新代码全在这
│ │ │ · /login 表单 │
│ ★ 不再自己 │ │ · 验 JWT │
│ 写任何 │ │ · ★ 删掉 Cookie │
│ Authori-│ │ · ★ 换成内网协议 │
│ zation │ └─────────┬─────────┘
└──────────┘ │
Authorization: QPaintStub 7 │ ← 42 讲那个 mock,格式一字未改
▼
┌───────────────────┐
│ paintdom :9999 │ ← ★ 一个字节没改
│ 只认 QPaintStub │
└───────────────────┘
核心的那 20 行(sessionToAuth.ServeHTTP):
func (p *sessionToAuth) ServeHTTP(w http.ResponseWriter, req *http.Request) {
cookie, err := req.Cookie("session")
if err != nil || isExpired(cookie) {
ReplyErr(w, 401, "bad token"); return
}
id, ok := parseToken(cookie.Value) // ← 验 JWT 签名 + exp
if !ok {
ReplyErr(w, 401, "bad token"); return
}
req = cloneRequest(req)
req.Header.Del("Cookie") // ★ 第三节专讲这一行
req.Header.Set("Authorization", "QPaintStub " + id)
p.base.ServeHTTP(w, req) // ← 转给反向代理 → paintdom
}
1.2 ★ 它回答了一个很自然的疑问
“42 讲那个假授权,43/44 讲讲了这么多 OAuth,最后到底怎么替换掉的?”
★ 答案是:它没有被替换掉,它被"包"起来了。
QPaintStub <uid>从"客户端说的话"变成了"网关说的话"——格式一模一样,变的是谁有权写它。┌──────────────────────────────────────────────────────────────────┐ │ ★ 一个协议的可信性,不来自它的格式,来自【谁被允许生成它】。 │ │ `QPaintStub 7` 这七个字符本身既不安全也不不安全 —— │ │ 它在公网上是零安全,在只有网关能写的内网里就是足够的。 │ └──────────────────────────────────────────────────────────────────┘★ 这也回过头证明了 42 讲那个 mock 授权不是白做的(带读 16 · 第六节):
它定下的那个接缝原封不动地活到了最后。
42 讲花半页纸做一个"任何人都能伪造"的假授权,价值就在这里——
它把"授权发生在哪一层、结果长什么样"钉死了,于是真授权来的时候,
业务服务连重新编译都不需要。
1.3 浏览器端的改动,也印证了同一件事
dom.js 的 diff(+26/−6)里,最重要的是两行被删掉的代码:
- http.setRequestHeader("Authorization", "QPaintStub 1") ← callAsync 里那句
- http.setRequestHeader("Authorization", "QPaintStub 1") ← 同步器里那句
★ v42 里是浏览器自己声称"我是 uid=1";v44-bear 里浏览器什么都不声称。
它只带一个 Cookie,"我是谁"由网关从那个 Cookie 推出来。★ 这就是"授权的可信性"落地时的具体形状:把"声明身份"这个动作,
从客户端手里拿走,交给一个客户端管不着的地方。
顺带一个小改动,很值得看(它是"接口演进"的一个微型样本):
- function callAsync(method, url, headers, body, onOK)
+ function callAsync(method, url, opts, onOK) ← headers/body/on401 打包成 opts
为什么要改? 因为要加一个 on401(401 时跳登录页)。
var newDocOptions = {
headers: [], body: null,
on401: function() {
window.location = "/login?return=" + encodeURIComponent(window.location)
}
}
★ 判断:当一个函数的"回调"要从一个变成两个时,位置参数就撑不住了。
五个位置参数已经很难读(callAsync("GET", url, [], null, fn)里那个[], null谁记得住?),
再加一个on401就必须变成"一个选项对象 + 一个主回调"。Python 里对应的是从
def f(a, b, c, d, cb)改成def f(a, b, *, on_error=None, **opts)。
一个函数的参数长到你要数位置,它就该收成一个对象了。
第二部分 · 原文这一讲
二 · 这一讲真正的产出:一次"买还是造"的决策,四步
原文的行文顺序就是那四步,但它没标出来。标出来是这样:
| 步 | 原文在做什么 | 原话 |
|---|---|---|
| ① 明确要什么 | 定下技术方案 | “比较理想的方式是我们基于 OpenID Connect 协议来提供帐号系统,基于 OAuth 2.0 协议来实现 Open API 体系” |
| ② 判断它与业务的关系 | 关键的一步 | “**这个选择与业务无关。**所以很自然地,我们决定评估一下” |
| ③ 找现成的 | 搜 | “最后,我们发现 CoreOS 团队搞了一个叫 dex 的项目” |
| ④ 评估架构 | 不只看功能 | “所以想到点子并不难,但看架构设计就会看到两者巨大的差距” |
★ 这一讲的价值是那四步,dex 只是那四步的结论。
而其中第②步和第④步是最容易被跳过、也最要紧的。
2.1 ★ "与业务无关"这个判据怎么用
原文只说了结论,没给判据。给一个能直接用的:
┌──────────────────────────────────────────────────────────────┐
│ ★ 问一句:如果这个东西我做得比所有人都好,用户会因此选我吗? │
│ 不会 → 与业务无关 → 用现成的 │
│ 会 → 它就是业务 → 自己做 │
└──────────────────────────────────────────────────────────────┘
| 能力 | 通常与业务无关 | 什么时候它变成业务 |
|---|---|---|
| 帐号 / 授权 | ✓ | 你卖的就是身份服务(Auth0、Okta、Clerk);或者帐号体系本身是护城河(微信登录) |
| 通知 / 短信 | ✓ | 你是通知平台(Twilio) |
| 支付 | ✓ | 你是支付公司 |
| 监控 / 日志 | ✓ | 你是可观测性公司(Datadog) |
| 存储 | ✓ | ★ 七牛自己——对七牛来说对象存储恰恰是业务 |
| 画图的 DOM 与同步 | ✗ 这就是业务 | 永远 |
★ 最后两行的对照最说明问题:同一个能力,在不同公司位置完全不同。
42 讲选 mongodb(不自己写数据库),44 讲选 dex(不自己写帐号)——
而七牛自己是不会去"选一个对象存储"的,因为那是它的业务。所以这个判据不是"哪些技术该买"的清单,是一个每家公司要自己回答一遍的问题。
2.2 三条路的取舍(原文的开头)
| 路线 | 做法 | 原文的评价 |
|---|---|---|
| ① 自建帐号库 | 用户名+密码 + Cookie Session | “最常规的做法”——就是 v44-bear 那条 |
| ② 遵循 OAuth 2.0 | 提供标准授权协议 | “第三方应用可以快速接入,而不是搞半天去研究我们自己发明的授权是怎么回事” |
| ③ 复用微信/支付宝 OpenID | 让用户免注册 | “而不是让用户在注册环节折腾半天” |
★ 注意①和②③不是互斥的——原文给出的答案是②+③(OIDC 提供帐号 + OAuth 提供 Open API)。
而 ① 那条是 v44-bear,也是实际唯一有代码的那条。★ 顺带一个诚实的观察:对一个还没有用户的产品,① 通常是对的起点。
② 的收益(第三方能接入)只有在真的有第三方想接入时才兑现,
而 ③ 的收益(降低注册门槛)是立刻兑现的。
原文选 ②+③ 是因为它在讲"理想架构",而 v44-bear 选 ① 是因为它在写 DEMO。
两个都对,只是回答的不是同一个问题。
三 · dex 的形状,以及它和 union 的差距
3.1 联邦(federated)是什么
Google Salesforce Azure GitHub LDAP SAML
│ │ │ │ │ │
└────────┴────┬─────┴────────┴────────┴───────┘
│ ← Pluggable Connector(每个上游一个插件)
┌─────▼──────┐
│ dex │
└─────┬──────┘
│ ← ★ 出口是【标准的】OpenID Connect + OAuth 2.0
┌───────────┼───────────┐
QPaint 别的应用 别的应用
原文的说明:
“dex 基于各类主流的 OpenID 来提供帐号系统,上游的 OpenID Provider 是以**插件方式(Pluggable Connector)提供。
这也是为什么把它叫联邦 OpenID(federated OpenID)**的原因。”
★ 这个形状你在这门课里已经见过很多次了:
| 例子 | 屏蔽了什么差异 | 统一出口 |
|---|---|---|
| 操作系统的驱动模型(8 讲) | 各种硬件 | 系统调用 |
| 文件系统(9 讲) | 各种存储介质 | open/read/write |
26 讲的 Shape interface |
各种图形 | onpaint |
| 41 讲的两层分层 | 各种网络协议 | DOM 树的方法调用 |
| dex | 各种 OpenID Provider | OIDC + OAuth 2.0 |
3.2 ★ 但 dex 多做了一件事,而这件事决定了它的价值
普通的适配器:把 N 个外部接口适配成我自己定义的一个接口。
dex:把 N 个外部接口适配成一个别人定的标准协议。
适配到"我自己的接口" 适配到"标准协议"
┌──────────────────────┐ ┌──────────────────────────┐
│ 下游必须用【我的 SDK】 │ │ ★ 下游用【任何现成的 │
│ 下游从此依赖【我】 │ │ OIDC 客户端库】 │
│ 我成了新的单点 │ │ ★ 下游不依赖 dex, │
│ │ │ 甚至能随时把 dex 换掉 │
└──────────────────────┘ └──────────────────────────┘
★ 这是"抽象出一层"最常见的失败模式,值得单独记:
抽象出了一个【只有你自己有】的接口, 于是你从"依赖 N 个供应商"变成了"所有人依赖你"—— ★ 你没有消除耦合,你把耦合搬到了自己身上。dex 避开了这个,因为它的出口是别人定的标准。
这也正好印证了 44 讲那两个 SDK 的选择:
QPaint 对接 dex 用的是golang.org/x/oauth2和coreos/go-oidc——
★ 都是通用的 OIDC/OAuth 客户端,没有一个叫"dex-client"。
一个不需要专属客户端的服务,才是真的对接了标准。
3.3 ★ “看架构设计就会看到两者巨大的差距”——差距具体在哪
原文点了 github.com/tiantour/union 这个项目(“看到我们熟悉的微信、支付宝、新浪微博了”),
然后说"想到点子并不难,但看架构设计就会看到两者巨大的差距",就走了。
三条可以直接拿去评估任何"聚合类"项目的判据:
| # | 问题 | dex | 一个"拼 SDK"式的项目 |
|---|---|---|---|
| 1 | 出口是标准协议,还是自造接口? | OIDC + OAuth 2.0 | 自己定的函数/结构体 |
| 2 | 新增一个上游要改几个文件? | 实现一个 Connector 接口,不动核心 |
在中心的 switch 里加一个 case |
| 3 | 它是一个可独立部署的【进程】,还是一个要被 import 的【包】? | ★ 可执行程序 | 包 |
第 3 条最容易被忽略,而它最重要——原文其实写到了,只是像个实现细节:
“dex 并不是一个包(package),而是一个可执行程序(application),它提供了帐号与授权服务。
你可以这样运行它:dex config.yaml”
★ 这句话的含义:
是包(library) 是进程(service) 谁能用 只有同语言的项目 ★ 任何语言 升级 每个用它的项目各自升级、各自重新发布 升级一次,所有人都升级了 密钥/凭据放哪 散在每个使用方的配置里 ★ 集中在一处 出安全漏洞时 要追着所有使用方去升级版本 改一个部署 ★ 而"帐号授权"这件事,上面每一行都指向"应该是进程":
它要被多语言的服务共用、要能紧急打补丁、要集中管密钥。★ 一条可以推广的判断:
一个能力如果 ① 被多个/多语言的服务共用,② 要能独立紧急升级, ③ 需要持有集中的秘密 —— 它就该是一个【服务】,而不是一个【库】。 反过来,纯计算、无状态、无秘密的能力(一个日期解析、一个编码器) 做成库就够了,做成服务只是自找网络故障。43 讲第六节那个"网关做内省、内部服务只做自校验",说的是同一个位置。
四 · ID Token 与 JWT
python3 代码/JWT不是加密的.py
4.1 为什么需要扩展 OAuth 2.0
原文这段很准:
“因为 OAuth 2.0 本身只关心授权,所以它会返回访问令牌(Access Token)和更新令牌(Refresh Token)。
但无论是访问令牌还是更新令牌,都并没有包含身份(Identity)信息。
没有身份信息,就没法作为 OpenID Provider。身份令牌(ID Token)解决了这一问题。”
★ 补一句 43 讲带读第六节那条:这不是 OAuth 的缺陷,是它的设计范围。
OAuth 是授权框架(你能干什么),不是认证框架(你是谁)。
一个 access token 说的是"持有者被允许做 X",它从来没打算说"持有者是谁"。
而原文说"ID Token 补上了身份信息"还差半句。 43 讲带读第 6.1 节那个混淆代理攻击说明了:
★ ID Token 补上的不只是"是谁"(
sub),更关键的是"这条身份声明是说给谁听的"(aud)。
一个不说明受众的身份声明,是可以被转手的。
脚本实测:
我(qpaint-web)验一个发给我的 token → ✓ 通过
我(qpaint-web)验一个发给【别人】的 token → ★ aud='别人家的App' 不是发给我的(拒绝)
4.2 ★ JWT 不是加密的——这是关于它最常见的误解
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJxcGFpbnQtd2ViIiw….YV9qEPYwH_6M…
└──────────┬──────────┘ └────────────┬────────────┘ └──────┬──────┘
header(36 字符) payload(216 字符) signature(43 字符)
"我用什么算法签的" 装内容(claims) HMAC / RSA 签名
脚本里那个 只解码() 函数不需要密钥,一样能读出全部内容:
header = {"alg": "HS256", "typ": "JWT"}
payload.aud = "qpaint-web"
payload.email = "zhai@example.com"
payload.sub = "7"
...
| 保证什么 | |
|---|---|
| 签名 sign | 完整性 + 来源可验 |
| 加密 encrypt | 机密性 |
★ 签名保证的是"没被改过"和"是我签的",它【不】保证"别人看不到"。
base64 不是加密,只是编码。★ 于是一条硬规则:JWT 的 payload 里不能放秘密。
不能放 密码、身份证号、完整手机号、"这个用户是老赖"这类不想让本人看到的判断 可以放 uid、过期时间、受众、scope、角色名 实践后果很直接:任何人都能把你的 token 粘进 jwt.io 看内容——
包括你的用户、用户装的浏览器插件、以及任何拿到日志的人。⚠ 真想加密有 JWE,但很少用。更常见也更省事的做法是:不放敏感内容,
需要时用sub去查一次库。(这又回到 43 讲那笔账:想省查库,就得接受"内容公开 + 不能吊销"。)
4.3 三种 token 的分工,一张表理清
| Access Token | Refresh Token | ★ ID Token | |
|---|---|---|---|
| 回答什么问题 | 能干什么 | (换新的) | ★ 你是谁 |
| 属于 | OAuth 2.0 | OAuth 2.0 | ★ OpenID Connect |
| 给谁看 | 资源服务器 | 授权服务器 | ★ 客户端自己 |
| 格式 | 不规定(可以是随机串) | 不规定 | ★ 必须是 JWT |
| 用几次 | 每个请求 | 极少 | ★ 登录时一次 |
┌────────────────────────────────────────────────────────────────┐ │ ★ 最容易搞错、也是很多真实漏洞的来源: │ │ ID Token 是给【客户端自己】看的,用来知道"刚登录的是谁"。 │ │ 它【不是】访问资源用的凭据 —— 访问资源要用 Access Token。 │ │ 反过来,Access Token【不能】当身份凭据用(43 讲那个混淆代理)。 │ └────────────────────────────────────────────────────────────────┘一句话记住三者:
Access = 门禁卡(能开哪些门,不写名字) Refresh = 换卡的凭条(只能拿去前台换新卡) ID = 一张【指名给我看】的身份证明(谁签的、是谁、给谁看、什么时候过期)
五 · 那句"我们这里就不详细展开"
“有了这些 SDK 和 dex 的使用说明,具体 QPaint 业务怎么对接 dex,就比较简单了。
我们这里就不详细展开,详细代码请参考:
github.com/qiniu/qpaint/tree/v44·compare/v42...v44”
★ 而那个 compare 是空的(0 commit,v44 = v42)。 所以这一讲的对接代码不存在。
这不是要挑错,而是它本身有教育意义:
★ "对接一个标准协议的客户端"确实是这一讲里最不值得写的部分——
它就是照着using-dex.md填三个配置项、调两个 SDK 函数。
原文说"比较简单了"是对的。★ 但它同时也说明了一件更实在的事:真正会被提交、会被维护、会出事的代码,
是 v44-bear 那 158 行自己写的帐号逻辑。而那 158 行里已经有三处留白(见第 7.3 节)。
——这恰恰是 44 讲主张"别造轮子"的最好证据,只不过证据在它没推荐的那个分支里。
第三部分 · 引申
⚠ 这一部分不是原文内容。
六 · ⚠ JWT 的三个坑(第三个是通用安全准则)
6.1 篡改:改内容是自由的,但改完签名就不对
把 payload 里的 sub 从 "7" 改成 "9"(★ 不需要密钥)
→ 再验 → ★ 签名对不上(被改过 / 不是这个密钥签的)
6.2 alg: none
历史上多个 JWT 库中过这个招:token 自己在 header 里声称 "alg": "none"(意思是"不需要签名")。
攻击者签发:eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiI5In0.
它的 header = {"alg": "none", "typ": "JWT"} ← 声称「不需要签名」
这个攻击之所以曾经有效,是因为很多库的 verify() 是这么写的:
alg = token.header.alg # ← ★ 听 token 自己说用什么算法
if alg == "none": return 通过
6.3 算法混淆(HS256 / RS256 互换)
真实场景:授权服务用 RS256(私钥签、公钥验),而【公钥是公开的】。
攻击者把 header 改成 alg: HS256,然后【拿那个公钥当 HMAC 密钥】去签。
如果验证方的代码是"用配置里的 key 去验,算法听 token 说的",
它就会拿公钥去做 HMAC 校验 —— ★ 而攻击者正好也有公钥,于是通过。
★ 6.2 和 6.3 的根因完全一样:把"用什么算法"这个决定权交给了 token。
┌──────────────────────────────────────────────────────────────┐ │ ★ 通用安全准则:不要让【被验证的数据】决定【怎么验证】。 │ └──────────────────────────────────────────────────────────────┘正确写法:算法由验证方规定,token 说什么都不算(脚本里第一道闸就是硬编码
alg != "HS256"→ 拒绝)。同一条准则在别处的样子:
· 反序列化时不要让数据指定要构造的类型 (Java 的 readObject、Python 的 pickle 都是这么栽的) · 不要让上传的文件自己声明 Content-Type 就照它处理 · 不要让请求里的 X-Forwarded-For / X-User-Id 被信任(第七节)
七 · ⚠ "信任内网"的代价
7.1 ★ req.Header.Del("Cookie") 为什么是最关键的一行
脚本演示了不删会怎样(假设业务服务有个功能会把收到的 header 转发给下游/webhook——这在真实系统里非常常见):
网关【删】了 Cookie(v44-bear 的做法)
业务服务看到的 header:['Authorization']
转发给第三方时会带出 session 吗?不会 ✓
网关【没删】Cookie
业务服务看到的 header:['Cookie', 'Authorization']
转发给第三方时会带出 session 吗?★ 会 —— 用户的长效凭据泄漏了
三条理由,一条比一条重要:
| # | 理由 |
|---|---|
| ① | 最小权限:业务服务不需要知道外部凭据,那就不该看到它 |
| ② | ★ 防止二次使用:业务服务拿到 session,它(或它调的下游、或它打的日志、或它的 SSRF 漏洞)就可能把用户的长效凭据带到别处去 |
| ③ | 划清责任:留着 Cookie,就会有人在业务服务里"顺手也解析一下 cookie"——于是认证逻辑在两个地方存在,而两处认证逻辑迟早不一致 |
┌──────────────────────────────────────────────────────────────┐ │ ★ 网关的职责不只是"把外部凭据翻译成内部凭据", │ │ 还包括【把外部凭据从此彻底拿掉】。翻译完不删,等于只做了一半。│ └──────────────────────────────────────────────────────────────┘⚠ 同一条准则在别处(都是真实事故的高发区):
· 反向代理必须【覆盖】而不是【追加】X-Forwarded-For(否则客户端能伪造来源 IP) · 网关必须删掉客户端自己塞的 X-User-Id 之类的内部头 · 服务间调用不要把上游的 Authorization 头原样往下传顺带一个 v44-bear 里做对了的细节:它用的是
Header.Set(覆盖)而不是Header.Add(追加)。
★ 写成 Add,后端读到的可能就是客户端塞进来的那一个。
7.2 信任边界:paintdom:9999 那个口子
脚本里攻击二得手了:
── 攻击二:绕过网关,直接对 paintdom:9999 伪造内网协议 ──
→ 200 这是 uid=9 的图 ✗ ★ 得手了!
★ 这不是 bug,是一个明确的设计选择,名字叫"信任内网":
paintdom 不做认证,它信任所有到达它的请求都已经被网关认证过。于是安全性完全依赖一件事:9999 端口不能被网关以外的东西访问到。
v44-bear 里靠的是go paintdom.Main()——它和网关在同一个进程里。
真实部署要靠:只监听127.0.0.1/ Unix socket · 安全组 / iptables / NetworkPolicy · service mesh 的 mTLS。
★ 而这个模型的代价必须说清楚:
┌────────────────────────────────────────────────────────────────┐ │ 一旦有【任何】一条路能绕过网关摸到业务服务,整个授权体系归零。 │ │ 典型的绕过路径: │ │ · 一个内网的调试页面 / 运维脚本 │ │ · 另一个服务的 SSRF 漏洞(它能替攻击者发内网请求) │ │ · 云上安全组配错一条规则 │ │ · 一个 sidecar / ingress 直连了后端端口 │ └────────────────────────────────────────────────────────────────┘★ 这就是"零信任"(zero trust)要解决的问题,它的主张一句话:
不要因为一个请求来自内网就信任它——每一跳都自己验。
实现上通常是内部调用也带一个短命的、签名的、带aud的凭据
(mTLS 客户端证书 / SPIFFE 身份 / 内部 JWT)——
★ 也就是把QPaintStub 7这种"裸声明"换成"可验证的声明"。⚠ 但别一上来就上零信任——它的成本是真实的(证书轮转、调试变难、每跳多一次验签)。
★ 判断标准是【故障域】(51 讲那个词):
内网只有 3 个服务、一个团队、一个 VPC 网关模型够用 内网有几百个服务、多团队、有第三方组件 裸声明迟早出事
7.3 ★ 读代码要看到"没做的事"
v44-bear 里有三处故意留白,读的时候必须看见:
| 代码 | 它其实没做 |
|---|---|
func isExpired(c *http.Cookie) bool { return false } |
★ 永远不过期。 兜住它的是 JWT 自己的 exp(claims.Valid())——也就是说这个函数是纯多余的,但它的存在会让读者以为有这道检查 |
func login(userName, password):if userName != password { return } |
“正常应该到数据库查,但是我们这里假设 userName, password 都是 uid”——密码 = 用户名,没有密码哈希 |
signKey = []byte("please protect your key") |
★ 密钥是源码里的字面量,进了 git 历史(变量名自己在提醒你,但代码没做) |
┌────────────────────────────────────────────────────────────────┐ │ ★ 一个空函数比没有这个函数更危险 —— 它让人以为那道检查存在。 │ │ 读安全相关的代码时,要专门找这三类东西: │ │ · 返回常量的检查函数(return false / return nil / return true)│ │ · 被注释掉的校验 │ │ · 写着 TODO / FIXME / "生产环境要改" 的分支 │ └────────────────────────────────────────────────────────────────┘★ 这三处也正是"自己造帐号系统"会掉进去的坑的一个最小样本——158 行里已经有三个。
真实的帐号系统还要处理:密码强度、登录限流、验证码、找回密码、多设备、
异地登录提醒、会话列表、密钥轮转、审计日志……而这些一件都和画图无关。
这就是 44 讲主张的最好证据。
八 · ⚠ 2026 年的诚实清单
| 原文当时 | 今天 |
|---|---|
| “CoreOS 团队搞了一个叫 dex 的项目” | CoreOS 已被 Red Hat 收购、品牌停用。dex 现在是 CNCF 的 sandbox 项目,仍在维护(Kubernetes 生态里用得很多) |
| “国内的主流 OpenID Provider,比如微信和支付宝,都没有在支持之列” | ★ 依然没有。 而且原因不只是"没人写"——微信的"OAuth"不是标准 OAuth 2.0:它用 appid/secret 而不是标准的 client_id/client_secret,返回 openid 而不是标准字段,端点也不叫标准名字 |
xushiwei/dex(改 Makefile 绕 GFW) |
那个 fork 早已停更;今天用官方镜像/Helm chart |
golang.org/x/oauth2 · coreos/go-oidc |
仍是标准选择(go-oidc 现在是 coreos/go-oidc/v3) |
| 六种 OAuth 授权模式 | OAuth 2.1 移除了 Implicit 和 Password 模式、强制 PKCE(43 讲带读第七节) |
| dex 是当时"唯一想得一样的" | 今天选项多了,见下表 |
★ 那条"微信不是标准 OAuth"很值得停一下,因为它是"联邦"这个思路的现实成本:
标准的价值取决于有多少人遵守它。 dex 的插件机制之所以必要,恰恰是因为【一大批"OAuth" 其实是 OAuth-like】。 ★ 所以"接标准"省下的力气,会以"给每个不标准的上游写一个 connector"的形式还回来一部分。
今天的同类选项:
| 方案 | 定位 | 什么时候选它 |
|---|---|---|
| dex | 轻量、专注做联邦 OIDC,配置即代码 | 你只要"把上游身份统一成 OIDC",尤其在 k8s 里 |
| Keycloak | 重、功能全(用户管理界面、角色、多租户 realm) | 你还需要一整套用户/角色管理后台 |
| Ory Hydra / Zitadel / Authentik | 各有侧重的开源 IdP | 按需 |
| Auth0 / Clerk / WorkOS / Logto | SaaS | ★ 团队小、不想运维、愿意付钱——今天多数创业公司的默认答案 |
| 自建(v44-bear 那条) | 只在 DEMO / 内部工具里合适 | 见 7.3 那张表 |
★ 而 44 讲的判断在 2026 年不但没过时,还更强了:
能选 SaaS 的时候,连"部署一个 dex"都是与业务无关的成本。
原文当年只能在"自己写"和"部署一个开源项目"之间选,
今天多了一档"连部署都不做"——但判据完全没变,还是 2.1 节那一句。
九 · 收口:这一讲带走什么
① 一个决策模板 明确要什么 → 判断与业务的关系 → 找现成的 → ★ 评估架构(不只看功能)
② 一个判据 "我做得最好,用户会因此选我吗?" —— 不会就别自己做
③ 三条评估标准 出口是标准还是自造 · 加一个上游要改几处 · ★ 是进程还是包
④ 一条协议判断 ★ 可信性不来自格式,来自"谁被允许生成它"
⑤ 一条安全准则 ★ 不要让被验证的数据决定怎么验证
⑥ 一条网关职责 翻译外部凭据 + ★ 把外部凭据彻底拿掉
⑦ 一个读代码的习惯 ★ 看到"没做的事":返回常量的检查函数
验收
地基(一)
| # | 问题 | 答案在 |
|---|---|---|
| 1 | v44-bear 相对 v42 改了哪些文件?业务服务改了多少?这说明了什么? | 一 |
| 2 | 42 讲那个 mock 的 QPaintStub <uid> 最后是怎么被"替换"的?由此推出什么判断? |
1.2 |
| 3 | 浏览器端删掉的那两行是什么?为什么删掉它们才叫"授权可信"? | 1.3 |
原文(二~五)
| # | 问题 | 答案在 |
|---|---|---|
| 4 | 这一讲真正的产出是什么四步?哪两步最容易被跳过? | 二 |
| 5 | "与业务无关"怎么判?举一个"同一能力在不同公司位置相反"的例子。 | 2.1 |
| 6 | dex 比"普通适配器"多做了一件什么事?不做会怎样? | 3.2 |
| 7 | “看架构设计就会看到两者巨大的差距”——差距是哪三条?哪条最容易被忽略? | 3.3 |
| 8 | "dex 是可执行程序而不是包"为什么是架构上的关键决定? | 3.3 |
| 9 | 为什么 OAuth 需要被 OpenID Connect 扩展?ID Token 补上的到底是什么? | 4.1 |
| 10 | JWT 的 payload 是加密的还是签名的?由此推出什么硬规则? | 4.2 |
| 11 | Access / Refresh / ID 三种 token 怎么分工?最容易搞错的是哪一条? | 4.3 |
引申(六~九)
| # | 问题 | 答案在 |
|---|---|---|
| 12 | alg:none 和算法混淆的根因是同一个,是什么?通用准则怎么说? |
六 |
| 13 | req.Header.Del("Cookie") 为什么是最关键的一行?三条理由是什么? |
7.1 |
| 14 | "信任内网"的代价是什么?什么时候该上零信任? | 7.2 |
| 15 | v44-bear 里三处"没做的事"是什么?读安全代码要专门找哪三类东西? | 7.3 |
| 16 | "微信不是标准 OAuth"这件事说明了"联邦"思路的什么现实成本? | 八 |
答案
1. 只改了 paintweb/main.go(35→192 行,+158/−1) 和 paintweb/www/dom.js(+26/−6),加上 go.mod / go.sum。paintdom/ 全部 5 个文件与 v42 字节相同——业务服务一个字都没改。
说明:帐号授权这件事一行都没进业务服务,它全部落在网关上。
(另外原文让你看的 compare/v42...v44 是空的——v44 这个 tag 和 v42 内容完全相同,0 commit。)
2. 它没有被替换掉,它被"包"起来了。 QPaintStub <uid> 格式一个字没改,从"客户端说的话"变成了"网关说的话"——变的是谁有权写它。
判断:一个协议的可信性不来自它的格式,来自【谁被允许生成它】。 QPaintStub 7 在公网上是零安全,在只有网关能写的内网里就足够了。
这也证明 42 讲那个 mock 不是白做的:它定下的接缝原封不动活到了最后,于是真授权来时业务服务连重新编译都不需要。
3. 删掉的是 dom.js 里两处 http.setRequestHeader("Authorization", "QPaintStub 1")。
v42 里浏览器自己声称"我是 uid=1";v44-bear 里浏览器什么都不声称,只带一个 Cookie,"我是谁"由网关从 Cookie 推出来。
★ 这就是"授权可信性"落地的具体形状:把"声明身份"这个动作从客户端手里拿走,交给一个客户端管不着的地方。
4. ① 明确要什么(OIDC 提供帐号 + OAuth 提供 Open API)② 判断它与业务的关系(“这个选择与业务无关”)③ 找现成的 ④ 评估架构(“想到点子并不难,但看架构设计就会看到两者巨大的差距”)。
最容易被跳过的是②和④——②决定了要不要自己做,④决定了找到的那个能不能用。这一讲的价值是那四步,dex 只是结论。
5. 判据:“如果这个东西我做得比所有人都好,用户会因此选我吗?” 不会 → 与业务无关 → 用现成的;会 → 它就是业务。
例子:对象存储。对 QPaint 是与业务无关的基础设施(42 讲直接选 mongodb),而对七牛自己,对象存储恰恰就是业务。同理帐号系统对 QPaint 无关,对 Auth0 / 微信就是业务本身。所以这不是一份技术清单,是每家公司要自己回答一遍的问题。
6. 普通适配器把 N 个外部接口适配成我自己定义的接口;dex 适配成一个别人定的标准协议(OIDC + OAuth 2.0)。
不做会怎样:下游必须用你的 SDK、从此依赖你,你从"依赖 N 个供应商"变成"所有人依赖你"——没有消除耦合,只是把耦合搬到自己身上。这是"抽象出一层"最常见的失败模式。
印证:QPaint 对接 dex 用的是通用的 golang.org/x/oauth2 和 coreos/go-oidc,没有一个叫 “dex-client” 的库——一个不需要专属客户端的服务,才是真的对接了标准。
7. 三条:① 出口是标准协议还是自造接口;② 新增一个上游要改几个文件(实现一个 Connector 接口,还是在中心 switch 里加 case);③ 它是可独立部署的进程,还是一个要被 import 的包。
第 3 条最容易被忽略,因为原文把它写成了一句像实现细节的话(“dex 并不是一个包,而是一个可执行程序”)。
8. 因为"是包"和"是进程"决定了四件事:谁能用(同语言 vs 任何语言)、怎么升级(每个使用方各自升级 vs 升级一次全都升级)、密钥放哪(散在每个使用方 vs 集中一处)、出漏洞时(追着所有使用方升版本 vs 改一个部署)。
而帐号授权每一行都指向"应该是进程"。
推广:一个能力如果 ① 被多个/多语言服务共用 ② 要能独立紧急升级 ③ 需要持有集中的秘密——它就该是服务而不是库。 反过来纯计算、无状态、无秘密的能力做成库就够了。
9. 因为 OAuth 2.0 只关心授权,access token 和 refresh token 都不含身份信息,没有身份信息就没法当 OpenID Provider。(补:这不是缺陷,是设计范围——OAuth 说的是"持有者被允许做 X",从来没打算说"持有者是谁"。)
ID Token 补上的不只是"是谁"(sub),更关键的是"这条身份声明是说给谁听的"(aud)——一个不说明受众的身份声明是可以被转手的(43 讲那个混淆代理攻击)。
10. 是签名的,不是加密的。 三段都是 base64url,而 base64 不是加密只是编码——脚本里那个 只解码() 函数不需要密钥就能读出全部内容。签名保证"没被改过 + 是我签的",不保证"别人看不到"。
硬规则:JWT 的 payload 里不能放秘密(不能放密码、身份证号、完整手机号、不想让本人看到的判断;可以放 uid、exp、aud、scope、角色名)。
想加密有 JWE 但很少用,更省事的做法是不放敏感内容、需要时用 sub 查一次库。
11. Access = 能干什么(给资源服务器看、每个请求都用、格式不规定);Refresh = 换新的(给授权服务器看、极少用);ID Token = 你是谁(★ 给客户端自己看、登录时一次、必须是 JWT、属于 OIDC 而不是 OAuth)。
最容易搞错:ID Token 不是访问资源的凭据(访问资源用 Access Token),反过来 Access Token 也不能当身份凭据用。
记法:Access = 门禁卡(不写名字)· Refresh = 换卡的凭条 · ID = 一张指名给我看的身份证明。
12. 根因是把"用什么算法"这个决定权交给了 token——库的 verify() 写成 alg = token.header.alg,于是 token 说 none 就跳过验签,说 HS256 就拿公开的 RSA 公钥去做 HMAC(而攻击者也有那个公钥)。
准则:不要让【被验证的数据】决定【怎么验证】。 正确写法是算法由验证方规定。
同一条准则:反序列化不要让数据指定构造的类型(Java readObject、Python pickle);不要信任上传文件自己声明的 Content-Type;不要信任请求里的 X-Forwarded-For / X-User-Id。
13. 因为翻译完不删,等于翻译只做了一半。三条理由:① 最小权限——业务服务不需要知道外部凭据;② ★ 防止二次使用——业务服务拿到 session,它或它调的下游、它打的日志、它的 SSRF 漏洞就可能把用户的长效凭据带到别处(脚本演示:不删的话转发给 webhook 时 session 就跟着出去了);③ 划清责任——留着 Cookie 就会有人在业务服务里"顺手也解析一下",于是认证逻辑在两处存在,迟早不一致。
同类:反向代理必须覆盖而不是追加 X-Forwarded-For;网关必须删掉客户端塞的内部头;服务间调用不要原样传上游的 Authorization。(v44-bear 用 Header.Set 而不是 Add,这点做对了。)
14. 代价是:一旦有任何一条路能绕过网关摸到业务服务,整个授权体系归零(脚本里直接对 9999 端口写 QPaintStub 9 就得手了)。典型绕过路径:内网调试页面/运维脚本、另一个服务的 SSRF、安全组配错一条规则、sidecar/ingress 直连后端端口。
零信任的主张是"不要因为请求来自内网就信任它,每一跳都自己验"——把裸声明换成可验证的声明(mTLS / SPIFFE / 内部 JWT)。
但它成本真实(证书轮转、调试变难、每跳多一次验签),判断标准是故障域:3 个服务一个团队一个 VPC → 网关模型够用;几百个服务多团队有第三方组件 → 裸声明迟早出事。
15. 三处:① isExpired() 直接 return false——永远不过期(真正兜住的是 JWT 自己的 exp,所以这个函数是纯多余的,但它的存在误导读者以为有这道检查);② login() 里 if userName != password——密码 = 用户名,没有密码哈希;③ signKey = []byte("please protect your key")——密钥是源码字面量,进了 git 历史。
读安全代码要专门找:返回常量的检查函数(return false/nil/true)、被注释掉的校验、写着 TODO / “生产环境要改” 的分支。
★ 一个空函数比没有这个函数更危险——它让人以为那道检查存在。
而这三处正是"自己造帐号系统"会掉的坑的最小样本:158 行里就有三个,而真实系统还要处理密码强度、登录限流、验证码、找回密码、多设备、异地登录、会话列表、密钥轮转——一件都和画图无关。
16. 说明标准的价值取决于有多少人遵守它。微信的"OAuth"用 appid/secret 而不是 client_id/client_secret、返回 openid 而不是标准字段——它是 OAuth-like 而不是 OAuth。
所以 dex 的插件机制之所以必要,恰恰是因为一大批上游不标准。
★ 现实成本:"接标准"省下的力气,会以"给每个不标准的上游写一个 connector"的形式还回来一部分。(而这也是原文说"国内主流 OpenID Provider 都没在支持之列"至今没变的原因——不是没人写,是写起来不是一个标准 connector。)
下一步
带读 19 · 收尾:后端四讲带走了什么(六个手法 · 一批判断 · 诚实清单 · 与前端五讲的对照)
回看:
带读 17 · 43 讲(混淆代理与 aud · 双 token · 认证 ≠ 授权)
带读 16 · 42 讲(QPaintStub 那个 mock 授权——第一节讲的就是它的下场)
带读 15 · 41 讲(分层是否成立的指标 · 中间件那个"包一层")
