几乎每个有登录功能的系统都在用 OAuth 2.0,但"能用"和"用对"之间隔着一整代安全审计发现。OAuth 2.0(RFC 6749)本身只定义了授权框架,把大量安全决策留给实现者,结果就是重定向 URI 校验不严、隐式模式泄露令牌、refresh token 被重放等经典问题反复出现。OAuth 2.1 把这些最佳实践收编为强制要求,OpenID Connect(OIDC)则在授权之上补出身份层。本文从协议演进讲到授权服务器的落地实现,覆盖流程、令牌、校验与常见坑。
一、OAuth 2.1 相对 2.0 的关键变化
OAuth 2.1 不是一份全新协议,而是把十年来的安全 BCP(RFC 8252、RFC 7636、RFC 9700)合并进主规范,同时删掉已被证明不安全的模式。
| 变化 | 2.0 状态 | 2.1 要求 |
|---|---|---|
| PKCE | 可选(移动端推荐) | 所有授权码流程强制 |
| 隐式模式(Implicit) | 可用 | 已移除 |
| 密码模式(ROPC) | 可用 | 已移除 |
| 客户端凭证 | 可用 | 保留,仅限机密客户端 |
| 重定向 URI 匹配 | 允许前缀/模糊匹配 | 必须精确字符串匹配 |
| refresh token | 可长期有效 | 对公开客户端必须轮换或发送者约束 |
| bearer token 使用 | 主流 | 鼓励改用发送者约束令牌(DPoP/mTLS) |
三点最需要记住:
- PKCE 不再是移动端专属。即便是服务端 Web 应用,也必须走 PKCE,因为授权码可能通过 Referer、日志、浏览器历史泄露。
- 隐式模式彻底退场。SPA 应改用授权码 + PKCE,不再从 URL fragment 里取 token。
- 公开客户端必须让 refresh token 单次使用,一旦检测到重放立即吊销整条令牌链。
二、授权码 + PKCE 流程
这是当前唯一推荐的交互式流程。核心是:客户端先本地生成 code_verifier,把它的哈希 code_challenge 随授权请求带上,换令牌时再用原始 code_verifier 证明"我就是发起授权的那个客户端"。
客户端 授权服务器 资源服务器
|-- 1. 生成 code_verifier + code_challenge --|
|-- 2. /authorize?response_type=code&code_challenge=... -->|
|<-- 3. 302 redirect_uri?code=xxx&state=yyy --------------|
|-- 4. POST /token (code + code_verifier) ---------------->|
|<-- 5. access_token + refresh_token + id_token ----------|
|-- 6. GET /resource Authorization: Bearer <access> ------------------>|
2.1 参数构造
GET /authorize?
response_type=code
&client_id=web-app
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email%20orders.read
&state=<32字节随机值>
&nonce=<32字节随机值>
&code_challenge=<BASE64URL(SHA256(verifier))>
&code_challenge_method=S256
逐项含义:
state:防 CSRF,回调时必须原样比对,用后即弃。nonce:仅 OIDC 需要,会被写进 ID Token,用于防重放。code_challenge_method:固定用S256,不要用plain。scope:openid是 OIDC 的开关,缺了它返回的就不是 ID Token 而是普通授权响应。
2.2 客户端实现(Node.js)
import crypto from 'node:crypto';
const base64url = (buf) =>
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// 1. 生成 verifier 与 challenge
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
const state = base64url(crypto.randomBytes(32));
// 2. 把 verifier 与 state 存进会话(服务端)或 sessionStorage(SPA)
session.oauth = { verifier, state };
// 3. 跳转授权端点
const authUrl = new URL('https://id.example.com/authorize');
authUrl.search = new URLSearchParams({
response_type: 'code',
client_id: 'web-app',
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
});
res.redirect(authUrl.toString());
回调处理时先校验 state,再用 code + verifier 换令牌:
app.get('/callback', async (req, res) => {
if (req.query.state !== session.oauth.state) return res.status(400).end();
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: 'https://app.example.com/callback',
client_id: 'web-app',
code_verifier: session.oauth.verifier,
});
const tokenRes = await fetch('https://id.example.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
});
// ...
});
注意 redirect_uri 在 /authorize 与 /token 两次请求里必须完全一致,否则授权服务器应拒绝。
三、OIDC 与 ID Token
OAuth 只解决"授权"(我能访问什么),OIDC 解决"认证"(你是谁)。ID Token 是一个 JWT,由授权服务器签名,客户端不得用它去访问资源服务器,它的唯一用途是告诉客户端"用户身份是什么"。
3.1 关键 claims
| claim | 含义 | 校验要点 |
|---|---|---|
iss | 签发者 | 必须等于预期的 issuer,且做精确匹配 |
aud | 受众 | 必须包含本客户端 client_id |
exp / iat | 过期/签发时间 | 校验过期,容忍少量时钟偏移 |
nonce | 请求时携带 | 必须与发起时一致,防重放 |
sub | 用户唯一标识 | 同一 issuer 内稳定,跨 issuer 不保证 |
azp | 授权方 | 多受众时必须校验 |
at_hash | access_token 哈希 | 与 access_token 绑定,防替换 |
3.2 校验步骤
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://id.example.com/.well-known/jwks.json'));
async function verifyIdToken(idToken, expectedNonce) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://id.example.com',
audience: 'web-app',
clockTolerance: 5, // 秒
});
if (payload.nonce !== expectedNonce) throw new Error('nonce mismatch');
return payload;
}
三个容易漏掉的点:
- 算法白名单:只接受
RS256/ES256等非对称算法,绝不允许none,也不要用客户端密钥去校验HS256(算法混淆攻击)。 - JWKS 缓存:每次请求都拉 JWKS 会成为性能瓶颈,应缓存并在遇到未知
kid时刷新一次。 - Discovery:授权服务器的元数据在
/.well-known/openid-configuration,客户端应据此发现端点而非硬编码。
3.3 端点一览
curl -s https://id.example.com/.well-known/openid-configuration | jq '{
issuer, authorization_endpoint, token_endpoint, jwks_uri,
userinfo_endpoint, end_session_endpoint,
code_challenge_methods_supported, id_token_signing_alg_values_supported
}'
四、授权服务器实现要点
无论选 Keycloak、ORY Hydra 还是自研,授权服务器侧有几处必须做对。
4.1 客户端注册与类型
| 客户端类型 | 能否保密 | 典型场景 | 认证方式 |
|---|---|---|---|
| 机密(confidential) | 能 | 后端 Web 应用 | client_secret_basic 或 mTLS |
| 公开(public) | 不能 | SPA、移动 App | 仅 PKCE,无密钥 |
| 机对机 | 能 | 服务间调用 | client_credentials + mTLS/私钥 JWT |
公开客户端不得嵌入 client_secret——反编译即泄露。它们的安全性完全建立在 PKCE 与精确 redirect_uri 之上。
4.2 redirect_uri 精确匹配
这是历史上漏洞最多的参数。规则必须是:
注册:https://app.example.com/callback
接受:https://app.example.com/callback
拒绝:https://app.example.com/callback/../evil
拒绝:https://app.example.com/callback?x=1
拒绝:https://app.example.com.evil.com/callback
拒绝:https://app.example.com/CALLBACK
校验应做字符串全等,而不是"前缀匹配"或"URL 规范化后匹配"。若需要多个回调地址,逐一注册。移动端可用私有 URI scheme(如 myapp://callback)或 RFC 8252 推荐的 loopback 重定向(http://127.0.0.1:<随机端口>/callback),后者必须允许任意端口但固定主机。
4.3 scope 与权限
scope 是粗粒度的权限声明,服务端必须按 scope 逐项鉴权,而不是"有 token 就放行"。资源服务器收到 token 后要校验 scope 是否包含该接口所需权限:
@PreAuthorize("hasAuthority('SCOPE_orders.read')")
@GetMapping("/api/orders/{id}")
public Order getOrder(@PathVariable String id) { ... }
细粒度授权(如"只能读自己的订单")不能靠 scope,要靠资源服务器自己的业务校验,或引入 UMA/RAR(Rich Authorization Requests)。
4.4 令牌端点加固
/token 是攻击者重点关照的端点,需要额外的防护:
- 客户端认证:机密客户端用
client_secret_basic(HTTP Basic)或private_key_jwt;公开客户端只能靠 PKCE,不要给它分配密钥。 - 速率限制:按
client_id与来源 IP 双维度限流,防止授权码/令牌暴力枚举。 - 授权码单次使用:授权码在成功换令牌后立即失效;若同一
code被第二次使用,吊销该 code 已签发的全部令牌(这是重放攻击的强信号)。 - 响应头:
Cache-Control: no-store,避免令牌被中间缓存。
POST /token HTTP/1.1
Host: id.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic d2ViLWFwcDpzZWNyZXQ=
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json
{"access_token":"...","token_type":"Bearer","expires_in":900,"refresh_token":"...","id_token":"..."}
4.5 登出与会话终止
OIDC 的 RP-Initiated Logout 定义了 end_session_endpoint,客户端登出时跳转到该端点并带上 id_token_hint 与 post_logout_redirect_uri:
GET /logout?
id_token_hint=<ID Token>
&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Flogged-out
&client_id=web-app
要点:post_logout_redirect_uri 同样需要预先注册并精确匹配,否则又是一条开放重定向。SSO 场景下,登出必须清理授权服务器侧的会话,否则用户在下一个应用会被静默重新登录。
五、令牌生命周期与发送者约束
5.1 access token:短时效 + 不透明优先
- access token 建议有效期 5~15 分钟,过期后用 refresh token 换新。
- 优先使用不透明令牌(opaque),资源服务器通过自省端点(RFC 7662)校验。它可即时吊销,不像 JWT 需要等过期。
- 若用 JWT 做 access token,必须接受"签发后到过期前无法撤销"的现实,因此有效期要更短,并配合撤销列表(jti 黑名单)。
5.2 refresh token 轮换与重放检测
对公开客户端,refresh token 必须单次使用 + 轮换:
客户端 POST /token grant_type=refresh_token refresh_token=RT1
服务器:吊销 RT1,签发 RT2 + 新 access_token
若再次收到 RT1(重放)→ 判定令牌链被窃,吊销该用户所有 refresh token
这条"检测到重放就全链吊销"的规则是把泄露窗口压到最小的关键。refresh token 本身应存于服务端存储(或 HttpOnly Cookie),绝不放 localStorage。
5.3 发送者约束:DPoP 与 mTLS
bearer token 的问题是"谁拿到谁能用"。发送者约束令牌(sender-constrained token)要求客户端证明持有私钥:
- mTLS(RFC 8705):客户端用证书绑定 token,资源服务器校验证书指纹与 token 里的
cnf一致。 - DPoP(RFC 9449):客户端为每个请求生成一个 DPoP proof JWT,用私钥签名并绑定 HTTP 方法与 URL。适合无法部署证书的 SPA/移动端。
POST /token
DPoP: <proof-jwt> ← 换令牌时证明持有私钥
→ 返回 token_type=DPoP 的 access_token
GET /resource
Authorization: DPoP <access_token>
DPoP: <新的 proof-jwt> ← 每个请求都带,且绑定到该 URL
DPoP 让被窃取的 access token 在攻击者手里形同废纸,因为攻击者拿不到私钥。
5.4 JWKS 轮换
授权服务器轮换签名密钥时,必须在新密钥启用后保留旧公钥一段时间(至少等于 access token 最大有效期 + 时钟偏移),否则已签发但未过期的 JWT 会全部校验失败。JWKS 里用 kid 区分,客户端按 kid 选取,遇到未知 kid 时刷新 JWKS。
六、常见漏洞与防御
| 漏洞 | 成因 | 防御 |
|---|---|---|
| 开放重定向 + 令牌窃取 | redirect_uri 校验宽松 | 精确匹配,拒绝 ../、?、大小写变体 |
| CSRF(授权请求伪造) | 未校验 state | 强制 state,服务端绑定会话 |
| Mix-up 攻击 | 多授权服务器下客户端发错端点 | 用 iss 校验,绑定 issuer 与端点 |
| 算法混淆 | 接受 HS256 + 公钥当密钥 | 算法白名单,非对称算法优先 |
| 令牌泄露(Referer/日志) | token 出现在 URL | 用授权码模式,token 只在 body/header |
| 重放 | refresh token 未轮换 | 轮换 + 重放检测 + 全链吊销 |
一个高频的组合攻击是:攻击者控制一个已注册客户端的子域,诱导用户授权后通过宽松的 redirect_uri 前缀匹配把 code 引到自己域名。防御只有一条——精确匹配,不搞任何模糊规则。这类"信任边界被绕开"的问题在 API 安全设计
里同样反复出现。
七、落地清单
- 所有交互式流程走授权码 + PKCE(S256),禁用隐式与密码模式。
redirect_uri精确字符串匹配,多回调逐一注册。- 校验 ID Token 的
iss/aud/exp/nonce,算法白名单不含none与HS256。 - access token 短时效、优先不透明;refresh token 轮换 + 重放全链吊销。
- 对高价值场景启用 DPoP 或 mTLS 发送者约束。
- JWKS 轮换保留旧公钥,客户端按
kid缓存刷新。 - 与 身份认证与会话安全 的会话管理、无密码认证 WebAuthn 的凭证体系协同设计,形成完整的身份链路。
OAuth 2.1 与 OIDC 的复杂度不在协议本身,而在实现细节的"最后一公里"。把 PKCE、精确重定向、令牌轮换、发送者约束这四件事做扎实,就能挡掉绝大多数历史 CVE。更完整的 JWT 攻防细节可参考 OAuth2 与 JWT 安全实践 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。