PKCE白话详解:为什么手机App登录要用”验证码”?

引言:手机App的安全困境
你有没有想过:为什么手机App登录时,有时候要跳转到浏览器,有时候又要输入验证码?
这背后其实是OAuth2的一个安全问题:授权码可能被拦截。
今天,我用白话讲讲PKCE如何解决这个问题。
第一章:授权码拦截问题

问题场景
正常的OAuth2流程:
1 2 3 4
| App → 跳转到授权页面 → 用户授权 → 授权服务器返回授权码 → App用授权码换Token
|
攻击场景:
1 2 3 4 5 6
| App → 跳转到授权页面 → 用户授权 → 授权服务器返回授权码 → 攻击者拦截授权码 → 攻击者用授权码换Token → 攻击者冒充用户
|
生活中的例子
没有PKCE的世界:
- 你去快递站取快递
- 快递员给你一个取件码
- 你拿着取件码去取快递
- 但取件码可能被旁边的人偷看到
有PKCE的世界:
- 你去快递站取快递
- 你先告诉快递员一个”暗号”
- 快递员给你一个取件码
- 你拿着取件码和”暗号”去取快递
- 即使取件码被偷看,没有”暗号”也取不到
PKCE就像这个”暗号”:即使授权码被拦截,没有验证器也无法换Token。
第二章:PKCE的工作原理
核心概念
PKCE涉及三个关键概念:
- Code Verifier(验证码):随机生成的字符串,只有App知道
- Code Challenge(挑战码):验证码的哈希值,发送给授权服务器
- Code Challenge Method(挑战方法):哈希算法,通常是S256
流程图解
步骤一:生成验证码和挑战码
1 2
| App → 生成随机验证码(code_verifier) → 计算挑战码(code_challenge = SHA256(code_verifier))
|
步骤二:授权请求
1 2 3
| App → 发送授权请求 → 携带挑战码(code_challenge) → 携带挑战方法(code_challenge_method=S256)
|
步骤三:获取授权码
步骤四:交换Token
1 2 3 4 5
| App → 发送Token请求 → 携带授权码 → 携带验证码(code_verifier) → 验证码 → 计算哈希 → 与存储的挑战码对比 → 匹配则返回Token
|
代码示例
生成验证码和挑战码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import hashlib import base64 import secrets
code_verifier = secrets.token_urlsafe(96)
code_challenge = base64.urlsafe_b64encode( hashlib.sha256(code_verifier.encode()).digest() ).decode().rstrip('=')
print(f"Code Verifier: {code_verifier}") print(f"Code Challenge: {code_challenge}")
|
授权请求:
1 2 3 4 5 6
| auth_url = "https://example.com/oauth2/authorize?" + \ "client_id=YOUR_CLIENT_ID&" + \ "redirect_uri=https://your-app.com/callback&" + \ "response_type=code&" + \ "code_challenge=" + code_challenge + "&" + \ "code_challenge_method=S256"
|
Token请求:
1 2 3 4 5 6 7 8 9 10
| token_url = "https://example.com/oauth2/token" data = { 'grant_type': 'authorization_code', 'code': authorization_code, 'client_id': 'YOUR_CLIENT_ID', 'redirect_uri': 'https://your-app.com/callback', 'code_verifier': code_verifier }
response = requests.post(token_url, data=data)
|
第三章:为什么PKCE能防攻击?
攻击者的困境
场景:攻击者拦截了授权码。
攻击者想要换Token,需要:
- 授权码(已拦截)
- 验证码(code_verifier)
问题:验证码只有App知道,攻击者无法获取。
即使攻击者知道挑战码(code_challenge):
- 挑战码是验证码的哈希值
- 哈希是单向的,无法反推验证码
- 攻击者无法伪造验证码
安全性对比
| 场景 |
没有PKCE |
有PKCE |
| 授权码被拦截 |
Token被窃取 |
Token安全 |
| 攻击者伪造请求 |
可能成功 |
无法成功 |
| 需要客户端密钥 |
是 |
否 |
第四章:PKCE的适用场景
场景一:移动App
问题:移动App是公开客户端,无法安全存储客户端密钥。
PKCE解决方案:
场景二:单页应用(SPA)
问题:SPA运行在浏览器中,无法安全存储客户端密钥。
PKCE解决方案:
- 不需要客户端密钥
- 使用验证码和挑战码
- 防止授权码拦截
场景三:原生桌面应用
问题:桌面应用可能被反编译,客户端密钥可能泄露。
PKCE解决方案:
第五章:PKCE的实现
前端实现(JavaScript)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| function generateRandomString(length) { const array = new Uint8Array(length) crypto.getRandomValues(array) return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('') }
async function sha256(plain) { const encoder = new TextEncoder() const data = encoder.encode(plain) const hash = await crypto.subtle.digest('SHA-256', data) return hash }
function base64urlencode(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') }
async function generatePKCE() { const codeVerifier = generateRandomString(64) const hashed = await sha256(codeVerifier) const codeChallenge = base64urlencode(hashed) return { codeVerifier, codeChallenge, codeChallengeMethod: 'S256' } }
const { codeVerifier, codeChallenge, codeChallengeMethod } = await generatePKCE()
sessionStorage.setItem('code_verifier', codeVerifier)
const authUrl = `https://example.com/oauth2/authorize?` + `client_id=YOUR_CLIENT_ID&` + `redirect_uri=${encodeURIComponent(redirectUri)}&` + `response_type=code&` + `code_challenge=${codeChallenge}&` + `code_challenge_method=${codeChallengeMethod}`
window.location.href = authUrl
|
后端实现(Python)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import hashlib import base64 import secrets
class PKCE: @staticmethod def generate_code_verifier(length=128): """生成随机验证码""" return secrets.token_urlsafe(length) @staticmethod def generate_code_challenge(code_verifier): """计算挑战码""" digest = hashlib.sha256(code_verifier.encode()).digest() return base64.urlsafe_b64encode(digest).decode().rstrip('=') @staticmethod def verify_code_verifier(code_verifier, code_challenge): """验证验证码""" calculated_challenge = PKCE.generate_code_challenge(code_verifier) return calculated_challenge == code_challenge
code_verifier = PKCE.generate_code_verifier() code_challenge = PKCE.generate_code_challenge(code_verifier)
print(f"Code Verifier: {code_verifier}") print(f"Code Challenge: {code_challenge}")
|
授权服务器实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| @app.route('/authorize') def authorize(): code_challenge = request.args.get('code_challenge') code_challenge_method = request.args.get('code_challenge_method') if code_challenge_method != 'S256': return error('unsupported_challenge_method') code = generate_authorization_code() store_challenge(code, code_challenge) return redirect(f"{redirect_uri}?code={code}")
@app.route('/token', methods=['POST']) def token(): code = request.form.get('code') code_verifier = request.form.get('code_verifier') stored_challenge = get_challenge(code) if not PKCE.verify_code_verifier(code_verifier, stored_challenge): return error('invalid_grant') access_token = generate_access_token() return jsonify({'access_token': access_token})
|
第六章:PKCE vs 客户端密钥
安全性对比
| 维度 |
客户端密钥 |
PKCE |
| 适用场景 |
机密客户端 |
公开客户端 |
| 密钥存储 |
安全存储 |
不需要 |
| 授权码拦截 |
可能被利用 |
防止 |
| 实现复杂度 |
简单 |
中等 |
选择建议
使用客户端密钥:
使用PKCE:
- 无后端服务器
- 无法安全存储密钥
- 公开客户端(SPA、移动App)
总结
核心要点
- PKCE解决授权码拦截问题
- 使用验证码和挑战码
- 不需要客户端密钥
- 适合公开客户端
适用场景
- 移动App:无法安全存储客户端密钥
- 单页应用(SPA):运行在浏览器中
- 原生桌面应用:可能被反编译
给开发者的建议
- 公开客户端必须使用PKCE
- 使用S256挑战方法
- 安全存储验证码
- 验证挑战码
PKCE就像给授权码加了一把”只有你知道的锁”:即使授权码被拦截,没有钥匙也打不开。