OAuth PKCE 扩展 #

概述 #

PKCE(Proof Key for Code Exchange,发音为 “pixie”)是 OAuth 2.0 的安全扩展,用于防止授权码劫持攻击。它最初为移动应用设计,现在已成为所有公共客户端(包括单页应用)的标准安全措施。

text
┌─────────────────────────────────────────────────────────────┐
│                    PKCE 重要性                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  PKCE 解决的问题:                                          │
│  ❌ 授权码被恶意应用截获                                    │
│  ❌ 恶意应用使用授权码获取令牌                              │
│  ❌ 用户数据被窃取                                          │
│                                                             │
│  PKCE 的解决方案:                                          │
│  ✅ 客户端生成随机验证码                                    │
│  ✅ 授权请求携带挑战码                                      │
│  ✅ 令牌请求验证验证码                                      │
│  ✅ 只有原始客户端能获取令牌                                │
│                                                             │
│  适用场景:                                                 │
│  - 单页应用(SPA)                                          │
│  - 原生移动应用                                             │
│  - 桌面应用                                                 │
│  - 任何无法安全存储 client_secret 的应用                    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

授权码劫持攻击 #

攻击原理 #

text
┌─────────────────────────────────────────────────────────────┐
│                  授权码劫持攻击                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  攻击场景(无 PKCE):                                      │
│                                                             │
│  ┌─────────┐     ┌─────────┐     ┌─────────┐               │
│  │ 合法应用 │     │恶意应用 │     │ 授权服务│               │
│  │         │     │         │     │         │               │
│  └────┬────┘     └────┬────┘     └────┬────┘               │
│       │               │               │                     │
│       │ 1. 请求授权   │               │                     │
│       │──────────────────────────────>│                     │
│       │               │               │                     │
│       │ 2. 用户授权   │               │                     │
│       │<──────────────────────────────│                     │
│       │               │               │                     │
│       │ 3. 授权码返回 │               │                     │
│       │<──────────────────────────────│                     │
│       │               │               │                     │
│       │               │ 4. 截获授权码 │                     │
│       │               │──────────────>│                     │
│       │               │               │                     │
│       │               │ 5. 获取令牌   │                     │
│       │               │<──────────────│                     │
│       │               │               │                     │
│       │               │ 6. 访问用户数据                    │
│       │               │               │                     │
└─────────────────────────────────────────────────────────────┘

攻击方式 #

text
┌─────────────────────────────────────────────────────────────┐
│                    攻击方式                                  │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 恶意应用注册相同的 URL Scheme                           │
│     - 移动应用可以注册自定义 URL Scheme                     │
│     - 恶意应用注册相同的 Scheme 拦截回调                    │
│                                                             │
│  2. 浏览器漏洞                                              │
│     - 某些浏览器可能泄露 URL                                │
│     - 浏览器历史记录                                        │
│                                                             │
│  3. 系统日志                                                │
│     - 操作系统可能记录 URL                                  │
│     - 恶意软件读取日志                                      │
│                                                             │
│  4. 网络监听                                                │
│     - 不安全的网络环境                                      │
│     - 中间人攻击                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

PKCE 工作原理 #

核心概念 #

text
┌─────────────────────────────────────────────────────────────┐
│                    PKCE 核心概念                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  code_verifier(验证码):                                  │
│  - 客户端生成的随机字符串                                   │
│  - 长度 43-128 个字符                                       │
│  - 包含字母、数字、-、.、_、~                               │
│  - 每次授权请求生成新的                                     │
│                                                             │
│  code_challenge(挑战码):                                 │
│  - 由 code_verifier 派生                                    │
│  - 使用 SHA-256 哈希后 Base64URL 编码                       │
│  - 在授权请求中发送                                         │
│                                                             │
│  验证过程:                                                 │
│  1. 客户端生成 code_verifier                                │
│  2. 计算 code_challenge                                     │
│  3. 授权请求携带 code_challenge                             │
│  4. 令牌请求携带 code_verifier                              │
│  5. 授权服务器验证两者匹配                                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

流程图 #

text
┌─────────────────────────────────────────────────────────────┐
│                    PKCE 流程                                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────┐                        ┌─────────┐            │
│  │  客户端  │                        │ 授权服务│            │
│  │ Client  │                        │ Server  │            │
│  └────┬────┘                        └────┬────┘            │
│       │                                  │                  │
│       │ 1. 生成 code_verifier            │                  │
│       │    和 code_challenge             │                  │
│       │                                  │                  │
│       │ 2. 授权请求                      │                  │
│       │    + code_challenge              │                  │
│       │─────────────────────────────────>│                  │
│       │                                  │                  │
│       │                                  │ 存储              │
│       │                                  │ code_challenge    │
│       │                                  │                  │
│       │ 3. 返回授权码                    │                  │
│       │<─────────────────────────────────│                  │
│       │                                  │                  │
│       │ 4. 令牌请求                      │                  │
│       │    + code_verifier               │                  │
│       │─────────────────────────────────>│                  │
│       │                                  │                  │
│       │                                  │ 验证:           │
│       │                                  │ SHA256(verifier) │
│       │                                  │ == challenge?    │
│       │                                  │                  │
│       │ 5. 返回令牌                      │                  │
│       │<─────────────────────────────────│                  │
│       │                                  │                  │
└─────────────────────────────────────────────────────────────┘

