一、前言
OAuth 2.0(RFC 6749)是目前最广泛使用的第三方授权框架,但其安全高度依赖于实现细节。许多开发者误以为"用了 OAuth 就是安全的",实际上每年都有大量基于 OAuth 的安全事件被披露。本文将从协议层面出发,逐一剖析 OAuth 2.0 的典型漏洞。
二、OAuth 2.0 核心流程回顾
在深入漏洞之前,我们先快速回顾标准的授权码模式(Authorization Code Grant),这是最完整、也是漏洞最集中的流程:
┌──────┐ ┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ User │────▶│ Client │────▶│ Auth Server │────▶│ Resource │
└──────┘ └──────────┘ └──────────────┘ └──────────────┘
│ │ │ │
│ 1.访问应用 │ │ │
│─────────────▶│ │ │
│ │ 2.重定向到授权页 │ │
│◀─────────────│────────────────▶│ │
│ 3.用户授权 │ │ │
│────────────────────────────────▶│ │
│ │ 4.返回code │ │
│◀─────────────│◀────────────────│ │
│ │ 5.code换token │ │
│ │────────────────▶│ │
│ │ 6.返回access_token│ │
│ │◀────────────────│ │
│ │ 7.用token访问资源 │ │
│ │──────────────────────────────────────▶│
│ │ 8.返回受保护资源 │ │
│ │◀──────────────────────────────────────│
三、漏洞一:Redirect URI 白名单绕过
3.1 漏洞描述
授权服务器未严格校验 redirect_uri 参数,攻击者可以构造恶意 URL 窃取授权码。
3.2 脆弱实现示例(Node.js + Express)
const express = require('express');
const querystring = require('querystring');
const app = express();
const CLIENT_ID = 'demo_client';
const REDIRECT_URIS = ['https://app.example.com/callback'];
app.get('/authorize', (req, res) => {
const { response_type, client_id, redirect_uri, state } = req.query;
if (client_id !== CLIENT_ID) {
return res.status(400).send('unknown client');
}
if (response_type !== 'code') {
return res.status(400).send('unsupported grant');
}
// 🔴 漏洞:仅做前缀匹配
const isAllowed = REDIRECT_URIS.some((uri) =>
redirect_uri.startsWith(uri.replace('/callback', ''))
);
if (!isAllowed) {
return res.status(400).send('invalid redirect_uri');
}
// 生成并发送授权码
const code = generateAuthorizationCode(client_id, redirect_uri, state);
const qs = querystring.stringify({ code, state });
return res.redirect(`${redirect_uri}?${qs}`);
});
3.3 攻击 Payload
攻击者可以构造如下 URL 绕过校验:
https://auth.example.com/authorize?
response_type=code
&client_id=demo_client
&redirect_uri=https://app.example.com.evil.com/callback
&state=xyz
因为 redirect_uri 以 https://app.example.com 开头,脆弱的前缀匹配会将其判定为合法,而实际上域名已经被替换为攻击者控制的 evil.com。
3.4 正确的校验方式
function validateRedirectUri(clientRedirectUris, redirectUri) {
const target = new URL(redirectUri);
return clientRedirectUris.some((allowed) => {
const allowedUrl = new URL(allowed);
// 必须完全匹配协议、主机、端口、路径
if (target.protocol !== allowedUrl.protocol) return false;
if (target.hostname !== allowedUrl.hostname) return false;
if (target.port !== allowedUrl.port) return false;
if (!target.pathname.startsWith(allowedUrl.pathname)) return false;
// 禁止开放重定向:redirect_uri 中不允许含有 fragment
if (redirectUri.includes('#')) return false;
// 禁止利用 query 绕过
if (target.search) return false;
return true;
});
}
四、漏洞二:State 参数缺失或校验不严
4.1 漏洞描述
CSRF(跨站请求伪造)是 OAuth 2.0 最经典的攻击之一。RFC 6749 强烈建议在授权请求中使用 state 参数,但许多客户端实现并未遵守。
4.2 攻击流程
- 用户已经在 auth.example.com 登录
- 攻击者构造一个授权 URL,指向自己的恶意回调地址
- 攻击者诱使受害者点击该 URL 或嵌入 iframe
- 受害者在不知情的情况下完成授权,授权码被发送给攻击者
- 攻击者用授权码换取 access_token,以受害者身份登录
4.3 脆弱客户端实现
app.get('/login', (req, res) => {
const params = querystring.stringify({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: 'https://app.example.com/callback',
// 🔴 缺失 state 参数
});
res.redirect(`https://auth.example.com/authorize?${params}`);
});
app.get('/callback', async (req, res) => {
const { code } = req.query;
// 🔴 没有校验 state,也没有将 code 绑定到当前用户会话
const token = await exchangeCode(code);
req.session.access_token = token.access_token;
res.redirect('/dashboard');
});
### 4.4 安全实现
```javascript
const crypto = require('crypto');
app.get('/login', (req, res) => {
const state = crypto.randomBytes(16).toString('hex');
// 将 state 存入 session,以便后续校验
req.session.oauth_state = state;
const params = querystring.stringify({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: 'https://app.example.com/callback',
state,
});
res.redirect(`https://auth.example.com/authorize?${params}`);
});
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// 校验 state 防止 CSRF
if (!state || state !== req.session.oauth_state) {
return res.status(403).send('state mismatch, possible CSRF');
}
// 清除已使用的 state
delete req.session.oauth_state;
const token = await exchangeCode(code);
req.session.access_token = token.access_token;
res.redirect('/dashboard');
});
五、漏洞三:授权码泄露到浏览器历史记录
5.1 漏洞描述
授权码应仅能使用一次,且有效期极短。但如果授权码被传递到 URL 参数中,它会被记录在:
- 浏览器历史记录
- 服务器访问日志(Nginx/APACHE access.log)
- 代理服务器日志
- Referer 头(当页面包含外链图片时)
5.2 真实案例
2022 年某大型社交平台因授权码泄露到 Referer 头,导致超过 50,000 个有效授权码被攻击者收集利用。
5.3 防护措施
在服务器端完成 code 换 token 的交换流程,而不是在前端(SPA)直接处理:
// ❌ 错误:前端直接用 code 换 token,token 会暴露在前端
// 现代浏览器的 SPA 推荐使用 Authorization Code Flow with PKCE
// ✅ 正确:后端服务端交换
async function exchangeCode(code, redirectUri) {
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: querystring.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const data = await response.json();
return data;
}
六、漏洞四:令牌重放与过期管理不当
6.1 漏洞描述
攻击者获取 access_token 后,若令牌有效期过长或不校验过期时间,可长期冒充受害者。
6.2 漏洞代码
from flask import request, jsonify
from jose import jwt
app = Flask(__name__)
SECRET_KEY = "supersecret"
@app.route('/api/resource')
def resource():
auth_header = request.headers.get('Authorization', '')
token = auth_header.replace('Bearer ', '')
# 🔴 没有校验 exp(过期时间)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
except:
return jsonify({'error': 'invalid token'}), 401
return jsonify({'user_id': payload['sub'], 'data': 'secret'})
6.3 安全实现
from jose import jwt, JWTError, ExpiredSignatureError
from datetime import datetime, timezone
def verify_access_token(token: str, secret: str) -> dict:
try:
payload = jwt.decode(
token,
secret,
algorithms=['HS256'],
options={
'verify_exp': True, # 校验过期
'verify_iat': True, # 校验签发时间
'verify_aud': True, # 校验受众
'verify_iss': True, # 校验签发者
'exp_leeway': 10, # 允许 10 秒时钟偏差
},
audience='resource-server',
issuer='auth.example.com',
)
return payload
except ExpiredSignatureError:
raise ValueError('token has expired')
except JWTError as e:
raise ValueError(f'token validation failed: {e}')
6.4 令牌轮换策略
使用刷新令牌(Refresh Token)+ 令牌轮换(Token Rotation)机制:
# 存储在服务端的刷新令牌表
refresh_tokens = {} # {jti: {user_id, expires_at, revoked}}
def issue_refresh_token(user_id: str) -> tuple[str, str]:
jti = uuid.uuid4().hex
refresh_token = jwt.encode(
{
'sub': user_id,
'jti': jti,
'typ': 'refresh',
'exp': datetime.now(timezone.utc) + timedelta(days=30),
},
REFRESH_SECRET,
algorithm='HS256',
)
refresh_tokens[jti] = {
'user_id': user_id,
'expires_at': datetime.now(timezone.utc) + timedelta(days=30),
'revoked': False,
}
return refresh_token, jti
def rotate_refresh_token(old_jti: str) -> tuple[str, str]:
if old_jti in refresh_tokens:
refresh_tokens[old_jti]['revoked'] = True
return issue_refresh_token(refresh_tokens[old_jti]['user_id'])
七、漏洞五:开放重定向结合 OAuth
7.1 漏洞描述
如果应用本身存在开放重定向漏洞,攻击者可以将其作为跳板来绕过 OAuth 的 redirect_uri 白名单限制。
7.2 攻击链路
1. 攻击者找到受害者应用的开放重定向:
https://app.example.com/redirect?url=
2. 将其嵌入 OAuth 授权流程:
https://auth.example.com/authorize
?response_type=code
&client_id=app_client
&redirect_uri=https://app.example.com/redirect?url=https://evil.com/callback
&state=xyz
3. 授权服务器认为 redirect_uri 合法,发送授权码到 app.example.com
4. app.example.com 收到授权码后重定向到 evil.com,授权码泄露
7.3 防护
- 彻底修复开放重定向漏洞
- 在 OAuth 回调路径上严格禁止任何二次重定向
- 使用完整 URL 匹配而非模糊匹配
八、漏洞六:PKCE 未启用
8.1 漏洞描述
Proof Key for Code Exchange(PKCE,RFC 7636)是为公共客户端(SPA、移动应用)设计的安全扩展。没有 PKCE,授权码容易被拦截后交换。
8.2 启用 PKCE 的流程
// 客户端生成 code_verifier 和 code_challenge
function generatePKCE() {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
return { codeVerifier, codeChallenge };
}
// 发起授权请求时带上 code_challenge
const { codeVerifier, codeChallenge } = generatePKCE();
sessionStorage.setItem('pkce_verifier', codeVerifier);
const params = querystring.stringify({
response_type: 'code',
client_id: PUBLIC_CLIENT_ID,
redirect_uri: 'https://app.example.com/callback',
code_challenge_method: 'S256',
code_challenge: codeChallenge,
});
// 换 token 时带上 code_verifier
app.post('/api/token', async (req, res) => {
const { code } = req.query;
const codeVerifier = sessionStorage.getItem('pkce_verifier');
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: querystring.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.example.com/callback',
client_id: PUBLIC_CLIENT_ID,
code_verifier: codeVerifier,
}),
});
sessionStorage.removeItem('pkce_verifier');
res.json(await response.json());
});
8.3 服务端校验
import hashlib
import base64
def verify_code_challenge(code_verifier: str, code_challenge: str, method: str) -> bool:
if method == 'S256':
digest = hashlib.sha256(code_verifier.encode()).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b'=').decode()
return computed == code_challenge
elif method == 'plain':
# plain 方法安全性较差,生产环境建议禁用
return code_verifier == code_challenge
return False
九、漏洞七:隐式流程(Implicit Flow)的安全问题
9.1 问题描述
隐式流程将 access_token 直接作为 URL fragment 返回给浏览器,存在多种泄露风险。RFC 9700 已将其废弃。
9.2 受攻击场景
- 攻击者可以通过监听 fragment(
location.hash)窃取 token - Referer 泄露
- 浏览器历史记录泄露
- 无法使用 PKCE 保护
9.3 迁移建议
立即迁移到 Authorization Code Flow + PKCE,即使是纯前端应用:
// 现代安全做法:Auth Code + PKCE
async function oauthLogin() {
const { codeVerifier, codeChallenge } = generatePKCE();
sessionStorage.setItem('pkce_verifier', codeVerifier);
const params = new URLSearchParams({
response_type: 'code',
client_id: 'your_spa_client',
redirect_uri: window.location.origin + '/callback',
scope: 'openid profile email',
state: crypto.randomUUID(),
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
window.location.href = `https://auth.example.com/authorize?${params}`;
}
十、漏洞八:客户端凭据泄露
10.1 漏洞描述
机密客户端(Confidential Client)的 client_secret 不应暴露在公共环境中。
10.2 常见错误
- 将 client_secret 硬编码在前端 JavaScript 中
- 提交到公开的 GitHub 仓库
- 打印到日志中
- 通过 HTTP 明文传输
10.3 安全管理
// ✅ 正确:使用环境变量
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
// ❌ 错误:硬编码
const clientSecret = 'sk_live_abcdef123456';
使用 Secret Manager 或 Vault 服务:
// AWS Secrets Manager 示例
const AWS = require('aws-sdk');
const secrets = new AWS.SecretsManager();
async function getOAuthCredentials() {
const response = await secrets.getSecretValue({
SecretId: 'oauth/client-credentials',
}).promise();
return JSON.parse(response.SecretString);
}
十一、漏洞九:Scope 参数处理不当
11.1 漏洞描述
攻击者可能通过请求过宽的 scope 或修改已授权 scope 来提权。
11.2 脆弱实现
// 🔴 客户端完全信任请求的 scope
app.get('/authorize', (req, res) => {
const requestedScope = req.query.scope || 'read';
// 直接使用客户端请求的 scope,没有限制
const token = issueToken({ scope: requestedScope });
});
11.3 安全实现
const ALLOWED_SCOPES = {
client_a: ['read', 'write'],
client_b: ['read'],
};
app.get('/authorize', (req, res) => {
const clientId = req.query.client_id;
const requestedScopes = (req.query.scope || '').split(' ');
const allowedScopes = ALLOWED_SCOPES[clientId] || [];
// 取交集:只允许客户端已被授权的 scope
const finalScopes = requestedScopes.filter((s) => allowedScopes.includes(s));
if (finalScopes.length === 0) {
return res.status(400).send('no valid scope requested');
}
// 展示给用户的授权页面应明确显示请求的权限
res.render('consent', { clientId, scopes: finalScopes });
});
十二、漏洞十:Token Introspection 未使用
12.1 漏洞描述
资源服务器(Resource Server)如果只校验 JWT 签名而不做内省(Introspection),就无法检测已被吊销的令牌。
12.2 完整的令牌校验链
import httpx
from functools import wraps
async def introspect_token(token: str) -> dict | None:
response = await httpx.post(
'https://auth.example.com/introspect',
data={'token': token, 'client_id': 'rs_client', 'client_secret': 'rs_secret'},
)
data = response.json()
if data.get('active'):
return data
return None
async def validate_access_token(token: str) -> dict:
# 步骤1: 基础格式校验
if not token.count('.') == 2:
raise ValueError('malformed token')
# 步骤2: 签名校验
try:
payload = jwt.decode(token, PUBLIC_KEY, algorithms=['RS256'])
except JWTError:
raise ValueError('invalid signature')
# 步骤3: 令牌内省(检测吊销)
introspection = await introspect_token(token)
if introspection is None:
raise ValueError('token is revoked or invalid')
# 步骤4: 必要字段校验
required = ['sub', 'exp', 'scope', 'aud']
for field in required:
if field not in payload:
raise ValueError(f'missing required field: {field}')
# 步骤5: 受众校验
if 'resource-server' not in payload.get('aud', []):
raise ValueError('token not intended for this resource server')
return payload
十三、OAuth 2.1 标准的改进
OAuth 2.1(正在制定中)已经吸收了十多年的实战经验:
| 改进项 | 说明 |
|---|---|
| 强制使用 PKCE | 所有客户端类型均要求 |
| 废弃隐式流程 | 完全移除 Implicit Flow |
| 缩短 access_token 有效期 | 建议 5-15 分钟 |
| 刷新令牌轮换 | 每次使用刷新令牌后签发新令牌 |
| 强制端点认证 | token 端点必须支持客户端认证 |
| DPoP 绑定 | 可选的证明令牌(Proof-of-Possession)绑定 |
十四、审计清单
在对 OAuth 实现进行安全审计时,按以下清单逐一检查:
- redirect_uri 是否做了精确的全字符串匹配
- 是否使用并校验 state 参数
- 公共客户端是否启用了 PKCE
- 是否禁止了 Implicit Flow 和 Password Flow
- access_token 有效期是否不超过 15 分钟
- 是否实现了刷新令牌轮换和吊销
- scope 是否做了白名单限制
- 是否使用 Token Introspection 或 JWT 校验吊销状态
- client_secret 是否安全存储(未暴露到前端或代码仓库)
- 是否启用了 HTTPS 且使用 HSTS
- Authorization 头是否通过 HTTPS 传输(防止中间人)
- CORS 配置是否限制了允许的来源
- 是否实现了对授权端点的暴力破解防护
- 日志中是否过滤掉敏感字段(code、token、refresh_token)
- 是否使用了 Nonce 防止 OIDC 重放攻击
- 是否校验了 OIDC 的
at_hash和c_hash字段
十五、总结
OAuth 2.0 的安全实现没有捷径。框架本身设计得很灵活,但灵活性意味着更多的实现细节需要开发者自行把控。最常见的问题集中在:
- 过度信任客户端传入的参数(redirect_uri、scope 等)
- 省略了安全增强功能(state、PKCE、nonce)
- 令牌生命周期管理不当(过长有效期、无吊销、无轮换)
- 误用已废弃的授权流程(Implicit Flow、Password Flow)
建议所有使用 OAuth 的团队都遵循最新的 OWASP OAuth 2.0 安全指南,并定期进行渗透测试和代码审计。
十六、参考资料
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 7636: Proof Key for Code Exchange by OAuth 2.0 Public Clients
- RFC 9700: OAuth 2.0 for Browser-Based Apps (obsoletes Implicit Flow)
- OAuth 2.0 Security Best Current Practice (BCP 225)
- OWASP OAuth 2.0 Cheat Sheet
- Auth0 "OAuth 2.0 Security Top 10" 白皮书
- CVE-2021-20090 - Apache Knox OAuth URL Redirection
- CVE-2020-15138 - Electron OAuth Token Exposure