加载中...

这一讲原文只有 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/oauth2coreos/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 被信任(第七节)

七 · ⚠ "信任内网"的代价

脚本演示了不删会怎样(假设业务服务有个功能会把收到的 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 自己的 expclaims.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/oauth2coreos/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 讲分层是否成立的指标 · 中间件那个"包一层")

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关