坑边闲话:Web 认证协议需要在一个只理解 Cookie 和 HTTP 重定向的浏览器之上,让彼此不共享密钥的几方对「当前请求来自谁」达成可验证的结论。本文从反向代理认证网关出发,推导 OAuth 2.0 授权码流程与 OpenID Connect 的结构,并以具体攻击为线索,说明 redirect_uri 精确匹配、授权码一次性使用、PKCE, state, nonce 以及令牌验证规则各自约束了哪一种绑定关系。分析表明,授权码流程的核心安全性只依赖高熵随机数、哈希函数、签发方的服务端状态和 TLS 信道,公钥签名仅在验证方不是签发方、且不应具备签发能力时才不可替代,本文也在 Cloudflare Access 的设计中检验了这一结论。

1. 问题背景·

1.1 访问控制·

Cloudflare Tunnel 解决的是可达性问题:内网里的 cloudflared 主动向 Cloudflare 边缘建立出站连接,边缘再按照发布规则(published application route)把某个域名的请求送进隧道,交给内网服务。家里没有公网 IP, 运营商封了 80 和 443 端口,都不妨碍服务上线。具体做法可以参考之前的文章 利用 Cloudflare Tunnel 实现服务公网发布。

但可达性和访问控制是两回事。域名一旦公开解析,就不能假设只有自己知道它;而自托管服务的认证能力参差不齐:有的管理面板只有一个弱口令,有的静态文件服务根本没有登录功能,有的虽然有登录页,但登录页之外的接口存在未授权访问漏洞。我们需要的是一道门:在请求到达应用之前先确认访问者的身份,并且这道门最好不要求应用做任何修改。

1.2 浏览器能力·

设计这道门时,最大的约束来自浏览器。用户手里的客户端就是一个普通浏览器,它不认识任何「认证协议」,只会做几件很朴素的事:

  • 收到 3xx 响应时,跟随 Location 头跳转到新地址;
  • 收到 Set-Cookie 头时保存 Cookie, 之后按规则自动附带在请求里;
  • 提交表单,执行页面里的 JavaScript.

网关登录,OAuth 2.0 以及建立在它之上的 OpenID Connect(下文简称 OIDC),还有 Cloudflare Access 这类商业产品,看起来各不相同,实际上都是用这几样东西编排出来的。浏览器在其中只是一个忠实的传话人:它不做任何密码学运算,不理解自己转交的参数是什么意思,也不知道下一跳是谁安排的。协议的安全性只能建立在服务器一侧的秘密、服务器一侧的状态,以及浏览器到服务器之间的 TLS 上。这条约束会反复出现,许多看起来绕远的设计都源于它。

温馨提示:WebAuthn(Passkey 背后的标准)是一个例外。它让浏览器调用设备上的私钥做签名,浏览器第一次以密码学参与者的身份进入了认证协议。第 7 章会再提到它。

1.3 三个基本问题·

围绕这道门,有三个问题需要回答:

  1. 插入:认证服务如何插入浏览器和应用之间,而应用不需要知道它的存在?
  2. 信任:一方签发的凭证,另一方凭什么相信?尤其是当两者不在同一个域名、甚至不属于同一家公司的时候。
  3. 原语:回答前两个问题,是否必须依赖公钥密码学?

前两个问题是工程问题,第 2 到第 6 章会逐步回答。第三个问题更值得玩味。学过非对称密码学的人,对「证明身份」的第一反应往往是数字签名:签发方用私钥签名,验证方用公钥验证,干净利落。然而今天部署最广的 Web 授权协议 OAuth 2.0, 它的授权码流程在核心环节里一个签名都没有用到,却依然是安全的。这件事并不显然。本文会在几个地方回到这个问题,并在第 7 章给出完整的回答。

2. 认证网关·

最直接的方案是在应用前面放一个反向代理,让反向代理在转发每个请求之前,先去问一个专门的认证服务。

温馨提示:反向代理(reverse proxy)是站在服务器一侧的代理。浏览器以为自己在和 nas.example.com 对话,实际接待它的是 Nginx 或 Caddy, 它们再把请求转给内网里真正的服务。与之相对,正向代理站在客户端一侧,替客户端访问外部网站。反向代理处在所有请求的必经之路上,所以 TLS 终结、按路径分流、限速和认证这类横切的工作都适合交给它。Nginx 是老牌的高性能 Web 服务器;Caddy 用 Go 编写,默认自动申请和续期 HTTPS 证书,配置文件更短。

温馨提示:Authelia, Authentik 和 oauth2-proxy 是自托管场景下最常见的三种认证服务。Authelia 用 Go 编写,自己维护用户和会话,支持口令、TOTP 和 WebAuthn, 也可以充当 OIDC 身份提供方。Authentik 是功能更全的身份平台,带图形化的用户和策略管理。oauth2-proxy 本身不管理用户,它把登录委托给外部的 OAuth 或 OIDC 身份提供方(Google, GitHub, Keycloak 等),自己只负责会话。三者都能以下面要讲的认证子请求方式接入 Nginx, Caddy 和 Traefik.

整体结构如下图所示,虚线是反向代理与认证服务之间的内部交互。

flowchart LR
    B["Browser"] -->|"HTTPS"| CF["Cloudflare edge"]
    CF -->|"tunnel"| CD["cloudflared"]
    CD --> RP["reverse proxy<br/>Nginx / Caddy"]
    RP -.->|"1. auth subrequest"| AU["auth service<br/>Authelia"]
    AU -.->|"2. 200 / 401 / 403"| RP
    RP -->|"3. forward only on 2xx"| APP["app<br/>192.168.1.10:8080"]

第一个问题是:浏览器在认证成功之后,凭什么会在后续请求里自动带上凭证?答案和认证无关,是 HTTP Cookie 的通用规则。服务器在响应里写一个 Set-Cookie 头:

1
2
3
HTTP/1.1 302 Found
Set-Cookie: session=8f29b7c1d3; Path=/; Secure; HttpOnly; SameSite=Lax
Location: /files

浏览器把它存进 Cookie 仓库,记下名字、值和几项属性。此后每发一个请求,浏览器都会逐条检查这些属性:

属性 作用
Domain 不写时只回传给设置它的那个主机(host-only);写了则回传给该域名及其所有子域名
Path 请求路径以它开头才回传
Secure 只在 HTTPS 请求里回传
HttpOnly 页面里的 JavaScript 读不到它
SameSite 限制跨站请求是否携带它,取值 Strict, Lax, None
Expires / Max-Age 过期时间,不写则关闭浏览器后失效

条件都满足,就附带在请求里:

1
2
3
GET /files HTTP/1.1
Host: nas.example.com
Cookie: session=8f29b7c1d3

关键在于,浏览器并不知道 nas.example.com 需要认证,也不知道 8f29b7c1d3 是什么意思,它只是在执行匹配规则。正因为浏览器对 Cookie 的内容一无所知,Cookie 才能承载任何一种认证方案。另一方面,Cookie 归属于「响应来自哪个主机」,而不是「响应由哪个程序生成」。反向代理转发回来的响应,在浏览器看来就是那个域名自己的响应。后面几节会反复利用这一点。

2.2 认证子请求·

第二个问题是:反向代理凭什么替第三方认证服务干活?答案是它不会主动去干,必须由管理员明确配置。Nginx 的 ngx_http_auth_request_module 模块提供了 auth_request 指令:处理受保护的请求之前,先向指定地址发一个内部子请求,根据子请求的状态码决定原请求的命运。按照 Nginx 文档,子请求返回 2xx 则放行,返回 401 或 403 则以相同状态码拒绝,其他状态码一律视为错误。

下面是一个接入 Authelia 的简化配置(/etc/nginx/conf.d/nas.conf),实际部署以 Authelia 当前版本的文档为准:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
server {
listen 443 ssl;
server_name nas.example.com;

location / {
auth_request /internal/authz; # ask the auth service first

# on 401, Authelia puts the login URL in the Location header
auth_request_set $redirection_url $upstream_http_location;
error_page 401 =302 $redirection_url;

# pass the identity on, overwriting any client-supplied value
auth_request_set $user $upstream_http_remote_user;
proxy_set_header Remote-User $user;

proxy_pass http://192.168.1.10:8080;
}

location = /internal/authz {
internal;
proxy_pass http://authelia:9091/api/authz/auth-request;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Original-Method $request_method;
}
}

Caddy 的 forward_auth 是同一个思路,写法更短。认证服务返回 2xx 时,Caddy 把 copy_headers 列出的响应头复制到原请求上,继续转发;返回其他状态码时,Caddy 把认证服务的响应原样交给浏览器,认证服务因此可以直接返回一个跳往登录页的 302:

1
2
3
4
5
6
7
nas.example.com {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
reverse_proxy 192.168.1.10:8080
}

一次完整的「首次访问、登录、再次访问」过程如下图所示。

sequenceDiagram
    autonumber
    participant B as Browser
    participant P as Nginx
    participant A as Authelia
    participant N as NAS
    B->>P: GET https://nas.example.com/files
    P->>A: subrequest /api/authz/auth-request (no cookie)
    A-->>P: 401, Location: https://auth.example.com/?rd=...
    P-->>B: 302 to auth.example.com
    B->>A: login page, then POST password + TOTP
    A-->>B: 302 back to nas.example.com, Set-Cookie: session=...
    B->>P: GET /files, Cookie: session=...
    P->>A: subrequest with Cookie and X-Original-URL
    A-->>P: 200, Remote-User: peng
    P->>N: proxy_pass, Remote-User: peng
    N-->>P: 200 page
    P-->>B: 200 page

