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

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

PKCE

引言:手机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涉及三个关键概念

  1. Code Verifier(验证码):随机生成的字符串,只有App知道
  2. Code Challenge(挑战码):验证码的哈希值,发送给授权服务器
  3. Code Challenge Method(挑战方法):哈希算法,通常是S256

流程图解

步骤一:生成验证码和挑战码

1
2
App → 生成随机验证码(code_verifier)
→ 计算挑战码(code_challenge = SHA256(code_verifier))

步骤二:授权请求

1
2
3
App → 发送授权请求
→ 携带挑战码(code_challenge)
→ 携带挑战方法(code_challenge_method=S256)

步骤三:获取授权码

1
2
授权服务器 → 返回授权码
→ 存储挑战码

步骤四:交换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

# 生成随机验证码(43-128字符)
code_verifier = secrets.token_urlsafe(96) # 128字符

# 计算挑战码(S256方法)
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,需要

  1. 授权码(已拦截)
  2. 验证码(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('')
}

// 计算SHA256哈希
async function sha256(plain) {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
const hash = await crypto.subtle.digest('SHA-256', data)
return hash
}

// Base64 URL编码
function base64urlencode(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}

// 生成PKCE参数
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()

// 存储codeVerifier(用于后续Token交换)
sessionStorage.setItem('code_verifier', codeVerifier)

// 构建授权URL
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):
"""计算挑战码"""
# SHA256哈希
digest = hashlib.sha256(code_verifier.encode()).digest()
# Base64 URL编码
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}")

# Token端点
@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')

# 生成Token
access_token = generate_access_token()

return jsonify({'access_token': access_token})

第六章:PKCE vs 客户端密钥

安全性对比

维度 客户端密钥 PKCE
适用场景 机密客户端 公开客户端
密钥存储 安全存储 不需要
授权码拦截 可能被利用 防止
实现复杂度 简单 中等

选择建议

使用客户端密钥

  • 有后端服务器
  • 可以安全存储密钥
  • 机密客户端

使用PKCE

  • 无后端服务器
  • 无法安全存储密钥
  • 公开客户端(SPA、移动App)

总结

核心要点

  1. PKCE解决授权码拦截问题
  2. 使用验证码和挑战码
  3. 不需要客户端密钥
  4. 适合公开客户端

适用场景

  • 移动App:无法安全存储客户端密钥
  • 单页应用(SPA):运行在浏览器中
  • 原生桌面应用:可能被反编译

给开发者的建议

  • 公开客户端必须使用PKCE
  • 使用S256挑战方法
  • 安全存储验证码
  • 验证挑战码

PKCE就像给授权码加了一把”只有你知道的锁”:即使授权码被拦截,没有钥匙也打不开。