一、前言

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_urihttps://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 攻击流程

  1. 用户已经在 auth.example.com 登录
  2. 攻击者构造一个授权 URL,指向自己的恶意回调地址
  3. 攻击者诱使受害者点击该 URL 或嵌入 iframe
  4. 受害者在不知情的情况下完成授权,授权码被发送给攻击者
  5. 攻击者用授权码换取 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_hashc_hash 字段

十五、总结

OAuth 2.0 的安全实现没有捷径。框架本身设计得很灵活,但灵活性意味着更多的实现细节需要开发者自行把控。最常见的问题集中在:

  1. 过度信任客户端传入的参数(redirect_uri、scope 等)
  2. 省略了安全增强功能(state、PKCE、nonce)
  3. 令牌生命周期管理不当(过长有效期、无吊销、无轮换)
  4. 误用已废弃的授权流程(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