为什么 PKCE 有效? #

text
┌─────────────────────────────────────────────────────────────┐
│                  PKCE 防护原理                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  攻击者截获授权码后:                                       │
│                                                             │
│  1. 攻击者尝试获取令牌                                      │
│     POST /token                                            │
│     code=AUTHORIZATION_CODE                                │
│     (没有 code_verifier)                                   │
│                                                             │
│  2. 授权服务器拒绝                                          │
│     - 缺少 code_verifier                                   │
│     - 或 code_verifier 不匹配                              │
│                                                             │
│  3. 攻击者无法伪造                                          │
│     - code_verifier 从未在网络传输                          │
│     - 攻击者无法从 code_challenge 逆推                      │
│     - SHA-256 是单向哈希                                   │
│                                                             │
│  结果:攻击者虽然截获了授权码,但无法获取令牌               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

PKCE 实现 #

生成 code_verifier #

javascript
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

function base64UrlEncode(buffer) {
  const bytes = new Uint8Array(buffer);
  let str = '';
  bytes.forEach(b => str += String.fromCharCode(b));
  return btoa(str)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

const codeVerifier = generateCodeVerifier();
console.log(codeVerifier);

生成 code_challenge #

javascript
async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(hash);
}

const codeChallenge = await generateCodeChallenge(codeVerifier);
console.log(codeChallenge);

完整实现示例 #

javascript
class PKCEClient {
  constructor(config) {
    this.config = config;
    this.codeVerifier = null;
    this.codeChallenge = null;
  }

  generateCodeVerifier() {
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    return this.base64UrlEncode(array);
  }

  async generateCodeChallenge(verifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const hash = await crypto.subtle.digest('SHA-256', data);
    return this.base64UrlEncode(hash);
  }

  base64UrlEncode(buffer) {
    const bytes = new Uint8Array(buffer);
    let str = '';
    bytes.forEach(b => str += String.fromCharCode(b));
    return btoa(str)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }

  generateState() {
    const array = new Uint8Array(16);
    crypto.getRandomValues(array);
    return this.base64UrlEncode(array);
  }

  async initiateLogin() {
    this.codeVerifier = this.generateCodeVerifier();
    this.codeChallenge = await this.generateCodeChallenge(this.codeVerifier);
    const state = this.generateState();

    sessionStorage.setItem('pkce_code_verifier', this.codeVerifier);
    sessionStorage.setItem('oauth_state', state);

    const params = new URLSearchParams({
      response_type: 'code',
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      scope: this.config.scope,
      state: state,
      code_challenge: this.codeChallenge,
      code_challenge_method: 'S256'
    });

    window.location.href = `${this.config.authorizationEndpoint}?${params}`;
  }

  async handleCallback() {
    const params = new URLSearchParams(window.location.search);
    const code = params.get('code');
    const state = params.get('state');

    const storedState = sessionStorage.getItem('oauth_state');
    if (state !== storedState) {
      throw new Error('State mismatch');
    }

    const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
    if (!codeVerifier) {
      throw new Error('No code verifier found');
    }

    sessionStorage.removeItem('pkce_code_verifier');
    sessionStorage.removeItem('oauth_state');

    const tokenResponse = await fetch(this.config.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code,
        redirect_uri: this.config.redirectUri,
        client_id: this.config.clientId,
        code_verifier: codeVerifier
      })
    });

    if (!tokenResponse.ok) {
      throw new Error('Token request failed');
    }

    return await tokenResponse.json();
  }
}

const client = new PKCEClient({
  authorizationEndpoint: 'https://auth.example.com/authorize',
  tokenEndpoint: 'https://auth.example.com/token',
  clientId: 'your-client-id',
  redirectUri: 'https://your-app.example.com/callback',
  scope: 'profile email'
});

document.getElementById('login').addEventListener('click', () => {
  client.initiateLogin();
});

if (window.location.pathname === '/callback') {
  client.handleCallback().then(tokens => {
    console.log('Access token:', tokens.access_token);
  });
}

Node.js 服务端实现 #

javascript
const crypto = require('crypto');

function generateCodeVerifier() {
  return crypto.randomBytes(32).toString('base64url');
}

function generateCodeChallenge(verifier) {
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
}

