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