mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4920 字
14 分钟
OAuth 2.0 与 OpenID Connect
2026-03-30

当你用 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
graph LR RO[" Resource Owner"] -->|"授权"| C[" Client"] C -->|"请求授权"| AS[" Auth Server"] AS -->|"颁发 Token"| C C -->|"携带 Token"| RS[" Resource Server"] AS -.->|"验证 Token"| RS RO -.->|"登录认证"| AS

注意:Authorization Server 和 Resource Server 可以是同一个服务(如 Google),也可以分开部署。

1.3 四种授权流程#

流程安全性适用场景推荐
授权码Web 应用(有后端)
授权码+PKCE最高SPA/移动端
隐式已废弃
客户端凭证服务间通信
Note

OAuth 2.1 草案已正式移除隐式流程和资源所有者密码凭证流程。所有新项目都应使用授权码(有后端)或授权码+PKCE(无后端/公开客户端)。

二、授权码流程#

2.1 标准授权码流程#

核心思想:先通过浏览器拿到一个短期有效的授权码,再由后端用授权码+客户端密钥换取令牌。授权码只使用一次,且必须配合 client_secret 才能换到令牌。

sequenceDiagram participant U as User participant C as Client (后端) participant B as Browser participant AS as Auth Server participant RS as Resource Server U->>B: 点击"使用 Google 登录" B->>AS: GET /authorize?response_type=code&client_id=xxx&state=abc123 AS->>B: 显示登录页面 U->>AS: 输入账号密码并授权 AS->>B: 302 重定向 redirect_uri?code=AUTH_CODE&state=abc123 B->>C: 回调携带授权码 C->>C: 验证 state(防 CSRF) C->>AS: POST /token (code + client_id + client_secret) AS->>C: 返回 access_token + refresh_token C->>RS: GET /api/userinfo (Bearer access_token) RS->>C: 返回用户资源
# 步骤 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_SECRET

2.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, session
import 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。

sequenceDiagram participant C as Client (SPA) participant B as Browser participant AS as Auth Server C->>C: 生成 code_verifier + code_challenge C->>B: 重定向到授权端点 + code_challenge B->>AS: GET /authorize?...&code_challenge=xxx&code_challenge_method=S256 AS->>AS: 保存 code_challenge B->>B: 用户登录并授权 AS->>B: 302 重定向 + code B->>C: 回调携带 code C->>AS: POST /token (code + code_verifier) AS->>AS: 验证 SHA256(code_verifier) == code_challenge AS->>C: 返回 access_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}
Warning

隐式流程(response_type=token)已废弃——它直接在 URL fragment 中返回 token,容易被截获,且无法验证客户端身份。所有新应用都应使用授权码+PKCE 流程。

三、隐式流程(已废弃)#

3.1 隐式流程的工作方式#

隐式流程跳过授权码步骤,直接在回调 URL 的 fragment 中返回 Access Token:

sequenceDiagram participant U as User participant B as Browser participant AS as Auth Server participant C as Client (SPA) U->>B: 点击登录 B->>AS: GET /authorize?response_type=token&client_id=xxx AS->>B: 显示登录页面 U->>AS: 登录并授权 AS->>B: 302 重定向到 redirect_uri#access_token=xxx B->>C: JS 读取 URL fragment 中的 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 移除了隐式流程#

  1. 授权码+PKCE 更安全且同样简单:PKCE 解决了公开客户端的安全问题
  2. 浏览器安全模型变化:SameSite Cookie、CORS 策略、浏览器扩展生态使得 URL fragment 中的 token 更容易泄露
  3. 行业共识:Google、Microsoft、Auth0 已全面推荐授权码+PKCE
Important

如果你的项目仍在使用隐式流程(response_type=token),请立即迁移到授权码+PKCE。这不是建议,是必须——隐式流程的安全缺陷已被广泛利用。

四、客户端凭证流程#

4.1 机器对机器认证#

客户端凭证流程适用于没有用户参与的场景——两个服务之间直接通信。