async function initiateAuth(req, res) {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = generateCodeChallenge(codeVerifier);
  const state = crypto.randomBytes(16).toString('hex');

  req.session.pkceCodeVerifier = codeVerifier;
  req.session.oauthState = state;

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.CLIENT_ID,
    redirect_uri: process.env.REDIRECT_URI,
    scope: 'profile email',
    state: state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256'
  });

  res.redirect(`${process.env.AUTH_ENDPOINT}?${params}`);
}

async function handleCallback(req, res) {
  const { code, state } = req.query;

  if (state !== req.session.oauthState) {
    return res.status(400).send('Invalid state');
  }

  const codeVerifier = req.session.pkceCodeVerifier;

  const tokenResponse = await fetch(process.env.TOKEN_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: process.env.REDIRECT_URI,
      client_id: process.env.CLIENT_ID,
      code_verifier: codeVerifier
    })
  });

  const tokens = await tokenResponse.json();
  
  req.session.accessToken = tokens.access_token;
  res.redirect('/dashboard');
}

Python 实现 #

python
import secrets
import hashlib
import base64
import requests

def generate_code_verifier():
    return secrets.token_urlsafe(32)

def generate_code_challenge(verifier):
    data = verifier.encode('utf-8')
    hash_obj = hashlib.sha256(data)
    return base64.urlsafe_b64encode(hash_obj.digest()).decode('utf-8').rstrip('=')

def initiate_auth(session):
    code_verifier = generate_code_verifier()
    code_challenge = generate_code_challenge(code_verifier)
    state = secrets.token_hex(16)
    
    session['pkce_code_verifier'] = code_verifier
    session['oauth_state'] = state
    
    params = {
        'response_type': 'code',
        'client_id': CLIENT_ID,
        'redirect_uri': REDIRECT_URI,
        'scope': 'profile email',
        'state': state,
        'code_challenge': code_challenge,
        'code_challenge_method': 'S256'
    }
    
    auth_url = f"{AUTH_ENDPOINT}?{urlencode(params)}"
    return redirect(auth_url)

def handle_callback(session, code, state):
    if state != session.get('oauth_state'):
        raise ValueError('Invalid state')
    
    code_verifier = session.get('pkce_code_verifier')
    
    response = requests.post(TOKEN_ENDPOINT, data={
        'grant_type': 'authorization_code',
        'code': code,
        'redirect_uri': REDIRECT_URI,
        'client_id': CLIENT_ID,
        'code_verifier': code_verifier
    })
    
    return response.json()

PKCE 参数详解 #

code_verifier 规范 #

text
┌─────────────────────────────────────────────────────────────┐
│                  code_verifier 规范                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  长度:43-128 个字符                                        │
│                                                             │
│  允许字符:                                                 │
│  - 字母:A-Z, a-z                                          │
│  - 数字:0-9                                               │
│  - 特殊字符:- . _ ~                                        │
│                                                             │
│  正则表达式:                                               │
│  /^[A-Za-z0-9-._~]{43,128}$/                               │
│                                                             │
│  生成方法:                                                 │
│  1. 生成 32 字节随机数                                      │
│  2. Base64URL 编码                                          │
│                                                             │
│  示例:                                                     │
│  dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

code_challenge_method #

text
┌─────────────────────────────────────────────────────────────┐
│              code_challenge_method 选项                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  S256(推荐):                                             │
│  code_challenge = BASE64URL(SHA256(code_verifier))          │
│                                                             │
│  plain(不推荐):                                          │
│  code_challenge = code_verifier                             │
│                                                             │
│  为什么 S256 更安全?                                       │
│  - code_verifier 从不在网络传输                             │
│  - 即使 code_challenge 泄露也无法逆推                       │
│  - SHA-256 是单向哈希函数                                   │
│                                                             │
│  plain 方法的问题:                                         │
│  - code_challenge 就是 code_verifier                        │
│  - 如果 code_challenge 泄露,安全性完全丧失                 │
│  - 仅用于不支持 SHA-256 的极端情况                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

PKCE 与不同授权模式 #

PKCE + 授权码模式 #

text
┌─────────────────────────────────────────────────────────────┐
│                PKCE + 授权码模式                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  适用场景:                                                 │
│  - 单页应用(SPA)                                          │
│  - 原生移动应用                                             │
│  - 桌面应用                                                 │
│  - 任何公共客户端                                           │
│                                                             │
│  请求示例:                                                 │
│  GET /authorize?                                            │
│    response_type=code&                                      │
│    client_id=CLIENT_ID&                                     │
│    redirect_uri=REDIRECT_URI&                               │
│    scope=profile%20email&                                   │
│    state=STATE&                                             │
│    code_challenge=CHALLENGE&                                │
│    code_challenge_method=S256                               │
│                                                             │
│  令牌请求:                                                 │
│  POST /token                                                │
│    grant_type=authorization_code&                           │
│    code=AUTH_CODE&                                          │
│    redirect_uri=REDIRECT_URI&                               │
│    client_id=CLIENT_ID&                                     │
│    code_verifier=VERIFIER                                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