这里有几个容易混淆的地方。

第一,Nginx 自始至终不解析 Cookie. 它只是把 Cookie 和原始 URL 交给 Authelia, 然后看状态码办事。两者之间的约定只有状态码和几个请求头,所以今天用 Authelia, 明天换成自己写的认证服务,Nginx 的配置几乎不用动。

第二,「检查」和「登录」是两条不同的路径。第 2 步和第 8 步的检查是 Nginx 到 Authelia 的服务器间请求,Authelia 只需要回答状态码;第 5 步的登录则是浏览器直接与 Authelia 的登录页面交互,Authelia 在这里才通过浏览器看得见的响应设置 Cookie. 子请求响应里的 Set-Cookie 不会自动转交给浏览器。

第三,应用完全不知道认证服务的存在。它可以是一个没有任何用户系统的静态文件服务器,只要信任反向代理传过来的 Remote-User 头就能知道访问者是谁。这种做法通常称为认证卸载(authentication offloading)。

2.3 会话凭证·

Cookie 里装什么,有两种设计。

第一种是随机会话 ID. 认证服务生成一个不可预测的随机串放进 Cookie, 自己在内存或数据库里保存「这个 ID 对应哪个用户、何时过期」。验证就是查表。它的好处是撤销即时生效:删掉表里那一行,会话立刻作废。

第二种是自包含令牌。Cookie 里直接写明「用户 peng, 某时刻过期」,再附上一个认证标签,防止用户自己改写内容。验证时不查表,只校验标签。这里用什么算法?很多人的第一反应是数字签名,但对这个场景来说 HMAC 就足够了:

1
cookie = base64url(payload) || "." || base64url(HMAC-SHA256(K, payload))

签发 Cookie 的是认证服务,验证 Cookie 的还是认证服务,密钥 K 从来不需要离开这台机器。不少 Web 框架默认的会话 Cookie 就是这样构造的。数字签名的价值在于「能验证的人无法伪造」,可这里验证的人和签发的人是同一个,这个性质毫无用处。

这是本文主线第一次露面:签发方和验证方是同一方时,对称密码学就够了。 后面会看到,这条朴素的观察可以一路推广到 OAuth 的授权码。

2.4 信任边界·

这套架构成立有三个前提,任何一个被打破,认证就形同虚设。

应用不能被绕过。 如果 NAS 的 8080 端口同时暴露在局域网甚至公网上,攻击者直接访问它就行。应用应该只监听回环地址或容器内部网络,或者用防火墙限定只接受反向代理的连接。

身份头不能被伪造。 上面的配置把 Authelia 返回的 Remote-User 交给应用,应用据此判断访问者是谁。如果反向代理只是「追加」而不是「覆盖」这个头,攻击者在自己的请求里带上 Remote-User: admin 就能冒充管理员。所以反向代理必须无条件覆盖或删除客户端送来的同名头,应用也只能信任来自反向代理的连接。

子请求的应答不能被篡改。 反向代理完全相信认证服务返回的状态码,谁能在这条链路上伪造一个 200, 谁就能通过认证。认证服务和反向代理应当处在同一台主机或同一个隔离的网络里,跨主机时要走 TLS.

回到上图的第 6 步。登录页面在 auth.example.com, 认证成功后要让浏览器在访问 nas.example.com 时带上凭证。可是 Cookie 的规则是:一个响应只能为自己的主机名,或者自己的上级域名设置 Cookie. auth.example.com 的响应如果写 Domain=nas.example.com, 浏览器会直接丢弃,因为 nas 是 auth 的兄弟而不是上级。这条规则防止的正是「任意网站给别人植入登录 Cookie」。

于是有三条出路,如下图所示。

flowchart TB
    Q["auth.example.com cannot set a cookie for nas.example.com"]
    Q --> A["A. parent-domain cookie<br/>Set-Cookie: session=...; Domain=example.com"]
    Q --> B["B. mount the login path under the app host<br/>nas.example.com/_auth/ proxied to the auth service"]
    Q --> C["C. callback with a one-time code<br/>nas.example.com/callback?code=..."]
    A --> A1["every *.example.com host receives the cookie<br/>one weak subdomain exposes every session"]
    B --> B1["host-only cookie<br/>works only where you control the proxy of that host"]
    C --> C1["the app side redeems the code and sets its own cookie<br/>this is the OAuth / OIDC pattern"]

方案 A 把 Cookie 设在父域名 example.com 上。这是 Authelia 这类网关的默认做法,简单有效,代价是作用域被放大:同一父域名下的所有子域名都会收到这个会话 Cookie. 只要其中一个子域名上的服务被攻破,或者本身就由不可信的人运营,它就能在服务器端读到别人的会话凭证并重放。

方案 B 把认证服务的登录路径挂到应用自己的域名下,例如由 Nginx 把 nas.example.com/_auth/ 转发给认证服务。2.1 节说过,浏览器只看响应来自哪个主机,所以认证服务此时设置的是一个只属于 nas.example.com 的 host-only Cookie, 配合 __Host- 前缀还能防止配置失误。它的局限是每个应用的入口都必须由你控制。

方案 C 让认证服务保留自己的域名,但不再替应用设置 Cookie. 认证成功后,它把浏览器重定向回应用的回调地址,并在 URL 里附带一个一次性的授权码:

1
2
HTTP/1.1 302 Found
Location: https://nas.example.com/callback?code=x8K2mP9qR4wLzT0vNc7YbA&state=Qm9vbGVhbg

应用一侧的认证组件拿着授权码,直接向认证服务兑换出用户信息,然后自己给浏览器设置 Cookie. 这样一来,认证服务可以服务任意多个域名不同、甚至分属不同组织的应用。这就是 OAuth 和 OIDC 的基本结构。

温馨提示:名字以 __Host- 开头的 Cookie 受到浏览器的额外约束:必须带 Secure, 必须是 Path=/, 并且不能带 Domain 属性。也就是说,它只能是当前主机的 host-only Cookie, 不会被父域名或兄弟域名设置或覆盖。SameSite 则控制跨站请求是否携带 Cookie: Strict 表示只要请求是从别的站点发起的就不带;Lax 在别的站点通过顶层导航(点击链接、重定向)跳过来时会带,跨站的 POST 和子资源请求不带;None 一律带,但要求同时设置 Secure.

方案 C 留下一个问题:code=x8K2mP9qR4wLzT0vNc7YbA 只是一串随机字符,应用凭什么相信它?是签名吗?下一章回答。

3. OAuth 授权码流程·

3.1 角色与术语·

OAuth 2.0(RFC 6749, 2012 年)最初要解决的问题并不是登录,而是授权委托:让一个第三方应用访问你在另一个服务上的资源,又不必把那个服务的口令交给它。典型的例子是让一个照片打印网站读取你云相册里的照片。协议定义了四个角色:

角色 英文 在本文场景中
资源所有者 resource owner 使用浏览器的用户
客户端 client NAS 一侧的认证组件,比如 oauth2-proxy
授权服务器 authorization server auth.example.com
资源服务器 resource server 提供受保护 API 的服务

用于登录时,OIDC 换了一套叫法:客户端称为依赖方(relying party),授权服务器称为身份提供方(identity provider, OIDC 规范里叫 OpenID Provider)。下文讨论 OAuth 本身时用「授权服务器」,讨论登录时用「身份提供方」,两者通常指同一个服务。

术语说明:认证(authentication)回答「你是谁」,授权(authorization)回答「你能做什么」。OAuth 2.0 本身只回答后者:它发出的访问令牌(access token)表示「持有者可以访问某些资源」,并不负责告诉客户端「当前用户是谁」。用 OAuth 做登录,需要 OIDC 补上的那一层,第 5 章会专门讨论。

3.2 前端与后端信道·

授权码流程最重要的结构,是把通信分成两种性质截然不同的信道。

术语说明:前端信道(front channel)指借助浏览器重定向传递的消息,参数写在 URL 里,由浏览器在两个服务器之间转交。后端信道(back channel)指客户端与授权服务器之间直接建立的 HTTPS 连接,浏览器看不到。下文统一使用「前端信道」和「后端信道」。

两者的安全性质对比如下:

前端信道 后端信道
谁能看到内容 浏览器、浏览器扩展、历史记录、服务器日志,可能还有 Referer 头 只有通信双方
谁能伪造消息 任何能让受害者的浏览器打开一个 URL 的人 很难伪造:TLS 认证了服务器,客户端还可以认证自己
适合传递 低价值、短时效、一次性、有绑定关系的东西 高价值的令牌

完整的流程如下图所示,绿色背景是前端信道,蓝色背景是后端信道。