sequenceDiagram participant C as Service A participant AS as Auth Server participant RS as Service B C->>AS: POST /token (client_id + client_secret + grant_type=client_credentials) AS->>AS: 验证客户端凭证 AS->>C: 返回 access_token C->>RS: GET /api/data (Bearer access_token) RS->>C: 返回数据
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:

  1. 授权码截获攻击:移动端恶意应用可能注册相同的 URL Scheme 来截获授权码回调
  2. 网络窃听:代理、恶意浏览器扩展仍可能截获授权码
  3. 日志泄露:授权码出现在服务器日志中(如 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.0OIDC
目的授权(你能做什么)认证+授权(你是谁+你能做什么)
核心输出Access TokenID 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用户全名 / 邮箱可选
Note

验证 ID Token 时必须检查:iss 是否匹配授权服务器、aud 是否包含你的 client_id、exp 是否未过期、nonce 是否与授权请求中的一致。忽略任何一项都可能导致安全漏洞。

6.3 UserInfo 端点#

ID Token 只包含基本声明。如需更多用户信息,调用 UserInfo 端点:

GET /userinfo
Authorization: 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 TokenRefresh TokenID Token
用途访问受保护资源获取新的 Access Token证明用户身份
格式JWT 或 OpaqueOpaque(不透明字符串)JWT
有效期短(15 分钟 ~ 1 小时)长(7 天 ~ 30 天)短(与 Access Token 同期)
接收方Resource ServerAuthorization ServerClient
是否应传给前端可以(需安全存储)绝对不能可以(读取用户信息)
泄露影响小(短期有效)大(可长期获取新 token)中(包含用户信息)

7.2 Opaque Token vs JWT#

维度Opaque TokenJWT
格式随机字符串(如 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 泄露的关键策略:

sequenceDiagram participant C as Client participant AS as Auth Server C->>AS: 用 Refresh Token A 换新 Token AS->>AS: 使 Refresh Token A 失效 AS->>C: 返回新 Access Token + Refresh Token B Note over C,AS: 如果 A 再次出现,说明被窃取 alt 攻击者用已失效的 A 换 Token C->>AS: 用 Refresh Token A 换新 Token AS->>AS: A 已被轮换!检测到 Token 泄露 AS->>AS: 撤销该用户所有 Refresh Token AS->>C: 返回错误,要求重新登录 end

轮换策略核心规则:

  • 每次使用 Refresh Token 后,旧 Token 立即失效,颁发新 Token
  • 如果已失效的 Token 被再次使用,说明 Token 泄露,撤销该用户所有 Token
  • Refresh Token 必须与客户端绑定(client_id + 设备指纹)

7.4 Token 存储安全#

存储方式安全性适用
LocalStorageXSS 可窃取不推荐
SessionStorageXSS 可窃取不推荐
HttpOnly CookieJS 不可访问推荐
内存刷新即丢失SPA 推荐
Tip

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,签发 JWT
func (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 模拟授权码换 token
curl -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_mismatchredirect_uri 与注册的不完全一致确保协议、域名、端口、路径完全匹配
invalid_grant授权码过期或已使用授权码只能用一次,10 分钟内有效
invalid_clientclient_id/client_secret 错误检查凭证是否正确,是否 URL 编码
PKCE verification failedcode_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 交换 token
print(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 Token
secret = "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_state
print(f"正常流程:")
print(f" 客户端发送 state: {original_state}")
print(f" 服务器返回 state: {returned_state}")
print(f" 匹配: {original_state == returned_state} ")
# CSRF 攻击:攻击者伪造 state
attacker_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后端安全存储 secretWeb 应用(有后端)SPA、移动端
授权码 + PKCEcode_verifier/challenge 动态证明SPA、移动端、桌面端
客户端凭证服务自身凭证微服务间通信需要用户身份
隐式流程已废弃所有场景

关键教训:

  1. 公开客户端必须使用 PKCE:SPA、移动端、桌面端无法安全存储 client_secret
  2. state 参数不可省略:它是防止 CSRF 攻击的唯一机制
  3. JWT 是编码不是加密:不要在 JWT 中存储敏感数据(密码、身份证号等)
  4. redirect_uri 必须精确匹配:协议、域名、端口、路径必须完全一致

十、总结#

上一章了解了证书与 PKI 体系。

10.1 授权类型选择决策表#

你的场景推荐流程原因
Web 应用(有后端)授权码后端安全存储 client_secret,token 不经过浏览器
SPA(无后端)授权码 + PKCE无需 client_secret,PKCE 防止授权码截获
移动应用授权码 + PKCE同 SPA,且防止 URL Scheme 截获
桌面应用授权码 + PKCE同移动应用
微服务间通信客户端凭证无用户参与,服务自身凭证即可
需要用户身份授权码 + OIDCOIDC 提供 ID Token,标准化身份信息
已有隐式流程迁移到授权码 + PKCE隐式流程已废弃,必须迁移

10.2 核心要点回顾#

维度关键要点
授权码Web 应用首选,后端用 code 换 token,token 不经过浏览器
PKCE公开客户端必须,S256 方法,防止授权码截获
隐式流程已废弃,必须迁移到授权码+PKCE
客户端凭证服务间通信,无需用户参与
OIDCOAuth 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)

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

OAuth 2.0 与 OpenID Connect
https://blog.souloss.com/posts/cryptography/oauth2-and-oidc/
作者
Souloss
发布于
2026-03-30
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时