PKCE 是否必需? #

text
┌─────────────────────────────────────────────────────────────┐
│                    PKCE 使用建议                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  必须使用 PKCE:                                            │
│  ✅ 单页应用(SPA)                                         │
│  ✅ 原生移动应用                                            │
│  ✅ 桌面应用                                                │
│  ✅ 任何公共客户端                                          │
│                                                             │
│  建议使用 PKCE:                                            │
│  ✅ 所有授权码模式请求                                      │
│  ✅ OAuth 2.1 要求                                          │
│                                                             │
│  可以不使用 PKCE:                                          │
│  ⚠️ 机密客户端(有 client_secret)                          │
│     - 但 OAuth 2.1 建议也使用                               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

安全考虑 #

PKCE 的安全性 #

text
┌─────────────────────────────────────────────────────────────┐
│                    PKCE 安全分析                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  安全保证:                                                 │
│                                                             │
│  1. 防止授权码劫持                                          │
│     - 攻击者截获授权码后无法使用                            │
│     - 没有 code_verifier 无法获取令牌                       │
│                                                             │
│  2. code_verifier 不传输                                    │
│     - 只在令牌请求中使用                                    │
│     - 不出现在浏览器历史                                    │
│     - 不出现在服务器日志                                    │
│                                                             │
│  3. 单向哈希                                                │
│     - SHA-256 无法逆推                                      │
│     - code_challenge 泄露不影响安全                         │
│                                                             │
│  局限性:                                                   │
│  ⚠️ 不防止钓鱼攻击                                          │
│  ⚠️ 不防止 XSS 攻击                                         │
│  ⚠️ 需要正确实现                                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

最佳实践 #

text
1. 始终使用 S256 方法
   - 不要使用 plain 方法
   - 除非客户端不支持 SHA-256

2. 每次授权生成新的 code_verifier
   - 不要重用
   - 使用加密安全的随机数生成器

3. 安全存储 code_verifier
   - 使用 sessionStorage(SPA)
   - 使用 Keychain/Keystore(移动应用)

4. 验证 state 参数
   - PKCE 不能替代 state 参数
   - 两者应该同时使用

5. 使用 HTTPS
   - PKCE 不能替代 HTTPS
   - 所有通信必须加密

常见问题 #

问题一:授权服务器不支持 PKCE? #

text
解决方案:

1. 检查授权服务器文档
   - 确认是否支持 PKCE
   - 查看配置方法

2. 使用后端代理
   - 后端完成授权码交换
   - 前端通过安全会话获取令牌

3. 更换授权服务器
   - 选择支持 PKCE 的服务
   - PKCE 已是行业标准

问题二:code_verifier 存储在哪里? #

text
存储位置建议:

单页应用(SPA):
✅ sessionStorage(推荐)
✅ 内存变量
❌ localStorage(XSS 风险)

移动应用:
✅ Keychain(iOS)
✅ Keystore(Android)
❌ 本地文件

桌面应用:
✅ 操作系统密钥存储
✅ 内存变量
❌ 配置文件

问题三:PKCE 与 client_secret 的关系? #

text
┌─────────────────────────────────────────────────────────────┐
│              PKCE vs client_secret                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  公共客户端(无 client_secret):                           │
│  - 必须使用 PKCE                                            │
│  - PKCE 提供客户端认证                                      │
│                                                             │
│  机密客户端(有 client_secret):                           │
│  - 建议同时使用 PKCE + client_secret                        │
│  - 双重保护                                                 │
│                                                             │
│  OAuth 2.1 要求:                                           │
│  - 所有授权码请求都必须使用 PKCE                            │
│  - 无论是否有 client_secret                                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘

PKCE 与 OAuth 2.1 #

text
┌─────────────────────────────────────────────────────────────┐
│                 OAuth 2.1 中的 PKCE                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  OAuth 2.1 强制要求:                                       │
│                                                             │
│  1. 所有授权码模式必须使用 PKCE                             │
│     - 包括机密客户端                                        │
│     - 包括服务器端应用                                      │
│                                                             │
│  2. 必须使用 S256 方法                                      │
│     - plain 方法被禁止                                      │
│                                                             │
│  3. 简化模式被移除                                          │
│     - 使用授权码 + PKCE 替代                                │
│                                                             │
│  4. 密码模式被移除                                          │
│     - 使用授权码 + PKCE 替代                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

下一步 #

现在你已经掌握了 PKCE 扩展,接下来学习 JWT 与 OAuth,了解 JSON Web Token 在 OAuth 中的应用!

最后更新:2026-03-28