当你用 Google 账号登录第三方应用时,你有没有想过:你的密码有没有发给那个应用?答案是”没有”——这就是 OAuth 2.0 的核心价值。你的密码只交给 Google(授权服务器),第三方应用拿到的是一个有时效的”令牌”,它只能访问你授权范围内的资源,且随时可以撤销。
在 证书与 PKI 中理解了证书信任链。当你用 Google 账号登录第三方应用时,密码并没有发给那个应用——这就是 OAuth 2.0 的核心价值。这一章来拆解 OAuth 2.0 的授权流程和 OIDC 的身份层。
一、OAuth 2.0 基础
1.1 OAuth 2.0 解决什么问题
假设你开发了一个照片打印服务”PrintMyPic”,用户想让你打印他们存在 Google Photos 里的照片。最直觉的做法是:让用户把 Google 密码告诉你,你替他们登录 Google 去下载照片。
这有几个严重问题:
| 问题 | 后果 |
|---|---|
| 密码泄露 | 你存了用户的 Google 密码,一旦被拖库,用户所有 Google 服务都暴露 |
| 权限过度 | 你只需要读照片,但拿到密码等于拿到了全部权限——邮件、文档、日历 |
| 无法细粒度授权 | 用户不能只授权”读照片”而保留其他权限 |
| 无法撤销 | 用户改了 Google 密码才能收回权限,无法单独撤销你的访问 |
OAuth 2.0 的解决方案:用户不需要交出密码,而是授权服务器给第三方应用颁发一个有限范围的令牌(Access Token)。用户可以随时撤销授权,令牌有过期时间,且只包含用户授权的权限范围。
1.2 四个角色
| 角色 | 说明 | 示例 |
|---|---|---|
| Resource Owner | 资源所有者,能授予访问权限的实体 | 用户(你) |
| Client | 请求访问资源的第三方应用 | ”使用 Google 登录”的网站 |
| Authorization Server | 认证用户并颁发令牌的服务器 | Google OAuth |
| Resource Server | 托管受保护资源的服务器 | Google API |
注意:Authorization Server 和 Resource Server 可以是同一个服务(如 Google),也可以分开部署。
1.3 四种授权流程
| 流程 | 安全性 | 适用场景 | 推荐 |
|---|---|---|---|
| 授权码 | 高 | Web 应用(有后端) | |
| 授权码+PKCE | 最高 | SPA/移动端 | |
| 隐式 | 低 | 已废弃 | |
| 客户端凭证 | 中 | 服务间通信 |
OAuth 2.1 草案已正式移除隐式流程和资源所有者密码凭证流程。所有新项目都应使用授权码(有后端)或授权码+PKCE(无后端/公开客户端)。
二、授权码流程
2.1 标准授权码流程
核心思想:先通过浏览器拿到一个短期有效的授权码,再由后端用授权码+客户端密钥换取令牌。授权码只使用一次,且必须配合 client_secret 才能换到令牌。
# 步骤 1:重定向到授权端点GET /authorize? response_type=code& client_id=YOUR_CLIENT_ID& redirect_uri=https://example.com/callback& scope=openid profile email& state=RANDOM_STATE
# 步骤 2:用户授权后,回调GET /callback?code=AUTH_CODE&state=RANDOM_STATE
# 步骤 3:用 code 换 token(后端→授权服务器,不经过浏览器)POST /token grant_type=authorization_code& code=AUTH_CODE& redirect_uri=https://example.com/callback& client_id=YOUR_CLIENT_ID& client_secret=YOUR_CLIENT_SECRET2.2 为什么需要授权码
为什么不直接在回调中返回 token,而要先返回授权码再换 token?
| 考量 | 直接返回 Token | 授权码换 Token |
|---|---|---|
| Token 暴露面 | Token 出现在浏览器 URL/历史记录中 | Token 只在后端与授权服务器的 HTTPS 通信中出现 |
| 客户端认证 | 无法验证请求来自合法客户端 | 换 token 时必须提供 client_secret |
| 一次性使用 | Token 可被重放 | 授权码只能使用一次,用完即废 |
| 浏览器安全 | 依赖浏览器安全(Referer 泄露、日志记录) | Token 从不经过浏览器 |
授权码是浏览器和后端之间的”桥梁”,它让 token 从不暴露在浏览器环境中。
2.3 服务端实现示例
# Flask 实现 OAuth2 授权码流程from flask import Flask, request, redirect, sessionimport requests, secrets
app = Flask(__name__)app.secret_key = "your-secret-key"CLIENT_ID, CLIENT_SECRET = "your-client-id", "your-client-secret"REDIRECT_URI = "https://example.com/callback"AUTH_SERVER = "https://accounts.google.com"
@app.route("/login")def login(): state = secrets.token_urlsafe(32) session["oauth_state"] = state return redirect( f"{AUTH_SERVER}/o/oauth2/v2/auth?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URI}&scope=openid profile email&state={state}" )
@app.route("/callback")def callback(): if request.args.get("state") != session.pop("oauth_state", None): return "State 验证失败", 400 code = request.args.get("code") if not code: return "授权失败", 400 token_resp = requests.post(f"{AUTH_SERVER}/o/oauth2/token", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, }) return "登录成功"2.4 授权码 + PKCE(公开客户端)
对于没有后端的 SPA 和移动端应用,无法安全存储 client_secret。PKCE(Proof Key for Code Exchange,RFC 7636)就是为公开客户端设计的增强方案。
PKCE 核心思路:客户端生成随机的 code_verifier,将其哈希值 code_challenge 发给授权服务器;换 token 时发送原始 code_verifier,授权服务器验证哈希是否匹配。即使授权码被截获,攻击者没有 code_verifier 也无法换取 token。
# PKCE 实现import hashlib, base64, os
# 生成 code_verifier(43-128 字符)和 code_challenge(SHA-256 哈希)code_verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode()code_challenge = base64.urlsafe_b64encode( hashlib.sha256(code_verifier.encode()).digest()).rstrip(b'=').decode()
# 授权请求携带 code_challenge;Token 请求携带 code_verifier(原始值,非哈希)auth_url = ( f"{AUTH_SERVER}/authorize?response_type=code&client_id={CLIENT_ID}&" f"redirect_uri={REDIRECT_URI}&scope=openid profile&" f"code_challenge={code_challenge}&code_challenge_method=S256")token_data = {"grant_type": "authorization_code", "code": auth_code, "client_id": CLIENT_ID, "code_verifier": code_verifier}隐式流程(response_type=token)已废弃——它直接在 URL fragment 中返回 token,容易被截获,且无法验证客户端身份。所有新应用都应使用授权码+PKCE 流程。
三、隐式流程(已废弃)
3.1 隐式流程的工作方式
隐式流程跳过授权码步骤,直接在回调 URL 的 fragment 中返回 Access Token:
3.2 为什么隐式流程不安全
| 漏洞 | 说明 |
|---|---|
| Token 暴露在 URL 中 | Access Token 出现在 URL fragment 中,可能被浏览器历史记录、Referer 头、代理日志记录 |
| 无法验证客户端身份 | 没有 client_secret 验证环节,任何拿到 token 的人都可以使用 |
| Token 替换攻击 | 攻击者可以构造一个合法 token 的替代品,让客户端使用攻击者的 token |
| 无 Refresh Token | 隐式流程不返回 Refresh Token,token 过期后必须重新授权 |
3.3 为什么 OAuth 2.1 移除了隐式流程
- 授权码+PKCE 更安全且同样简单:PKCE 解决了公开客户端的安全问题
- 浏览器安全模型变化:SameSite Cookie、CORS 策略、浏览器扩展生态使得 URL fragment 中的 token 更容易泄露
- 行业共识:Google、Microsoft、Auth0 已全面推荐授权码+PKCE
如果你的项目仍在使用隐式流程(response_type=token),请立即迁移到授权码+PKCE。这不是建议,是必须——隐式流程的安全缺陷已被广泛利用。
四、客户端凭证流程
4.1 机器对机器认证
客户端凭证流程适用于没有用户参与的场景——两个服务之间直接通信。
POST /token grant_type=client_credentials& client_id=SERVICE_ID& client_secret=SERVICE_SECRET& scope=api.read# Python 服务间调用示例import requests
def get_service_token(): resp = requests.post("https://auth.example.com/oauth2/token", data={ "grant_type": "client_credentials", "client_id": "my-service-id", "client_secret": "my-service-secret", "scope": "api.read api.write", }) return resp.json()["access_token"]
def call_internal_api(): token = get_service_token() resp = requests.get( "https://api.internal.example.com/users", headers={"Authorization": f"Bearer {token}"}, ) return resp.json()4.2 授权码 vs 客户端凭证
| 维度 | 授权码 | 客户端凭证 |
|---|---|---|
| 用户参与 | 需要 | 不需要 |
| 适用场景 | 用户授权 | 服务间通信 |
| Token 类型 | Access + Refresh | 仅 Access |
| 代表谁 | 用户 | 应用自身 |
| 客户端类型 | 机密/公开 | 仅机密 |
| Refresh Token | 有 | 无(重新获取即可) |
五、PKCE 深入
5.1 为什么 PKCE 是必须的
PKCE 最初为原生移动应用设计——移动应用无法安全存储 client_secret(APK/IPA 可被反编译)。但如今,所有公开客户端都应使用 PKCE:
- 授权码截获攻击:移动端恶意应用可能注册相同的 URL Scheme 来截获授权码回调
- 网络窃听:代理、恶意浏览器扩展仍可能截获授权码
- 日志泄露:授权码出现在服务器日志中(如 Nginx access log),没有 PKCE 就能直接换 token
5.2 code_verifier 与 code_challenge 的生成
import hashlib, base64, secrets
def generate_pkce_pair(): """生成 PKCE 的 code_verifier 和 code_challenge 对""" code_verifier = base64.urlsafe_b64encode( secrets.token_bytes(32) # 32 字节 → 43 字符 Base64 ).rstrip(b'=').decode('ascii')
digest = hashlib.sha256(code_verifier.encode('ascii')).digest() code_challenge = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii') return code_verifier, code_challenge
verifier, challenge = generate_pkce_pair()print(f"code_verifier: {verifier}") # 发送给 /token 端点print(f"code_challenge: {challenge}") # 发送给 /authorize 端点关键规则:
code_verifier长度必须在 43-128 字符之间code_challenge_method必须是S256(不要用plain,它等于没加密)- 每次授权请求必须生成新的 PKCE 对
六、OpenID Connect(OIDC)
6.1 OAuth 2.0 vs OIDC
OAuth 2.0 解决授权问题——“这个应用能不能访问我的数据”。OIDC 在 OAuth 2.0 之上叠加身份认证层——“我是谁”。
| 维度 | OAuth 2.0 | OIDC |
|---|---|---|
| 目的 | 授权(你能做什么) | 认证+授权(你是谁+你能做什么) |
| 核心输出 | Access Token | ID Token + Access Token |
| 用户信息 | 需要额外调用 API 获取 | ID Token 中直接包含 |
| 标准化 | 无标准用户信息格式 | 标准声明集(sub, name, email 等) |
| 互操作性 | 各家实现不同 | 标准化发现、注册、会话管理 |
| 会话管理 | 无标准 | Front-channel/Back-channel 登出 |
简单类比:OAuth 2.0 是酒店的房卡(授权你进入房间),OIDC 是你的身份证(证明你是谁)。很多应用把 OAuth 2.0 当认证用——这是危险的,因为 Access Token 不包含用户身份信息。
6.2 ID Token 结构与声明
ID Token 是一个 JWT,包含用户身份信息:
{ "iss": "https://accounts.google.com", "sub": "1234567890", "aud": "your-client-id", "exp": 1699999999, "iat": 1699999000, "auth_time": 1699998900, "nonce": "n-0S6_WzA2Mj", "name": "Zhang San", "email": "zhangsan@example.com", "email_verified": true}| 字段 | 说明 | 是否必须 |
|---|---|---|
iss | 签发者(Issuer),授权服务器的 URL | |
sub | 用户唯一标识(Subject),对同一 iss 永远不变 | |
aud | 接收者(Audience),即 client_id | |
exp | 过期时间(Unix 时间戳) | |
iat | 签发时间(Unix 时间戳) | |
auth_time | 用户实际完成认证的时间 | 推荐 |
nonce | 防重放攻击的随机值,与授权请求中的 nonce 一致 | 推荐 |
name / email | 用户全名 / 邮箱 | 可选 |
验证 ID Token 时必须检查:iss 是否匹配授权服务器、aud 是否包含你的 client_id、exp 是否未过期、nonce 是否与授权请求中的一致。忽略任何一项都可能导致安全漏洞。
6.3 UserInfo 端点
ID Token 只包含基本声明。如需更多用户信息,调用 UserInfo 端点:
GET /userinfoAuthorization: Bearer <access_token>{ "sub": "1234567890", "name": "Zhang San", "given_name": "San", "family_name": "Zhang", "email": "zhangsan@example.com", "phone_number": "+86 13800138000", "address": { "formatted": "北京市朝阳区xxx", "country": "CN" }}为什么需要单独的 UserInfo 端点?ID Token 需要保持紧凑(在 HTTP 请求中传递),不适合放太多数据。UserInfo 端点用 Access Token 调用,返回完整用户信息。
6.4 OIDC 发现
OIDC 定义了标准化的发现机制——通过 .well-known/openid-configuration 获取授权服务器配置:
{ "issuer": "https://accounts.google.com", "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", "token_endpoint": "https://oauth2.googleapis.com/token", "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo", "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", "response_types_supported": ["code", "token", "id_token"], "id_token_signing_alg_values_supported": ["RS256"], "scopes_supported": ["openid", "email", "profile"]}发现端点的价值:你的代码不需要硬编码任何端点 URL——只需知道 issuer,就能自动发现所有端点、支持的算法、可用的 scope。
# Python 动态发现 OIDC 配置import requests
def discover_oidc_config(issuer): resp = requests.get(f"{issuer}/.well-known/openid-configuration") resp.raise_for_status() return resp.json()
config = discover_oidc_config("https://accounts.google.com")print(f"授权端点: {config['authorization_endpoint']}")print(f"Token 端点: {config['token_endpoint']}")print(f"JWKS 端点: {config['jwks_uri']}")七、Token 类型详解
7.1 Access Token vs Refresh Token vs ID Token
| 维度 | Access Token | Refresh Token | ID Token |
|---|---|---|---|
| 用途 | 访问受保护资源 | 获取新的 Access Token | 证明用户身份 |
| 格式 | JWT 或 Opaque | Opaque(不透明字符串) | JWT |
| 有效期 | 短(15 分钟 ~ 1 小时) | 长(7 天 ~ 30 天) | 短(与 Access Token 同期) |
| 接收方 | Resource Server | Authorization Server | Client |
| 是否应传给前端 | 可以(需安全存储) | 绝对不能 | 可以(读取用户信息) |
| 泄露影响 | 小(短期有效) | 大(可长期获取新 token) | 中(包含用户信息) |
7.2 Opaque Token vs JWT
| 维度 | Opaque Token | JWT |
|---|---|---|
| 格式 | 随机字符串(如 abc123def456) | 三段式编码(header.payload.signature) |
| 自包含 | 否,需查询授权服务器验证 | 是,包含声明和签名 |
| 性能 | 每次请求需远程验证 | 本地验证签名即可 |
| 撤销 | 立即生效(删除服务端记录) | 需等待过期或使用黑名单 |
| 大小 | 小(几十字节) | 大(几百字节 ~ 几 KB) |
| 适用 | 高安全要求、需即时撤销 | 分布式系统、微服务网关 |
# JWT 格式的 Access Token 解码示例import jwt # PyJWT 库
token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature"decoded = jwt.decode(token, options={"verify_signature": False})print(decoded)# {'sub': '1234567890', 'name': 'John Doe', 'aud': 'client-id', 'exp': 1699999999}7.3 Token 轮换策略
Refresh Token 轮换(Rotation)是防止 Token 泄露的关键策略:
轮换策略核心规则:
- 每次使用 Refresh Token 后,旧 Token 立即失效,颁发新 Token
- 如果已失效的 Token 被再次使用,说明 Token 泄露,撤销该用户所有 Token
- Refresh Token 必须与客户端绑定(client_id + 设备指纹)
7.4 Token 存储安全
| 存储方式 | 安全性 | 适用 |
|---|---|---|
| LocalStorage | XSS 可窃取 | 不推荐 |
| SessionStorage | XSS 可窃取 | 不推荐 |
| HttpOnly Cookie | JS 不可访问 | 推荐 |
| 内存 | 刷新即丢失 | SPA 推荐 |
Access Token 存储在内存中,Refresh Token 存储在 HttpOnly + Secure + SameSite Cookie 中。这是 SPA 最安全的 Token 存储方案。Access Token 存内存意味着页面刷新后会丢失,但 Refresh Token 的 HttpOnly Cookie 会自动发送,帮你无感获取新的 Access Token。
八、安全最佳实践
8.1 OAuth 2.0 常见漏洞
| 漏洞 | 攻击方式 | 防御措施 |
|---|---|---|
| Redirect URI 操纵 | 修改 redirect_uri 指向攻击者服务器,截获授权码 | 严格白名单校验,精确匹配(包括路径和查询参数) |
| CSRF 攻击 | 构造自己的授权码,让受害者使用攻击者的 code 完成登录 | 使用 state 参数,每次请求生成随机值,回调时严格验证 |
| Token 泄露 | Token 出现在 URL、日志、Referer 头中 | Token 只通过 POST 请求体传递,不放在 URL 中 |
| 授权码重放 | 截获授权码后重复使用 | 授权码一次性使用,用完即废;设置短有效期 |
| 开放重定向 | redirect_uri 验证不严 | 精确匹配 redirect_uri,不允许通配符 |
| PKCE 降级 | 将 code_challenge_method 从 S256 降级为 plain | 强制使用 S256,拒绝 plain 方法 |
8.2 OAuth 2.1 改进
| 改进 | 说明 |
|---|---|
| 移除隐式流程 | response_type=token 不再被允许 |
| 移除密码凭证流程 | grant_type=password 不再被允许 |
| PKCE 必须使用 | 所有授权码流程都要求 PKCE |
| redirect_uri 精确匹配 | 不再允许通配符或子路径匹配 |
| Bearer Token 不在 URL 中 | 不再允许 ?access_token=xxx 的方式传递 token |
| Refresh Token 轮换 | 强制要求 Refresh Token 一次性使用 |
8.3 安全检查清单
- state 参数:每次授权请求生成随机 state,回调时严格验证
- PKCE:公开客户端必须使用 S256 方法的 PKCE
- redirect_uri:精确白名单匹配,不允许通配符
- client_secret:只在后端使用,绝不暴露给前端
- Token 存储:Access Token 存内存,Refresh Token 存 HttpOnly Cookie
- HTTPS:所有 OAuth 通信必须走 HTTPS
- 授权码有效期:不超过 10 分钟,一次性使用
- scope 最小化:只请求必要的权限范围
- Token 过期:Access Token 有效期不超过 1 小时
- Refresh Token 轮换:每次使用后颁发新 Token
- ID Token 验证:验证 iss、aud、exp、nonce
- 日志脱敏:确保 token、授权码不出现在日志中
九、实践
9.1 用 Go 实现 OAuth2 授权服务器
package main
import ( "crypto/sha256" "encoding/base64" "fmt" "net/http" "sync" "time" "github.com/golang-jwt/jwt/v5")
type AuthServer struct { clients map[string]*Client authCodes map[string]*AuthCode jwtSecret []byte mu sync.RWMutex}type Client struct{ ID, Secret string; RedirectURIs []string }type AuthCode struct { Code, ClientID, RedirectURI, CodeChallenge string ExpiresAt time.Time; Used bool}
// Authorize: 验证 client_id + redirect_uri,生成授权码func (s *AuthServer) Authorize(w http.ResponseWriter, r *http.Request) { clientID := r.URL.Query().Get("client_id") redirectURI := r.URL.Query().Get("redirect_uri") codeChallenge := r.URL.Query().Get("code_challenge") state := r.URL.Query().Get("state") client, ok := s.clients[clientID] if !ok { http.Error(w, "invalid client_id", 400); return } validURI := false for _, uri := range client.RedirectURIs { if uri == redirectURI { validURI = true; break } } if !validURI { http.Error(w, "invalid redirect_uri", 400); return } code := randomString(32) s.mu.Lock() s.authCodes[code] = &AuthCode{ Code: code, ClientID: clientID, RedirectURI: redirectURI, CodeChallenge: codeChallenge, ExpiresAt: time.Now().Add(10 * time.Minute), } s.mu.Unlock() http.Redirect(w, r, fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state), 302)}
// Token: 验证授权码 + PKCE,签发 JWTfunc (s *AuthServer) Token(w http.ResponseWriter, r *http.Request) { code := r.FormValue("code") codeVerifier := r.FormValue("code_verifier") s.mu.Lock() ac, ok := s.authCodes[code] if !ok || ac.Used || time.Now().After(ac.ExpiresAt) { http.Error(w, "invalid code", 400); s.mu.Unlock(); return } if ac.CodeChallenge != "" { h := sha256.Sum256([]byte(codeVerifier)) if base64.RawURLEncoding.EncodeToString(h[:]) != ac.CodeChallenge { http.Error(w, "PKCE failed", 400); s.mu.Unlock(); return } } ac.Used = true; s.mu.Unlock() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "sub": "user-123", "aud": ac.ClientID, "scope": "openid profile email", "exp": time.Now().Add(time.Hour).Unix(), }) accessToken, _ := token.SignedString(s.jwtSecret) w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":3600}`, accessToken)}
func main() { s := &AuthServer{ clients: map[string]*Client{ "test-client": {ID: "test-client", Secret: "test-secret", RedirectURIs: []string{"http://localhost:8080/callback"}}, }, authCodes: make(map[string]*AuthCode), jwtSecret: []byte("your-256-bit-secret"), } http.HandleFunc("/authorize", s.Authorize) http.HandleFunc("/token", s.Token) http.ListenAndServe(":9090", nil)}9.2 调试 OAuth2 流程
# 1. 用 curl 模拟授权码换 tokencurl -X POST https://auth.example.com/token \ -d "grant_type=authorization_code" \ -d "code=YOUR_AUTH_CODE" \ -d "redirect_uri=http://localhost:8080/callback" \ -d "client_id=test" -d "client_secret=secret"
# 2. 解码 JWT Token(不验证签名)echo "YOUR_JWT_TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | python3 -m json.tool
# 3. 查看 OIDC 发现文档curl https://auth.example.com/.well-known/openid-configuration | python3 -m json.tool
# 4. 获取 JWKS(公钥,用于验证 JWT 签名)curl https://auth.example.com/.well-known/jwks.json | python3 -m json.tool调试时的常见问题:
| 问题 | 原因 | 解决 |
|---|---|---|
| redirect_uri_mismatch | redirect_uri 与注册的不完全一致 | 确保协议、域名、端口、路径完全匹配 |
| invalid_grant | 授权码过期或已使用 | 授权码只能用一次,10 分钟内有效 |
| invalid_client | client_id/client_secret 错误 | 检查凭证是否正确,是否 URL 编码 |
| PKCE verification failed | code_verifier 与 code_challenge 不匹配 | 确保使用 S256 方法,哈希计算正确 |
九·附、实践:构建 OAuth2 授权码流程
OAuth 2.0 的诞生源于一个现实问题:HTTP Basic Auth 把密码直接交给第三方应用——你让某个 Twitter 客户端访问你的账户,就得把 Twitter 密码给它,它可以做任何事。2007 年 OAuth 1.0 试图解决这个问题,但复杂的签名机制(每个请求都要 HMAC 签名)让开发者望而却步。2012 年 OAuth 2.0 简化了设计:用 Bearer Token 替代签名,用 HTTPS 保护传输——“简单就是安全的基础”。然而,简化也带来了新的风险:移动端和 SPA 无法安全存储 client_secret,授权码可能被截获。2015 年 PKCE(Proof Key for Code Exchange)扩展解决了这个问题——客户端用动态生成的 challenge 证明”这个授权码是我发起的”,无需 client_secret。
从 Basic Auth 到 OAuth 2.0 + PKCE,核心演进是:从”共享密码”到”委托授权”再到”证明所有权”——每一步都在减少需要信任的信息量。
附.1 前置知识
- Python 3.8+ 环境(hashlib、secrets、base64 为内置库)
- PyJWT 库(
pip install PyJWT,用于 JWT 解码) - 理解本章前九节的理论内容(授权码流程、PKCE、Token 类型)
附.2 PKCE 生成器
PKCE 的 S256 方法是 OAuth 2.0 安全的核心组件。理解它的计算过程是理解整个授权码流程安全性的基础:
import hashlib, base64, secrets
# Step 1: 生成 code_verifier(高熵随机字符串)code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()
# Step 2: 计算 code_challenge = BASE64URL(SHA256(code_verifier))code_challenge = base64.urlsafe_b64encode( hashlib.sha256(code_verifier.encode()).digest()).rstrip(b'=').decode()
print(f"code_verifier: {code_verifier}")print(f" (长度: {len(code_verifier)} chars, 32 bytes 原始数据)")print(f"code_challenge: {code_challenge}")print(f" = BASE64URL(SHA256(code_verifier))")
# 验证:重新计算 challenge 应与原始匹配recomputed = base64.urlsafe_b64encode( hashlib.sha256(code_verifier.encode()).digest()).rstrip(b'=').decode()print(f"重新计算匹配: {recomputed == code_challenge}")code_verifier: U7j1QxRl9HRvlszG63RFQqVcMzW1Jr181yczQAjqLyk (长度: 43 chars, 32 bytes 原始数据)code_challenge: hoZLEUI4_x5OuPB068oKDKNtCdjGXRS_EcaXcKRb4-s = BASE64URL(SHA256(code_verifier))重新计算匹配: True注:PKCE 的安全性在于:code_challenge 在授权请求中发送(公开可见),code_verifier 在 token 交换时发送(只经过 HTTPS)。攻击者截获授权码后,无法从 challenge 反推 verifier(SHA-256 是单向函数),因此无法完成 token 交换。
附.3 授权码 + PKCE 完整流程模拟
以下代码模拟了 OAuth 2.0 授权码 + PKCE 的完整四步流程:
import hashlib, base64, secrets, json
# Step 1: 客户端准备(生成 state 和 PKCE 参数)state = secrets.token_urlsafe(16)code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode()code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b'=').decode()
print("Step 1: 客户端准备")print(f" state: {state}")print(f" code_verifier: {code_verifier}")print(f" code_challenge: {code_challenge}")
# Step 2: 构造授权 URL(用户在浏览器中访问)auth_url = ( f"https://auth.example.com/authorize?" f"response_type=code&" f"client_id=my-app&" f"redirect_uri=http://localhost:8080/callback&" f"scope=openid profile email&" f"state={state}&" f"code_challenge={code_challenge}&" f"code_challenge_method=S256")print(f"\nStep 2: 授权 URL")print(f" {auth_url[:80]}...")
# Step 3: 授权服务器返回授权码auth_code = secrets.token_urlsafe(16)print(f"\nStep 3: 授权服务器返回")print(f" authorization_code: {auth_code}")print(f" state: {state} (客户端验证与 Step 1 的 state 匹配)")
# Step 4: 客户端用授权码 + code_verifier 交换 tokenprint(f"\nStep 4: Token 交换请求")print(f" POST /token")print(f" grant_type=authorization_code")print(f" code={auth_code}")print(f" code_verifier={code_verifier}")print(f" → 服务器验证: SHA256(code_verifier) == code_challenge ")Step 1: 客户端准备 state: Z6cKscUxwFpGp_NNM6NEig code_verifier: wfqsnk7mBiPGMurej-VqEQ7dwOsaR32ePJCgsPHdUN0 code_challenge: _57HreRsePbg2wTzNUBBOLXX08Q5qEgQXs7eF-eI1ZU
Step 2: 授权 URL https://auth.example.com/authorize?response_type=code&client_id=my-app&redirect_...
Step 3: 授权服务器返回 authorization_code: yuRzyVu88TWb1drDlnSKKw state: Z6cKscUxwFpGp_NNM6NEig (客户端验证与 Step 1 的 state 匹配)
Step 4: Token 交换请求 POST /token grant_type=authorization_code code=yuRzyVu88TWb1drDlnSKKw code_verifier=wfqsnk7mBiPGMurej-VqEQ7dwOsaR32ePJCgsPHdUN0 → 服务器验证: SHA256(code_verifier) == code_challenge注意:授权码只能使用一次,有效期通常为 10 分钟。如果攻击者截获授权码并尝试用它交换 token,由于攻击者不知道 code_verifier,服务器计算的 challenge 与原始 challenge 不匹配,请求会被拒绝。
附.4 JWT Token 解码与分析
OAuth 2.0 返回的 Access Token 通常是 JWT。理解 JWT 的结构对于调试授权流程至关重要:
import jwt, json, base64
# 模拟 Access Tokensecret = "jwt-secret-key"token = jwt.encode({ "iss": "https://auth.example.com", "sub": "user-12345", "aud": "my-app", "exp": 1778167624, "iat": 1778164024, "scope": "openid profile email", "client_id": "my-app"}, secret, algorithm="HS256")
# 解码 JWT(不验证签名——仅用于调试)header = json.loads(base64.urlsafe_b64decode(token.split('.')[0] + '=='))payload = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '=='))
print(f"JWT Header:")print(json.dumps(header, indent=2))print(f"\nJWT Payload:")print(json.dumps(payload, indent=2))print(f"\n JWT 是 Base64 编码而非加密——任何人都可以解码查看内容")JWT Header:{ "alg": "HS256", "typ": "JWT"}
JWT Payload:{ "iss": "https://auth.example.com", "sub": "user-12345", "aud": "my-app", "exp": 1778167624, "iat": 1778164024, "scope": "openid profile email", "client_id": "my-app"}
JWT 是 Base64 编码而非加密——任何人都可以解码查看内容调试 JWT 的命令行方法:
# 解码 JWT Payload(不验证签名)echo 'PAYLOAD_PART' | base64 -d 2>/dev/null | python3 -m json.tool
# 查看 OIDC 发现文档curl https://auth.example.com/.well-known/openid-configuration | python3 -m json.tool
# 获取 JWKS(公钥,用于验证 JWT 签名)curl https://auth.example.com/.well-known/jwks.json | python3 -m json.tool附.5 state 参数防 CSRF
state 参数是 OAuth 2.0 防止 CSRF 攻击的关键机制。攻击者可以构造一个恶意授权链接,诱骗用户点击——如果没有 state 参数,用户的浏览器会自动完成授权流程,攻击者获得授权码:
import secrets
# 正常流程:state 匹配original_state = secrets.token_urlsafe(16)returned_state = original_stateprint(f"正常流程:")print(f" 客户端发送 state: {original_state}")print(f" 服务器返回 state: {returned_state}")print(f" 匹配: {original_state == returned_state} ")
# CSRF 攻击:攻击者伪造 stateattacker_state = secrets.token_urlsafe(16)print(f"\nCSRF 攻击:")print(f" 客户端发送 state: {original_state}")print(f" 攻击者构造 state: {attacker_state}")print(f" 匹配: {original_state == attacker_state} → 拒绝请求")正常流程: 客户端发送 state: njmI0TseDc1z_soOmcPxbw 服务器返回 state: njmI0TseDc1z_soOmcPxbw 匹配: True
CSRF 攻击: 客户端发送 state: njmI0TseDc1z_soOmcPxbw 攻击者构造 state: lyD_P0zaVZasB9-EJVIqdg 匹配: False → 拒绝请求附.6 实践小结
| 授权类型 | 安全机制 | 适用场景 | 不适用场景 |
|---|---|---|---|
| 授权码 + client_secret | 后端安全存储 secret | Web 应用(有后端) | SPA、移动端 |
| 授权码 + PKCE | code_verifier/challenge 动态证明 | SPA、移动端、桌面端 | — |
| 客户端凭证 | 服务自身凭证 | 微服务间通信 | 需要用户身份 |
| 隐式流程 | 无 | 已废弃 | 所有场景 |
关键教训:
- 公开客户端必须使用 PKCE:SPA、移动端、桌面端无法安全存储 client_secret
- state 参数不可省略:它是防止 CSRF 攻击的唯一机制
- JWT 是编码不是加密:不要在 JWT 中存储敏感数据(密码、身份证号等)
- redirect_uri 必须精确匹配:协议、域名、端口、路径必须完全一致
十、总结
上一章了解了证书与 PKI 体系。
10.1 授权类型选择决策表
| 你的场景 | 推荐流程 | 原因 |
|---|---|---|
| Web 应用(有后端) | 授权码 | 后端安全存储 client_secret,token 不经过浏览器 |
| SPA(无后端) | 授权码 + PKCE | 无需 client_secret,PKCE 防止授权码截获 |
| 移动应用 | 授权码 + PKCE | 同 SPA,且防止 URL Scheme 截获 |
| 桌面应用 | 授权码 + PKCE | 同移动应用 |
| 微服务间通信 | 客户端凭证 | 无用户参与,服务自身凭证即可 |
| 需要用户身份 | 授权码 + OIDC | OIDC 提供 ID Token,标准化身份信息 |
| 已有隐式流程 | 迁移到授权码 + PKCE | 隐式流程已废弃,必须迁移 |
10.2 核心要点回顾
| 维度 | 关键要点 |
|---|---|
| 授权码 | Web 应用首选,后端用 code 换 token,token 不经过浏览器 |
| PKCE | 公开客户端必须,S256 方法,防止授权码截获 |
| 隐式流程 | 已废弃,必须迁移到授权码+PKCE |
| 客户端凭证 | 服务间通信,无需用户参与 |
| OIDC | OAuth 2.0 + ID Token,标准化身份认证 |
| Token 安全 | Access Token 内存存储,Refresh Token HttpOnly Cookie + 轮换 |
| 安全检查 | state 防 CSRF、redirect_uri 精确匹配、PKCE、HTTPS |
参考
- RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients (PKCE)
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