sequenceDiagram
    autonumber
    participant B as Browser
    participant C as Client (nas.example.com)
    participant S as Authorization Server (auth.example.com)
    rect rgba(120, 200, 120, 0.15)
    B->>C: GET /files (no session)
    C->>C: generate state and code_verifier
    C-->>B: 302 to S /authorize?response_type=code&client_id&redirect_uri&state&code_challenge
    B->>S: GET /authorize?...
    S-->>B: login page
    B->>S: password / TOTP / passkey
    S->>S: create code, bind it to client_id, redirect_uri, user, code_challenge
    S-->>B: 302 to C /callback?code=...&state=...
    B->>C: GET /callback?code=...&state=...
    end
    rect rgba(100, 150, 230, 0.15)
    C->>S: POST /token with code, redirect_uri, client credentials, code_verifier
    S->>S: look up the code, check every binding, mark it used
    S-->>C: access_token, id_token, refresh_token
    end
    C-->>B: 302 to /files, Set-Cookie: __Host-session=...

第 3 步的授权请求大致是这样,各参数的作用会在下一章逐个解释:

1
2
3
4
5
6
7
8
GET /authorize?response_type=code
&client_id=nas
&redirect_uri=https%3A%2F%2Fnas.example.com%2Fcallback
&scope=openid%20email
&state=Qm9vbGVhbg
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: auth.example.com

第 10 步的兑换请求走后端信道:

1
2
3
4
5
6
7
8
9
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic bmFzOmNsaWVudC1zZWNyZXQ=
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=x8K2mP9qR4wLzT0vNc7YbA
&redirect_uri=https%3A%2F%2Fnas.example.com%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

3.3 授权码的验证·

现在可以回答上一章留下的问题了。授权码不是签名,也不是密文,它是授权服务器生成的一个高熵随机串,本身不携带任何信息。授权服务器在签发它的同时,在自己这边记下一条记录:

1
2
3
4
5
6
7
8
9
10
{
"code_hash": "SHA-256 of the code",
"client_id": "nas",
"redirect_uri": "https://nas.example.com/callback",
"user": "peng",
"scope": "openid email",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"expires_at": "2026-10-20T04:10:00Z",
"used": false
}

客户端收到授权码后,自己并不验证它,而是原样交回给签发它的授权服务器。授权服务器查到这条记录,逐项核对:是否存在、是否过期、是否已经用过、兑换者是不是记录里的那个客户端、redirect_uri 是否一致、PKCE 是否通过。全部满足,就把授权码标记为已使用,然后发放令牌。

整个过程没有签名。授权码的不可伪造性来自不可猜测性:RFC 6749 第 10.10 节要求,攻击者猜中这类凭证的概率不得高于 2 的 -128 次方,最好不高于 2 的 -160 次方。授权码的有效性则来自授权服务器自己的记录:签发方认得自己发出去的随机数。

这比签名更强,而不是更弱。一个签过名的授权码,验证方只能确认「它确实出自授权服务器」,却无从得知它是否已经被用过、是否已经被撤销,这些仍然要查表。既然最后总要回到签发方那里查表,签名就成了多余的部分。主线在这里第二次出现:授权码由签发方自己验证,所以随机数加服务端状态就够了。

温馨提示:这里的随机数必须来自密码学安全的伪随机数生成器(cryptographically secure pseudorandom number generator),例如 Linux 的 getrandom() 系统调用、Go 的 crypto/rand, Python 的 secrets 模块。普通的 Math.random() 或 C 库的 rand() 输出是可以预测的。128 位熵相当于 16 个随机字节,用 Base64URL 编码后是 22 个字符。另外,上面的记录里存的是授权码的 SHA-256 而不是原文,这样数据库泄露时攻击者拿不到可用的授权码。口令需要用 Argon2 这类慢哈希来存,是因为口令熵太低,可以被字典枚举;授权码本身有 128 位以上的熵,一次普通的 SHA-256 就足够了。

3.4 两步兑换·

既然授权服务器在第 8 步已经确认了用户身份,为什么不直接把访问令牌放进重定向 URL, 而要先发授权码,再让客户端回来兑换?

直接返回令牌的方式确实存在,叫隐式授权(implicit grant),response_type=token, 令牌放在 URL 的片段(# 之后的部分)里返回。它的问题在于把高价值的令牌放进了前端信道:令牌会出现在浏览器历史里,可能被页面里的第三方脚本和浏览器扩展读到,可能在后续跳转中泄露;客户端没有机会认证自己,攻击者还可以把自己的令牌注入受害者的会话。RFC 9700(OAuth 2.0 安全最佳实践,2025 年发布)第 2.1.2 节建议不要再使用隐式授权,正在制定的 OAuth 2.1 草案直接把它删掉了。

授权码流程把两种信道的长处组合起来。前端信道只运送一张「取件凭条」,它短时效(RFC 6749 建议不超过 10 分钟)、只能用一次、和客户端及回调地址绑定,丢了的代价有限;真正值钱的令牌走后端信道,由客户端带着自己的身份证明去领取。打个比方,寄存处发给你的号码牌本身不值钱,要回到柜台,由工作人员核对之后才能换回行李。

3.5 客户端认证·

上面兑换请求里的 Authorization: Basic ... 头,是客户端在向授权服务器证明「我是注册过的 nas」。能做到这一点的前提是客户端能保管秘密,OAuth 据此把客户端分成两类:

  • 机密客户端(confidential client):运行在服务器上的程序,比如 NAS 一侧的认证组件,能安全保存 client_secret.
  • 公开客户端(public client):运行在用户设备上的程序,比如手机 App, 桌面程序和纯前端的单页应用。打包进去的任何秘密都能被提取出来,等于没有秘密。

客户端认证的常见方式有三种:client_secret_basic 或 client_secret_post, 也就是上面这种共享密钥;private_key_jwt, 客户端用私钥签一个短期 JWT 作为证明,授权服务器只保存公钥;以及基于双向 TLS 证书的认证(RFC 8705)。第一种是纯对称的,后两种用到了非对称密码学,但下一章会看到,在最关键的一类攻击面前,三者的效果完全一样。

到这里流程已经完整了。问题是授权码要经过浏览器,而浏览器所在的环境并不可信:授权码可能被偷走、被调包、被攻击者换成自己的。下一章逐个讨论。

4. 攻击与防御·

4.1 威胁模型·

讨论攻击之前,先说清楚攻击者能做什么。RFC 9700 第 3 节给出的攻击者模型可以归纳为五类:

  • Web 攻击者:可以架设任意多的网站和服务器,诱导受害者访问攻击者指定的 URL, 从而让受害者的浏览器向任意地址发起请求;也可以自己注册 OAuth 客户端,甚至运营一个看起来正常的授权服务器。但它读不到不是发给自己的消息。
  • 网络攻击者:完全控制网络,可以窃听、篡改、伪造和阻断消息,只是破不了 TLS 这类密码学保护。
  • 能读到授权响应的攻击者:例如通过泄露的日志、Referer 头,或者手机上注册了相同 URL scheme 的恶意 App, 读到回调 URL 里的授权码,但不能篡改它。
  • 能读到授权请求的攻击者:例如通过泄露的日志读到授权请求的参数。
  • 能拿到访问令牌的攻击者:例如攻破了某个资源服务器,或者通过社会工程拿到令牌。

下面的讨论假设所有后端信道以及浏览器到服务器的连接都走 TLS. 这个前提非常重要,第 7 章会专门回到它。

4.2 重定向篡改·

授权服务器把授权码送到 redirect_uri 指向的地址。如果攻击者能控制这个地址,授权码就直接落到了攻击者手里。攻击方式是构造一个授权请求,把 redirect_uri 换成自己的网站,再诱导受害者点击。受害者如果已经在授权服务器上登录过,甚至可能看不到任何登录页面,授权码就已经发出去了。

早期很多授权服务器只做前缀匹配或通配符匹配,例如允许 https://nas.example.com/*. 这给了攻击者许多变形空间:利用客户端网站上的开放重定向(open redirect)漏洞,把授权码先送到合法的地址,再被转发出去;或者利用 URL 解析的差异,构造出看起来合法、实际指向别处的地址。

防御有两层。第一层是在授权服务器上预先注册回调地址,并做精确的字符串比较。RFC 9700 第 2.1 节把它定为强制要求,只允许本机原生应用的 localhost 回调在端口号上有出入。第二层是绑定:授权服务器把授权请求里的 redirect_uri 记在授权码的记录里,兑换时客户端必须再提交一次,两者必须完全相同(RFC 6749 第 4.1.3 节)。

4.3 授权码截获·

即使回调地址是对的,授权码也可能在路上被读到。最典型的场景是手机:原生 App 常用自定义 URL scheme(如 com.example.nas://callback)接收回调,而操作系统允许多个 App 注册同一个 scheme, 恶意 App 就有机会截下回调。其他泄露途径还包括代理和服务器的访问日志、浏览器历史,以及回调页面加载第三方资源时带出去的 Referer 头。

针对这类泄露,授权码本身已经有几层限制:

  • 短时效,降低泄露之后的可用时间;
  • 只能用一次。RFC 6749 第 4.1.2 节还要求,同一个授权码被兑换两次时,授权服务器必须拒绝,并且应当撤销此前用它签发的所有令牌。两次兑换意味着其中一次来自攻击者,而授权服务器无法分辨哪一次是合法的,干脆全部作废;
  • 客户端认证。攻击者只有授权码,没有 client_secret, 无法冒充机密客户端去兑换。

最后一条对公开客户端无效,因为它们没有秘密。偏偏手机 App 既是最容易泄露授权码的场景,又是没有客户端秘密的场景。PKCE 就是为这种情况设计的。

4.4 PKCE·

PKCE(Proof Key for Code Exchange, RFC 7636, 2015 年)的思路是:客户端没有长期秘密,那就在每次授权时临时生成一个。具体分三步。

第一步,客户端生成一个高熵的随机串 code_verifier, 长度 43 到 128 个字符,RFC 7636 推荐用 32 个随机字节做 Base64URL 编码。然后计算:

1
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))

第二步,授权请求只携带 code_challenge 和 code_challenge_method=S256. 授权服务器把挑战值和即将签发的授权码绑定在一起。

第三步,兑换授权码时,客户端提交原始的 code_verifier. 授权服务器重新计算哈希,与记录里的挑战值比较,相等才发放令牌。如下图所示,攻击者即使在途中拿到了授权码,也因为没有 code_verifier 而无法兑换。

sequenceDiagram
    participant C as Client
    participant B as Browser
    participant S as Authorization Server
    participant M as Attacker
    C->>C: v = 32 random bytes, c = SHA256(v)
    C-->>B: 302 /authorize?...&code_challenge=c
    B->>S: GET /authorize?...&code_challenge=c
    S->>S: store the code together with c
    S-->>B: 302 /callback?code=...
    B--xM: code leaks (log, Referer, rogue app)
    B->>C: GET /callback?code=...
    M->>S: POST /token code=... without v
    S-->>M: invalid_grant
    C->>S: POST /token code=..., code_verifier=v
    S->>S: check SHA256(v) == c
    S-->>C: tokens

从密码学的角度看,PKCE 是一个基于哈希函数的承诺方案。

温馨提示:承诺方案(commitment scheme)可以类比为把一张纸条封进不透明的信封交给对方:对方现在看不到内容,这叫隐藏性(hiding);你之后也无法偷偷换掉信封里的纸条,这叫绑定性(binding)。到了约定的时刻,你打开信封,对方核对。用哈希函数实现时,信封就是 c = H(v), 开封就是公布 v.

这个视角能帮助我们精确说出 PKCE 需要 SHA-256 的哪些性质。攻击者看到了挑战值 c(它在前端信道里传递,可以认为是公开的),想要的是任意一个满足 SHA256(v') = c 的 v'. 这是一个原像问题,所以 PKCE 依赖的是 SHA-256 的抗原像性。抗碰撞性在这里用不上:碰撞需要攻击者同时选择两个输入,而 v 是诚实的客户端选的。另一方面,单向函数只有在输入空间足够大时才有意义。如果 code_verifier 只是六位数字,攻击者把一百万种可能全部算一遍就能反推出来,所以 RFC 7636 对它的熵有明确的要求。

RFC 7636 还定义了一个 plain 方法,挑战值就是 verifier 本身。它只防得住「能读授权响应、但读不到授权请求」的攻击者,而 S256 在授权请求也被读到时依然有效。规范要求只要客户端能计算 SHA-256, 就必须使用 S256. 授权服务器还需要防范降级:如果授权请求里没有 code_challenge, 兑换时却出现了 code_verifier, 必须拒绝(RFC 9700 第 4.8 节)。

主线在这里第三次出现。PKCE 要证明的是「兑换者持有授权开始时的那个秘密」,这本来是数字签名的拿手好戏,这里一个哈希函数就做到了。原因在于这个秘密只需要证明一次:证明的方式就是把秘密交出来,交出来之后它就作废了。这和 Lamport 一次性签名、S/KEY 一次性口令的思想一脉相承,它们都只用哈希函数,代价都是「每个秘密只能用一次」。第 7 章会看到,当同一个秘密需要被反复证明时,非对称密码学才真正登场。

4.5 授权码注入·

PKCE 的价值不止于防截获。考虑一个更隐蔽的攻击:攻击者截获了受害者 Alice 的授权码 code_A, 但不自己去兑换,而是交给合法的客户端去兑换。

RFC 9700 第 4.5.1 节描述了具体做法。攻击者 Mallory 用自己的浏览器正常访问 NAS, 发起一次登录;授权服务器把 Mallory 自己的授权码 code_M 送回来时,Mallory 把它替换成偷来的 code_A, 再访问回调地址。NAS 是合法的机密客户端,它用自己正确的 client_secret 去兑换 code_A, 授权服务器核对客户端身份无误,签发了 Alice 的令牌。NAS 于是给 Mallory 的浏览器建立了一个属于 Alice 的会话。

客户端认证在这里完全没有被突破,攻击者也没有伪造任何东西,合法的客户端被诱导去完成了一次不该完成的兑换。RFC 9700 第 4.5.2 节专门指出,即使客户端使用非对称密码学做认证,也挡不住这个攻击。问题不在于「兑换者是谁」,而在于「这个授权码属于哪一次授权事务」。

PKCE 恰好补上了这个绑定。如下图所示,code_A 在 Alice 的事务里和 challenge_A 绑定,而 NAS 在 Mallory 的会话里保存的是 verifier_M.

sequenceDiagram
    participant M as Mallory browser
    participant C as NAS (confidential client)
    participant S as Authorization Server
    Note over S: code_A was issued to Alice,<br/>bound to challenge_A = SHA256(verifier_A)
    M->>C: start login
    C->>C: session_M stores verifier_M
    C-->>M: 302 /authorize?...&code_challenge=challenge_M
    M->>S: authorize as Mallory
    S-->>M: 302 /callback?code=code_M
    M->>C: GET /callback?code=code_A (swapped)
    C->>S: POST /token code=code_A, client_secret, code_verifier=verifier_M
    S->>S: SHA256(verifier_M) != challenge_A
    S-->>C: invalid_grant

兑换失败,哪怕 NAS 提交的客户端凭证完全正确。RFC 9700 据此要求公开客户端必须使用 PKCE, 并推荐机密客户端也使用。使用 OIDC 的机密客户端也可以改用 nonce(5.4 节)达到同样的效果。

这个防御也有边界。如果攻击者有能力篡改受害者授权请求里的 code_challenge, 就可以把它换成自己掌握的值,PKCE 的绑定随之失效(RFC 9700 第 4.5.4 节)。所以 PKCE 防的是授权码在返回途中的泄露和注入,而不是授权请求被篡改。

三种机制各自证明的东西不同:

机制 证明了什么 能否单独防御授权码注入
客户端认证 兑换者是注册过的客户端 不能
state 回调属于当前浏览器发起的那次登录 不能(见下一节)
PKCE 授权码属于持有对应 verifier 的那次事务 能

4.6 登录 CSRF 与 state·

把上一节的攻击反过来:Mallory 不是把 Alice 的授权码塞进自己的会话,而是把自己的授权码塞进 Alice 的会话。

术语说明:跨站请求伪造(cross-site request forgery, CSRF)指攻击者诱导受害者的浏览器向目标网站发出一个受害者并不知情的请求。登录 CSRF(login CSRF)是它的一个变种:被伪造的请求是一次登录,结果是受害者被登录进了攻击者的账号。

Mallory 自己走一遍登录,拿到属于 Mallory 账号的 code_M, 但不去兑换,而是在自己的网页里放一个链接或者自动跳转,让 Alice 的浏览器访问 https://nas.example.com/callback?code=code_M. 如果 NAS 不做任何检查,Alice 就在不知情的情况下登录成了 Mallory, 之后上传的文件、保存的设置都进了 Mallory 的账号。

state 参数就是为此设计的。客户端在发起授权请求前生成一个不可预测的随机值,把它和当前浏览器绑定(通常存在一个 Cookie 里,或者存在以 Cookie 为索引的服务端会话里),同时放进授权请求。授权服务器在回调时原样带回。客户端收到回调,先比较 URL 里的 state 与当前浏览器保存的是否一致,不一致就拒绝。

sequenceDiagram
    participant M as Mallory
    participant A as Alice browser
    participant C as NAS
    participant S as Authorization Server
    M->>C: start login
    C-->>M: 302 /authorize?state=s_M, Set-Cookie: state=s_M
    M->>S: authorize as Mallory
    S-->>M: 302 /callback?code=code_M&state=s_M
    Note over M: Mallory stops here and keeps the URL
    M-->>A: lure Alice to /callback?code=code_M&state=s_M
    A->>C: GET /callback?code=code_M&state=s_M (no matching state cookie)
    C-->>A: reject: state mismatch

Alice 的浏览器里没有 s_M, 回调被拒绝。注意 state 是客户端自己生成、自己核对的关联值,不需要授权服务器签名,也不需要任何密码学运算,只需要不可预测。

对比 4.5 节就能看出 state 的局限。在授权码注入里,Mallory 用的是自己的浏览器,state 也是 NAS 发给 Mallory 的,检查完全通过,被调包的只是授权码。PKCE 则两种攻击都能防住:在登录 CSRF 里,Alice 的会话里要么没有 verifier, 要么是和 code_M 不匹配的 verifier. 所以 RFC 9700 第 4.7.1 节允许客户端在确认授权服务器支持 PKCE 之后,依赖 PKCE 来防御 CSRF, 此时 state 可以只用来携带应用自己的状态,比如登录完成后要跳回哪个页面。

温馨提示:保存 state 或 verifier 的 Cookie 不能设成 SameSite=Strict. 回调请求是从授权服务器的域名跳转过来的,对客户端来说属于跨站请求,Strict 的 Cookie 不会被带上,结果每次登录都会因为「找不到 state」而失败。Lax 在顶层的 GET 跳转中会被携带,适用于普通的重定向回调。如果授权服务器用 response_mode=form_post 以跨站 POST 的方式返回结果,Lax 也不会被携带,这时只能用 SameSite=None; Secure.

4.7 混淆攻击·

到目前为止都假设客户端只对接一个授权服务器。实际上很多网站同时提供「用 Google 登录」「用 GitHub 登录」等多个选项。2016 年,Fett, Küsters 和 Schmitz 在对 OAuth 2.0 做形式化安全分析时,发现了针对这种场景的一类攻击。

术语说明:授权服务器混淆攻击(mix-up attack)指攻击者让客户端搞错「这次授权响应来自哪个授权服务器」,从而把诚实授权服务器签发的授权码交给攻击者控制的授权服务器。下文简称「混淆攻击」。

简化后的过程如下。攻击者运营一个授权服务器 evil-as, 并且它在 NAS 上是一个可选的登录方式。

sequenceDiagram
    participant B as Browser
    participant C as NAS
    participant E as evil-as (attacker)
    participant H as honest-as
    B->>C: choose to log in with evil-as
    C->>C: session remembers issuer = evil-as
    C-->>B: 302 evil-as /authorize?client_id=nas-at-evil
    B->>E: GET /authorize
    E-->>B: 302 honest-as /authorize?client_id=nas-at-honest
    B->>H: user logs in at the honest server
    H-->>B: 302 NAS /callback?code=code_H
    B->>C: GET /callback?code=code_H
    C->>E: POST evil-as /token code=code_H
    Note over E: the attacker now holds a valid code_H

用户在诚实授权服务器上看到的是完全正常的登录页面,授权码也被送到了 NAS 正确的回调地址。出问题的是 NAS 的判断:它记得用户选的是 evil-as, 于是把 code_H 拿到 evil-as 的令牌端点去兑换,攻击者就此拿到了一个诚实授权服务器签发的有效授权码,接下来可以配合 4.5 节的注入攻击使用。

防御的关键是让授权响应自报家门。RFC 9207(2022 年)为授权响应增加了 iss 参数,写明签发者的标识。客户端发起请求时记下预期的签发者,收到回调后比较两者,不一致就丢弃(RFC 9700 第 4.4.2.1 节)。另一种做法是为每个授权服务器分配不同的回调地址,但 RFC 9700 认为它只应在前一种做法不可用时使用。

这个攻击值得一提的地方在于,它是在形式化分析中被发现的。协议的每一步单独看都没有问题,漏洞出在多个授权服务器并存时的组合上,而这恰恰是凭直觉最难察觉的地方。

4.8 防御汇总·

把这一章的攻击和防御放在一起:

攻击 防御 建立的绑定 用到的原语
重定向篡改 精确匹配,兑换时复核 redirect_uri 授权码与回调地址 服务端状态
授权码截获 短时效,一次性,重复兑换时撤销,客户端认证 授权码与客户端 随机数,服务端状态,共享密钥
授权码截获(公开客户端) PKCE 授权码与授权事务 随机数,哈希函数
授权码注入 PKCE 或 nonce 授权码与授权事务 随机数,哈希函数
登录 CSRF state 或 PKCE 回调与浏览器 随机数,Cookie
混淆攻击 iss 参数 授权响应与签发者 服务端状态
令牌经前端信道泄露 两步兑换 令牌只走后端信道 TLS

最右一列里没有数字签名。客户端认证可以选用签名,但前面已经看到,它对注入攻击无效。整个授权码流程的安全性,建立在随机数、哈希函数、服务端状态和 TLS 之上。

不过到这里,客户端拿到的还只是一张访问令牌。它说明「持有者可以访问某些资源」,却没有说明「当前登录的是谁」。下一章讨论 OIDC 如何回答这个问题。

5. OpenID Connect·

5.1 授权与认证·

在 OIDC 出现之前,很多网站用 OAuth 做登录的方式是:拿到访问令牌之后,用它调用资源服务器的「获取个人信息」接口,返回谁就认为登录的是谁。这种做法有一个结构性的缺陷,根源在于访问令牌是持有者令牌。

术语说明:持有者令牌(bearer token, RFC 6750)指「谁持有谁就能用」的令牌,使用时不需要证明自己持有任何别的秘密,就像一张不记名的电影票。与之相对的是发送方约束令牌(sender-constrained token),使用时必须同时证明自己持有某个密钥,令牌丢了也不能直接被别人使用。下文简称「持有者令牌」和「约束令牌」。

访问令牌是签发给资源服务器看的,它不会告诉客户端「这张令牌是不是签发给我的」。假设用户曾经在一个恶意应用上用同一个账号授权过,这个恶意应用手里就有一张该用户的访问令牌。如果某个客户端接受由前端提交的访问令牌作为登录凭证(在隐式授权和原生 App 中曾经很常见),恶意应用把这张令牌交给它,它拿去调用个人信息接口,接口如实返回该用户的信息,恶意应用就以该用户的身份登录了。令牌本身没有问题,问题在于它被拿到了一个不是为它准备的地方使用。

OIDC(2014 年发布)的核心补充是 ID Token: 一份由身份提供方签发、专门写给某个客户端看的身份声明,其中的 aud(audience)字段写明了接收方的 client_id. 客户端只接受 aud 包含自己的 ID Token, 别的应用拿到的 ID Token 在这里无效。除此之外,OIDC 还规定了 openid scope, UserInfo 接口、服务发现和 nonce 等配套机制。

5.2 ID Token 结构·

ID Token 是一个 JWT(JSON Web Token, RFC 7519)。最常见的形式由三段 Base64URL 编码的字符串用点连接起来,结构如下图所示。

1
2
3
4
5
6
7
8
9
10
11
12
BASE64URL(header)    . BASE64URL(payload)   . BASE64URL(signature)
| | |
v v v
+--------------------+ +--------------------+ +--------------------------+
| header | | payload | | signature |
| alg: RS256 | | iss: issuer URL | | RS256(private key, |
| kid: 1e9gdk7 | | sub: user id | | signing input) |
| typ: JWT | | aud: client_id | | checked with the public |
| | | exp, iat | | key whose kid matches |
| | | nonce | | (from the JWKS) |
+--------------------+ +--------------------+ +--------------------------+
|<------------- signing input ------------->|

一个典型的载荷:

1
2
3
4
5
6
7
8
9
10
{
"iss": "https://auth.example.com",
"sub": "248289761001",
"aud": "nas",
"exp": 1792500000,
"iat": 1792496400,
"auth_time": 1792496390,
"nonce": "n-0S6_WzA2Mj",
"email": "peng@example.com"
}

有两点需要强调。第一,载荷只是编码,没有加密,任何拿到 JWT 的人都能读出内容,所以里面不能放秘密。第二,签名覆盖的是「头部的编码、一个点、载荷的编码」这串 ASCII 字符,头部也在签名范围内,篡改 alg 会导致签名失效;但如果验证方根据头部里的 alg 来选择验证算法,就会出现 5.5 节的漏洞。

温馨提示:JWT 属于 JOSE(JSON Object Signing and Encryption)规范家族,常见的几个名字如下。JWS(RFC 7515)是带签名或 MAC 的 JSON 结构,日常说的三段式 JWT 就是 JWS. JWE(RFC 7516)是加密的 JSON 结构,有五段。JWK(RFC 7517)是用 JSON 表示的密钥,多个 JWK 组成的集合叫 JWKS(JWK Set)。JWA(RFC 7518)规定了算法名,例如 RS256 表示 RSASSA-PKCS1-v1_5 配合 SHA-256, ES256 表示 P-256 曲线上的 ECDSA 配合 SHA-256, HS256 表示 HMAC-SHA256. Base64URL 是把标准 Base64 里的 + 和 / 换成 - 和 _, 并去掉末尾填充 = 的变体,结果可以直接放进 URL.

5.3 令牌验证·

OIDC Core 第 3.1.3.7 节规定了客户端验证 ID Token 的步骤。去掉加密和扩展相关的条目后,主要是:

  1. iss 必须与身份提供方的标识完全相等;
  2. aud 必须包含自己的 client_id. 如果有多个受众,还要检查 azp;
  3. 按预期的算法验证签名,默认是 RS256;
  4. 当前时间必须早于 exp, iat 也不能离当前时间太远;
  5. 如果认证请求里发送过 nonce, ID Token 里必须有相同的 nonce;
  6. 如果请求过 acr 或 auth_time, 还要检查认证强度和认证时间。

真正有意思的是第 3 步的一个例外。规范的原话是:如果 ID Token 是客户端通过与令牌端点的直接通信拿到的(授权码流程正是如此),那么可以用 TLS 对服务器的验证来代替对令牌签名的检查。

主线在这里第四次出现,而且是以规范条文的形式。签名的作用,是让一个没有直接从签发方手里拿到消息的人,也能确认消息出自签发方。在授权码流程里,ID Token 是客户端通过后端信道从身份提供方的令牌端点亲手取回的,TLS 已经证明了对面就是身份提供方,签名提供的保证与此重复。规范把签名检查定为可选,是对这一事实的承认。

同一节还有一条容易被忽略的规定:如果 ID Token 使用 HS256 这类 MAC 算法,密钥就是该客户端的 client_secret. 身份提供方和每个客户端各自共享一个密钥,发给谁的 ID Token 就用谁的密钥计算 MAC. 这也是一种合理的选择,代价是每个客户端只能验证发给自己的令牌,而且客户端有能力伪造发给自己的令牌。这一点在第 7 章还会用到。

5.4 前端令牌与 nonce·

现在考虑一个更直接的设计:身份提供方把签好名的 ID Token 通过浏览器直接交给应用,应用用身份提供方的公钥验签,不再需要后端信道。这正是 OIDC 隐式流程(response_type=id_token)的做法,「使用 Google 账号登录」的部分前端集成方式也是如此。它省掉了一次服务器之间的请求,应用甚至可以完全离线地完成验证。

这个设计可行,但只靠签名不够。假设 Mallory 通过某种途径(泄露的日志、被注入了脚本的页面)拿到了一张 Alice 的 ID Token, 然后从自己的浏览器提交给应用。签名有效,iss 是身份提供方,aud 是这个应用,exp 也还没到,所有检查都通过,Mallory 就以 Alice 的身份登录了。

签名证明的是「身份提供方说过这句话」,却不能证明「这句话是在回应我当前这次登录请求」。补上这个缺口的是 nonce: 应用在发起认证请求前生成一个随机值,绑定到当前浏览器的会话,并放进认证请求;身份提供方把它原样写进 ID Token 的载荷,再一起签名;应用验证时检查载荷里的 nonce 是否等于当前会话保存的值。

sequenceDiagram
    participant A as Alice browser
    participant M as Mallory browser
    participant P as App
    participant I as Identity Provider
    A->>P: start login
    P-->>A: session_A stores nonce n_A
    A->>I: authenticate with nonce = n_A
    I-->>A: signed id_token containing nonce n_A
    A->>P: submit id_token
    P->>P: signature ok, nonce == n_A, login ok
    Note over M: Mallory steals the id_token of Alice
    M->>P: start login
    P-->>M: session_M stores nonce n_M
    M->>P: submit the stolen id_token
    P->>P: signature ok, but n_A != n_M
    P-->>M: reject

Mallory 无法把令牌里的 n_A 改成 n_M, 因为改动会破坏签名。签名和 nonce 各自承担了一半:前者证明来源,后者证明这份声明属于当前这一次登录。OIDC Core 第 3.2.2.10 节规定隐式流程必须使用 nonce.

即便如此,前端令牌方案仍然把高价值的凭证暴露在前端信道里,所以对有服务器端的应用来说,授权码流程依然是首选。

5.5 JWT 实现陷阱·

JWT 的规范本身并不复杂,历史上的漏洞大多出在实现上。RFC 8725(JWT 最佳实践,2020 年)总结了其中的典型问题。

不签名的令牌。 JWS 规范允许一种不签名的形式,alg 写成 none, 签名段为空。早期一些库根据头部的 alg 决定如何验证,攻击者只要把 alg 改成 none 并去掉签名,令牌就能通过。

算法混淆。 服务端用 RSA 公钥验证 RS256 令牌,而验证库同样根据头部选择算法。攻击者把头部改成 HS256, 然后用服务端的 RSA 公钥(它是公开的)作为 HMAC 密钥计算 MAC. 库读到 HS256, 拿起手边的「密钥」,也就是那串公钥字节,去算 HMAC, 结果完全一致。从密码学角度看,这个漏洞的本质是同一串字节在一个算法里是公钥,在另一个算法里却成了对称密钥。密钥必须与算法绑定,不能让消息自己声明用什么算法来验证它。

kid 注入。 kid 告诉验证方用哪一把密钥。如果实现拿 kid 去拼文件路径或 SQL 查询,攻击者就能把它指向一个内容已知的文件,再用这个已知内容作为 HMAC 密钥伪造令牌。类似地,头部的 jku 和 x5u 可以携带一个密钥集合的 URL, 验证方如果照着去取公钥,攻击者就能让验证方用攻击者的公钥验证攻击者的签名。

漏检声明。 只验签名、不验 aud 和 iss, 别的应用的令牌就能在这里使用;不区分令牌类型,访问令牌格式的 JWT 就可能被当成 ID Token 接受。RFC 8725 建议用 typ 头显式标注类型。

对应的原则只有几条:为每个签发方固定允许的算法,不信任头部的 alg; 公钥只从预先配置的地址获取;每一个声明都按预期检查。

5.6 公钥分发与轮换·

验证 ID Token 需要身份提供方的公钥。OIDC 用两份公开文档解决公钥分发。服务发现文档位于 https://<issuer>/.well-known/openid-configuration, 列出授权端点、令牌端点、支持的算法,以及公钥集合的地址 jwks_uri; 后者返回一个 JWKS:

1
2
3
4
5
6
{
"keys": [
{ "kty": "RSA", "kid": "1e9gdk7", "use": "sig", "alg": "RS256", "n": "w7Zdfmece8iaB0kiTY8pCtiBtzbptJmP28nSWwtdjRu0f2GFpajvWE4VhfJA...", "e": "AQAB" },
{ "kty": "RSA", "kid": "2c7f9a1", "use": "sig", "alg": "RS256", "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtV...", "e": "AQAB" }
]
}

轮换密钥时,身份提供方先把新公钥加进集合,过一段时间再开始用新私钥签名,旧公钥保留到用它签过的令牌都过期为止。JWT 头部的 kid 告诉验证方该用集合里的哪一把。验证方缓存这个集合,遇到不认识的 kid 时重新拉取一次,拉取频率要有上限,防止攻击者用随机的 kid 拖垮验证方。

值得注意的是,这些公钥本身是通过 HTTPS 获取的。签名的信任根,最终还是落在 TLS 证书体系上。第 7 章会回到这一点。

身份提供方在 ID Token 上签名,是为了让客户端在某些情况下可以离线验证。下一章的 Cloudflare Access 则是一个签名必不可少的例子。

6. Cloudflare Access·

6.1 Tunnel 与 Access·

Cloudflare Tunnel 和 Cloudflare Access 是两个独立的配置。Tunnel 里的发布规则把一个域名映射到内网服务,只解决可达性;Access 里的应用(Access application)把一个域名(可以细化到路径)和一组访问策略绑定在一起,解决身份。创建 Tunnel 不会自动创建 Access 应用,没有 Access 应用的域名对所有人开放。

Access 的检查发生在 Cloudflare 边缘:请求到达边缘后先经过 Access, 通过了才会被送进 Tunnel. 整体结构如下图所示。

flowchart LR
    B["Browser"]
    subgraph CF["Cloudflare"]
        E["edge for nas.example.com<br/>Access check"]
        T["team domain<br/>team.cloudflareaccess.com"]
    end
    I["identity provider<br/>email PIN / GitHub / Google / SAML"]
    subgraph HOME["home network"]
        D["cloudflared<br/>Protect with Access"]
        O["origin<br/>192.168.1.10:8080"]
    end
    B -->|"HTTPS"| E
    B -.->|"login redirects"| T
    T -.->|"OIDC / SAML"| I
    E -->|"tunnel + Cf-Access-Jwt-Assertion"| D
    D -->|"only if the JWT is valid"| O

6.2 三重角色·

对照前几章的概念,Access 同时扮演了三个角色:

角色 面向谁 对应前文的概念
认证网关 浏览器 第 2 章的反向代理加认证服务,只是部署在 Cloudflare 边缘
依赖方 身份提供方(GitHub, Google, Okta 等) 第 3 到 5 章的 OAuth 客户端或 OIDC 依赖方
令牌签发方 源站和 cloudflared 相当于一个身份提供方,签发自己的 JWT

第三个角色值得多说一句。无论用户是用邮箱验证码、GitHub 还是公司的 SAML 系统登录的,源站收到的都是同一种格式、由同一个签发方签名的 JWT. 源站只需要信任 Cloudflare 一家,不必关心背后接了多少种身份提供方。这种在中间做一次身份归一的结构,通常称为身份代理(identity broker)。

6.3 跳转链路·

说明:Cloudflare 的文档没有完整列出登录过程中的每一个跳转地址。下面的路径和参数名是典型情况,可能随版本变化,请以浏览器开发者工具中实际记录到的跳转为准。

假设 nas.example.com 受 Access 保护,团队域名是 team.cloudflareaccess.com. 用户第一次访问时,链路大致如下。

sequenceDiagram
    autonumber
    participant B as Browser
    participant E as Edge (nas.example.com)
    participant T as Team domain
    participant I as Identity Provider
    participant D as cloudflared
    participant O as Origin
    B->>E: GET /files (no CF_Authorization)
    E-->>B: 302 team domain /cdn-cgi/access/login/nas.example.com?kid=AUD&redirect_url=/files
    B->>T: GET /cdn-cgi/access/login/...
    alt no valid global session
        T-->>B: 302 identity provider /authorize?...
        B->>I: user authenticates
        I-->>B: 302 team domain /cdn-cgi/access/callback?code=...&state=...
        B->>T: GET /cdn-cgi/access/callback?...
        T->>I: back channel: redeem the code, verify the id_token
        T->>T: evaluate the Access policy of nas.example.com
        T-->>B: Set-Cookie CF_Authorization (team domain), 302 nas.example.com /cdn-cgi/access/authorized?token=...
    else global session still valid
        T->>T: evaluate the Access policy of nas.example.com
        T-->>B: 302 nas.example.com /cdn-cgi/access/authorized?token=...
    end
    B->>E: GET /cdn-cgi/access/authorized?token=...
    E-->>B: Set-Cookie CF_Authorization (host-only), 302 /files
    B->>E: GET /files, Cookie: CF_Authorization
    E->>E: verify the JWT and the policy
    E->>D: forward through the tunnel, Cf-Access-Jwt-Assertion
    D->>D: verify signature, iss, aud, exp
    D->>O: proxy the request
    O-->>B: 200 (back through cloudflared and the edge)

这条链路几乎就是前几章内容的组合:

  • 开头的「未登录就跳转」是第 2 章的认证网关,只不过网关是 Cloudflare 边缘;
  • alt 块里是一次标准的 OIDC 授权码流程,团队域名是依赖方,https://team.cloudflareaccess.com/cdn-cgi/access/callback 就是它在身份提供方那里登记的 redirect_uri. 如果登录方式是邮箱一次性验证码(One-time PIN),这一段由 Cloudflare 自己完成,不涉及外部身份提供方;
  • 之后的跳转把凭证从团队域名带到应用域名。

最值得注意的是 /cdn-cgi/access/authorized 这一跳,它解决的正是 2.5 节的 Cookie 域名问题。团队域名 team.cloudflareaccess.com 无法为 nas.example.com 设置 Cookie, Access 的做法是把凭证放进 URL, 让浏览器带着它跳到 nas.example.com 下的一个特殊路径,由那里的响应设置 host-only 的 CF_Authorization. 这条路径之所以可用,是因为 Cloudflare 的边缘接管了所有经它代理的域名,可以在任何一个域名下保留 /cdn-cgi/ 开头的路径给自己用。换句话说,Access 同时用了 2.5 节的两个方案:像方案 B 那样在应用自己的域名下挂一个由认证系统控制的路径,再像方案 C 那样用一次前端信道的回调把凭证送过去。

团队域名上的 CF_Authorization 则是全局会话。用户之后访问另一个受保护的应用时,跳到团队域名就会发现全局会话仍然有效,于是直接跳回并带上新应用的凭证,用户感觉不到登录过程。这就是单点登录。

根据 Cloudflare 的文档,Access 在两个域名上各设置一个 CF_Authorization:

所在域名 内容 HttpOnly SameSite 有效期
团队域名 全局会话令牌(JWT, type 为 org) 是 None 全局会话时长,默认 24 小时
应用域名 应用令牌(JWT, type 为 app) 管理员设置,默认不设 管理员设置,默认不设 策略或应用会话时长,默认 24 小时

由于令牌是自包含的 JWT, 边缘可以只验证签名和有效期,不必每次查询会话数据库。代价也是 2.3 节说过的:JWT 签发之后很难在过期前收回,会话时长本质上是在安全和便利之间取舍。

应用域名上的 CF_Authorization 默认没有 HttpOnly, 页面里的脚本可以读到它;源站收到的请求里也带着它和 Cf-Access-Jwt-Assertion 头,源站的日志、源站上的漏洞都可能把它泄露出去。它是一张持有者令牌,泄露就意味着可以被重放。

Access 为此提供了一个可选的绑定 Cookie CF_Binding. 开启后,用户认证时会同时获得一个与该应用的 CF_Authorization 绑定的 CF_Binding; 边缘要求两者同时出现,单独的 CF_Authorization 会被拒绝;而 CF_Binding 在边缘就被剥离,永远不会到达源站。于是,从源站一侧泄露出去的令牌无法再通过边缘使用。这和 5.1 节提到的约束令牌是同一个思路,只是这里的「证明」是另一个 Cookie, 而不是一把私钥:它挡住的是「源站泄露」这条具体路径,挡不住浏览器本身被攻破。文档同时提醒,SSH, RDP 这类非浏览器客户端不要开启它。

6.5 Access JWT·

应用令牌的载荷大致如下,字段含义来自 Cloudflare 文档:

1
2
3
4
5
6
7
8
9
10
11
12
{
"aud": ["32eafc7626e974616deaf0dc3ce63d7bcbed58a2731e84d06bc3cdf1b53c4228"],
"email": "peng@example.com",
"exp": 1792582800,
"iat": 1792496400,
"nbf": 1792496400,
"iss": "https://team.cloudflareaccess.com",
"type": "app",
"identity_nonce": "6ei69kawdKzMIAPF",
"sub": "7335d417-61da-459d-899c-0a01c76a2f94",
"country": "CN"
}
  • aud: Access 应用的 AUD 标签,说明令牌是签发给哪个应用的;
  • iss: 团队域名;
  • type: app 表示应用令牌,org 表示全局会话令牌;
  • identity_nonce: 查询完整身份信息时用的缓存键,完整身份可以带着 Cookie 访问 /cdn-cgi/access/get-identity 获取;
  • sub: 用户 ID.

头部的 alg 是 RS256, kid 指明签名用的是哪一把密钥。公钥发布在:

1
https://team.cloudflareaccess.com/cdn-cgi/access/certs

返回内容包含 JWK 格式的 keys, 以及 PEM 格式的 public_cert 和 public_certs. 文档提醒验证方要按 kid 在 public_certs 里匹配,不要只用 public_cert, 因为它只是当前那一把。Access 默认每 6 周轮换一次签名密钥,旧密钥在轮换后继续有效 7 天。

主线在这里第五次出现,这一次的答案是「必须用非对称」。设想 Access 改用 HMAC 签发 JWT, 那么每个要验证令牌的源站都得持有那把对称密钥。源站数量众多,分布在 Cloudflare 的信任边界之外,任何一台被攻破,攻击者就能用这把密钥给团队里的任意应用签发任意用户的令牌。aud 检查也无济于事,因为 aud 是伪造者自己写的。用 RSA 签名之后,源站只拿到公钥,能验证却不能伪造。验证方不是签发方,并且验证方不应该具备签发能力,这正是非对称密码学不可替代的场景。

6.6 Protect with Access·

Access 在边缘挡住了未认证的请求,那么源站为什么还要再验证一遍?因为边缘的检查依赖配置,而配置可能出错:Access 应用被误删;Access 应用只保护了某个路径,而 Tunnel 发布了整个域名;有人给同一个内网服务新加了一个没有受 Access 保护的域名。在这些情况下,请求会不经 Access 直接穿过 Tunnel 到达源站。

cloudflared 的 Protect with Access 功能把检查延伸到了隧道的末端。开启后,cloudflared 在把请求转给源站之前,先验证 Cf-Access-Jwt-Assertion 头里的 JWT. 在控制台里,它位于 Tunnel 发布规则的 Access 设置中,需要填写团队名和 AUD 标签;本地管理的隧道则写在配置文件里(/etc/cloudflared/config.yml):

1
2
3
4
5
6
7
8
9
10
11
12
13
tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /etc/cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json

ingress:
- hostname: nas.example.com
service: http://192.168.1.10:8080
originRequest:
access:
required: true
teamName: team
audTag:
- 32eafc7626e974616deaf0dc3ce63d7bcbed58a2731e84d06bc3cdf1b53c4228
- service: http_status:404

这样,即使边缘的配置出了问题,没有有效 JWT 的请求也会在 cloudflared 这里被挡下。这是纵深防御:认证决策在边缘做出,在离源站最近的地方再核验一次。

AUD 标签就是 JWT 里 aud 字段的取值,每个 Access 应用一个,除非删除重建应用,否则不会改变。它不是秘密,只是一个标识符,作用和 OIDC 的 aud 检查完全一样。假设同一个团队下还有一个策略更宽松的应用 wiki.example.com, 任何公司邮箱都能登录,那么它的令牌签名同样有效,但 aud 是 wiki 的标签,NAS 前面的 cloudflared 会拒绝它。

Protect with Access 的保护范围也要说清楚:它只验证经过隧道的请求。如果内网里的 192.168.1.10:8080 可以被局域网内的其他设备直接访问,这条路径完全不经过 cloudflared. 2.4 节的「应用不能被绕过」在这里同样成立。另外,验证通过只说明请求来自一个满足 Access 策略的用户,应用自身的权限检查仍然需要保留。

温馨提示:在 Cloudflare 控制台的 Tunnel 设置里填写 AUD 时,输入框是一个多值标签控件。粘贴之后要按回车,让它变成一个独立的标签,否则保存时会报错 Add at least one AUD tag when Protect with Access is on, 尽管输入框里看起来已经有值了。

6.7 登录方式与 TOTP·

Access 支持多种登录方式:邮箱一次性验证码(One-time PIN),GitHub, Google 等通过 OIDC 接入的身份提供方,基于 SAML 的企业身份系统,以及 Cloudflare 账号本身。一次性验证码的认证依据是「能收到这个邮箱的邮件」,每次会话过期都需要收一次邮件。

2026 年 4 月起,Access 支持独立于身份提供方的多因素认证(independent MFA),由 Access 自己验证第二因素,支持 TOTP 验证器应用,以及安全密钥和生物识别。它可以在组织、应用和策略三个层级配置,优先级是策略高于应用、应用高于组织。需要注意的是,它是第一因素之后的第二因素,不能单独作为登录方式。

温馨提示:TOTP(RFC 6238)就是 Bitwarden, Google Authenticator 这类应用里每 30 秒变一次的六位数字。计算方法是先把当前时间换算成计数器 T = floor((now - T0) / 30), 再用双方共享的密钥 K 计算 HMAC-SHA1(K, T), 从结果里截取一个 31 位整数,对 10^6 取模。这是一个纯对称的认证方案:服务器必须保存 K 的原文才能验证,K 一旦从服务器泄露,攻击者就能生成任意时刻的验证码。六位数字只有一百万种可能,服务器通常还会接受前后各一个时间窗口,所以每次猜测的成功概率约为三十三万分之一,必须配合严格的失败次数限制。这也是大多数系统只把它当作第二因素的原因:如果只凭六位数字登录,又没有用户名,所有攻击者都在猜同一个密钥。

温馨提示:Jellyfin, Immich 这类服务的手机客户端和电视客户端不会执行浏览器式的跳转登录,放在 Access 后面往往无法使用。Access 为非浏览器客户端准备了服务令牌(service token),客户端在请求头里携带 CF-Access-Client-Id 和 CF-Access-Client-Secret, 本质上是一对共享密钥。较新的控制台里,应用设置中还有一个处于测试阶段的 Managed OAuth 选项,让 Access 为该应用代管 OAuth 授权,供能够自行完成 OAuth 流程、并支持 RFC 8707(资源指示符)的客户端使用。

7. 对称与非对称·

现在可以回到第 1 章的第三个问题了。

7.1 原语分工·

把全文出现过的机制放在一张表里:

机制 要证明什么 谁来验证 用到的原语
网关会话 Cookie 请求属于一个已登录的会话 签发方自己 随机 ID 或 HMAC
授权码 授权码由我签发、未过期、未使用 签发方自己 随机数,服务端状态
client_secret 兑换者是注册过的客户端 授权服务器 共享密钥
PKCE 兑换者发起过这次授权 授权服务器 哈希承诺
state 回调属于这个浏览器发起的登录 客户端自己 随机数,Cookie
ID Token(后端信道) 身份声明出自身份提供方 客户端 TLS, 签名可省
ID Token(前端信道) 同上,并且属于本次登录 客户端 签名,nonce
TOTP 用户持有共享密钥 认证服务器 HMAC
Access JWT 身份声明出自 Access 源站,cloudflared RSA 签名
TLS 握手 对方是这个域名的合法持有者 浏览器,客户端 证书签名,密钥交换

签名只在少数几行出现。规律藏在「谁来验证」这一列里。

7.2 在线与离线验证·

区分这些场景的,是验证方与签发方的关系,以及验证方是否可以拥有签发能力,如下图所示。

flowchart TB
    Q1{"Is the verifier<br/>the issuer itself?"}
    Q1 -->|"yes"| R1["random handle + server-side state,<br/>or a MAC under a key the issuer keeps<br/>(session ID, authorization code)"]
    Q1 -->|"no"| Q2{"May the verifier hold<br/>the power to issue?"}
    Q2 -->|"yes, one key per pair"| R2["MAC with a pairwise key<br/>(client_secret, HS256 ID Token, TOTP)"]
    Q2 -->|"no, or too many verifiers"| R3["digital signature<br/>(front-channel ID Token,<br/>Access JWT, TLS certificates)"]

第一种情况:签发方自己验证。签发方不需要说服自己「这是我签发的」,只需要认出它。随机句柄加上服务端记录就够了,而且查表还能顺带检查是否过期、是否用过、是否被撤销,这些是签名做不到的。不想保存状态时,用签发方自己保管的密钥计算 MAC 也可以。

第二种情况:验证方不是签发方,但两者之间可以一对一共享密钥。MAC 的特点是能验证的人也能伪造,但如果每一对关系都有独立的密钥,验证方伪造出来的东西也只能骗它自己。client_secret, OIDC 的 HS256 ID Token 和 TOTP 都属于这种情况。

第三种情况:验证方不是签发方,而且验证方数量很多,或者不能信任它们拥有签发能力,或者事先根本没有机会和每个验证方共享密钥。这时就必须用签名。MAC 的验证能力和生成能力是同一种能力,签名把这两种能力拆开了:私钥签名,公钥验证,持有公钥的人无法签名。「非对称」这个名字说的正是这种能力上的不对称。

所以,授权码流程里没有签名,并不是设计者回避了非对称密码学,而是在那些环节里,没有人需要「能验证但不能伪造」这种性质。

7.3 下沉到 TLS·

不过,说 OAuth 2.0 不需要非对称密码学,还不完全准确。前面的每一个环节都默认了一个前提:浏览器与服务器之间、客户端与授权服务器之间的连接都是 TLS. 客户端在把 client_secret 和 code_verifier 发出去之前,需要确信对面就是真正的授权服务器,而不是一个中间人。这个保证来自 TLS 握手中的证书验证,也就是证书链上的数字签名,以及基于椭圆曲线 Diffie-Hellman 的密钥交换。

换句话说,公钥密码学并没有消失,而是下沉到了传输层。TLS 一次性解决了「对方是谁」和「信道保密」两个问题,应用层协议就可以放心地传递持有者令牌,只用随机数和哈希来建立各种绑定关系。

OAuth 的历史清楚地展示了这一点。2010 年发布的 OAuth 1.0(RFC 5849)没有假设所有连接都走 HTTPS, 因此要求客户端对每个请求签名,主要用的是 HMAC-SHA1, 也可以用 RSA-SHA1; 只有在 TLS 之上才允许使用明文的 PLAINTEXT 方法。请求签名实现起来很麻烦,参数排序和编码稍有差异就会验证失败。2012 年的 OAuth 2.0 去掉了请求签名,改为强制使用 TLS, 并引入了持有者令牌(RFC 6750)。这个决定当时颇有争议,OAuth 1.0 规范的作者、OAuth 2.0 规范最初的编辑 Eran Hammer 在 2012 年退出了编辑工作,公开批评新协议把安全性过多地押在了 TLS 和实现者的谨慎上。

温馨提示:完全不用非对称密码学的认证协议是存在的,最著名的是 Kerberos. 它源自 Needham-Schroeder 对称密钥协议:每个用户和每个服务都与一个可信的密钥分发中心共享一把长期密钥,由中心签发「票据」,用户凭票据访问服务。它的结构和 OAuth 很像,都是一个可信的第三方给出凭证,别人凭凭证办事。代价是所有参与者都必须事先在中心登记密钥,这在一个组织内部可行,在开放的互联网上就做不到了。OAuth 能在互联网上运作,靠的是 TLS 和 Web 公钥基础设施替它完成了「事先登记」这一步。

7.4 非对称的增量·

那么在应用层,非对称密码学能带来哪些 TLS 之外的东西?有三类。

反复证明同一个秘密。 4.4 节说过,PKCE 用哈希承诺证明持有秘密,代价是证明一次就要交出秘密。如果希望每一个请求都证明「我持有某个密钥」,而且不暴露这个密钥,就需要签名。DPoP(RFC 9449, 2023 年)就是这样做的:客户端持有一对密钥,每次调用 API 时用私钥签一个短期的证明 JWT, 内容包括请求方法、URL 和时间;访问令牌与公钥的指纹绑定,成为约束令牌。令牌被偷走,没有私钥也用不了。DPoP 第 10 节还定义了 dpop_jkt 参数,把授权码也绑定到这对密钥上,相当于一个非对称版本的 PKCE. 用双向 TLS 证书绑定令牌的 RFC 8705 也是同一类思路。

服务端不保存可冒用的秘密。 TOTP 和 client_secret 都要求服务端保存密钥原文,服务端泄露就等于凭证泄露。private_key_jwt 和 Passkey(WebAuthn)让服务端只保存公钥。Passkey 还多了一层保护:签名的内容包含浏览器确认过的网站来源,在仿冒域名上产生的签名无法用于真实的域名,这是它能够抵抗钓鱼的根本原因。

第三方离线验证。 也就是 ID Token 和 Access JWT 的场景,前面已经讨论过。

最后澄清一个容易混淆的设计:让授权服务器给授权码签名,并不能替代 PKCE. 授权服务器的签名证明的是「这个授权码出自我手」,攻击者截获授权码时会连同签名一起截获,照样可以拿去用。PKCE 要证明的是「兑换者就是发起者」,这是持有证明,而不是来源证明。这两种性质经常被「签名」这个词混在一起,但它们是完全不同的东西。

回到第 1 章的第三个问题。认证协议并不必然依赖公钥密码学,关键在于先说清楚需要哪一种绑定关系:凭证与签发方、凭证与客户端、凭证与这一次事务、凭证与这个浏览器、凭证与发送它的那把密钥,再为每一种关系选择足够用的最小原语。OAuth 授权码流程的大部分绑定只需要随机数、哈希和服务端状态。公钥密码学集中出现在三个地方:TLS 握手,需要被第三方离线验证的身份声明,以及需要反复证明持有权的场景。

8. 参考资料·