OAuth 2.0 与 OIDC 深入
概述
OAuth 2.0 是当前业界最广泛使用的授权框架,它允许第三方应用获取用户资源而不需要暴露用户的凭据。OpenID Connect(OIDC)则在 OAuth 2.0 之上构建了身份认证层。本文将深入探讨 OAuth 2.0 与 OIDC 的核心机制、安全风险及最佳实践。
OAuth 2.0 四种授权模式
OAuth 2.0 定义了四种授权模式,适用于不同的应用场景。
授权码模式(Authorization Code Grant)
授权码模式是 OAuth 2.0 中最完善、最安全的授权模式,适用于有后端服务器的 Web 应用。其流程如下:
- 用户访问客户端,客户端将用户重定向到授权服务器
- 用户登录并授权,授权服务器返回授权码
- 客户端使用授权码向授权服务器换取访问令牌
- 授权服务器返回访问令牌(及可选的刷新令牌)
- 客户端使用访问令牌访问资源服务器
GET /authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=https://client.example.com/callback&scope=openid%20profile&state=xyz123 HTTP/1.1
Host: auth.example.comPOST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=AUTH_CODE&redirect_uri=https://client.example.com/callback&client_id=CLIENT_ID&client_secret=CLIENT_SECRET隐式模式(Implicit Grant)
隐式模式是为纯前端应用(如浏览器 JavaScript 应用)设计的,授权服务器直接返回访问令牌,不经过授权码交换步骤。
用户 ---> 授权服务器 ---> 返回 access_token(URL 片段中)
↓
客户端(浏览器)流程: 客户端请求授权 → 用户登录授权 → 授权服务器返回 access_token(在 URL fragment 中)→ 客户端提取 token。
密码模式(Resource Owner Password Credentials Grant)
密码模式允许客户端直接使用用户的用户名和密码换取访问令牌。此模式仅适用于高度可信的客户端。
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=password&username=user&password=pass&client_id=CLIENT_ID&client_secret=CLIENT_SECRET安全限制: 客户端不能存储密码,此模式已不推荐使用,RFC 6749 本身也将其标记为遗留模式。
客户端凭证模式(Client Credentials Grant)
客户端凭证模式用于机器对机器(M2M)的通信场景,客户端使用自身凭据获取访问令牌,不涉及用户授权。
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=CLIENT_ID&client_secret=CLIENT_SECRET适用场景: 微服务间通信、后台作业、API 网关内部调用等。
| 授权模式 | 适用场景 | 安全性 | 是否推荐 |
|---|---|---|---|
| 授权码 + PKCE | 原生/SPA 应用 | 高 | 强烈推荐 |
| 授权码 | 服务端 Web 应用 | 高 | 推荐 |
| 隐式模式 | 遗留 SPA(已弃用) | 低 | 不推荐 |
| 密码模式 | 遗留/受信应用 | 低 | 不推荐 |
| 客户端凭证 | 机器对机器 | 中 | 推荐(仅 M2M) |
授权码流程与 PKCE 扩展详解
传统授权码流程的问题
传统授权码流程要求客户端持有 client_secret 在后台交换授权码。但原生应用和单页应用无法安全存储 client_secret,因此存在授权码拦截攻击的风险。
PKCE(Proof Key for Code Exchange)
PKCE(RFC 7636)通过引入动态密钥解决了这一问题,即使授权码被拦截也无法交换成令牌。
PKCE 流程步骤:
- 生成 Code Verifier: 客户端生成一个高熵随机字符串(43-128 个字符)
- 计算 Code Challenge: 使用 SHA-256 对 Code Verifier 进行哈希(或使用明文
S256/plain方法) - 授权请求: 客户端发送
code_challenge和code_challenge_method到授权服务器 - 授权码交换: 客户端发送
code_verifier到授权服务器 - 验证: 授权服务器计算
code_challenge并与存储的值对比,匹配则颁发令牌
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))
= "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"GET /authorize?response_type=code&client_id=CLIENT_ID&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&redirect_uri=... HTTP/1.1
Host: auth.example.comPKCE 的优势
- 即使授权码被拦截,攻击者也无法获得令牌
- 不需要
client_secret,适合公共客户端 - 已被 OAuth 2.0 Security BCP 推荐为所有授权码流程的标配
隐式模式弃用的原因
隐式模式在 OAuth 2.0 Security Best Current Practice(RFC 9700)中已被正式弃用,主要原因如下:
- 访问令牌暴露在 URL 中: Token 出现在浏览器历史记录、服务器日志、Referer 头中,增加了泄露风险
- 无法安全使用刷新令牌: 隐式模式不返回刷新令牌,令牌过期后用户体验差
- 浏览器回退按钮问题: 用户点击回退按钮可能导致令牌在历史记录中被捕获
- 缺乏客户端认证: 无法验证客户端身份
- PKCE 使授权码模式在 SPA 中同样安全: PKCE 解决了公共客户端的安全问题,使得隐式模式失去存在意义
迁移建议: 所有使用隐式模式的应用应迁移至授权码 + PKCE 模式。
Token 交换与 Token 类型
访问令牌(Access Token)
访问令牌是客户端用于访问受保护资源的凭证,通常为 JWT 或不透明的字符串。
JWT 格式的访问令牌示例:
eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCJ9.n5nT6Q...解码后的 Payload:
{
"iss": "https://auth.example.com",
"sub": "1234567890",
"aud": "client-id-123",
"exp": 1718000000,
"iat": 1717996400,
"scope": "openid profile email",
"client_id": "CLIENT_ID"
}刷新令牌(Refresh Token)
刷新令牌用于在访问令牌过期后获取新的访问令牌,而无需用户重新授权。
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=REFRESH_TOKEN&client_id=CLIENT_ID&client_secret=CLIENT_SECRETToken 交换的安全性考虑
| 方面 | 注意事项 |
|---|---|
| 传输安全 | 始终使用 HTTPS,防止中间人攻击 |
| Token 存储 | 服务端:内存/安全存储;前端:HttpOnly Cookie(避免 localStorage) |
| 令牌有效期 | 访问令牌:短期(15 分钟 - 1 小时);刷新令牌:长期可轮换 |
| 令牌轮换 | 每次使用刷新令牌时返回新的刷新令牌,旧令牌失效 |
| 令牌吊销 | 授权服务器应提供令牌吊销端点(RFC 7009) |
OIDC(OpenID Connect)核心概念
OpenID Connect 在 OAuth 2.0 之上添加了身份认证层,使客户端能够验证用户身份并获取用户基本信息。
ID Token
ID Token 是 OIDC 的核心,使用 JWT 格式,包含身份认证信息。
header.payload.signature标准 Claims:
{
"iss": "https://auth.example.com",
"sub": "24400320",
"aud": "s6BhdRkqt3",
"exp": 1718000000,
"iat": 1717996400,
"auth_time": 1717996000,
"nonce": "n-0S6_WzA2Mj"
}| Claim | 描述 | 必需 |
|---|---|---|
iss | 签发者标识 | 是 |
sub | 用户标识符(唯一且不重复) | 是 |
aud | 受众(客户端 ID) | 是 |
exp | 过期时间 | 是 |
iat | 签发时间 | 是 |
auth_time | 用户认证时间 | 条件 |
nonce | 防止重放攻击 | 条件 |
ID Token 验证流程
- 验证 JWT 签名(使用授权服务器的 JWK Set)
- 验证
iss是否匹配预期的签发者 - 验证
aud是否包含客户端的client_id - 验证
exp未过期 - 验证
nonce(如果授权请求中包含) - 可选:验证
auth_time是否满足要求
UserInfo Endpoint
UserInfo 端点是 OIDC 提供的标准化接口,用于获取用户的详细信息。客户端使用访问令牌请求该端点。
GET /userinfo HTTP/1.1
Host: auth.example.com
Authorization: Bearer ACCESS_TOKENHTTP/1.1 200 OK
Content-Type: application/json
{
"sub": "24400320",
"name": "张三",
"given_name": "三",
"family_name": "张",
"preferred_username": "zhangsan",
"email": "zhangsan@example.com",
"picture": "https://example.com/avatar.jpg"
}Scope 体系
OIDC 定义了标准 scope 用于控制 UserInfo 的返回内容:
| Scope | 包含的 Claims |
|---|---|
openid | sub(必需) |
profile | name, given_name, family_name, picture, locale, updated_at 等 |
email | email, email_verified |
address | address(结构化地址) |
phone | phone_number, phone_number_verified |
请求示例:
scope=openid profile emailCSRF 防护(state 参数)
state 参数的作用
state 参数用于防止跨站请求伪造(CSRF)攻击,它在授权请求和回调之间维护状态的一致性。
攻击场景
攻击者诱导用户点击预先构造的授权回调链接,从而在用户不知情的情况下绑定第三方账户。
防护实现
步骤 1: 客户端生成随机 state 值并存储在本地(Session / Cookie / 内存)
// 客户端生成 state
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);步骤 2: 将 state 附加到授权请求中
GET /authorize?response_type=code&client_id=CLIENT_ID&state=GENERATED_STATE&redirect_uri=... HTTP/1.1
Host: auth.example.com步骤 3: 回调时验证 state
// 回调处理
const receivedState = new URLSearchParams(window.location.search).get('state');
const storedState = sessionStorage.getItem('oauth_state');
if (receivedState !== storedState) {
throw new Error('State mismatch - possible CSRF attack');
}nonce 与 state 的区别
- state: 用于维护授权请求与回调之间的状态,防止 CSRF
- nonce: 用于关联 ID Token 与授权请求,防止重放攻击
重定向 URI 投毒攻击
攻击原理
攻击者利用授权服务器对重定向 URI 验证不严格的问题,将授权码或令牌劫持到攻击者控制的服务器。
常见攻击向量
- 开放重定向: 授权服务器未对 redirect_uri 进行严格匹配
- 通配符滥用: 使用过于宽泛的重定向 URI 模式
- 路径遍历: 利用路径解析差异绕过验证
- Host 头注入: 通过修改 Host 头操纵重定向
攻击示例
弱验证模式:
注册的 URI: https://client.example.com/*
允许的回调: https://client.example.com.evil.com/callback严格验证模式(推荐):
注册的 URI: https://client.example.com/callback
只允许: https://client.example.com/callback防护措施
- 精确匹配: 注册完整的重定向 URI,不使用通配符
- 前缀匹配最小化: 如果必须使用前缀匹配,确保最小范围
- Host 验证: 验证回调的 Host 是否与注册的完全一致
- 禁止模式匹配: 避免使用正则表达式匹配重定向 URI
- 多个 URI 注册: 如果多个回调需要,分别注册每个,而不是使用通配
授权码拦截攻击
攻击原理
授权码拦截攻击的核心在于,攻击者窃取授权码并抢先于合法客户端完成 Token 交换。
Native 应用中的攻击向量
在 OAuth 2.0 for Native Apps(RFC 8252)出台前,原生应用常使用自定义 URI Scheme(如 myapp://callback)接收回调。攻击者可以注册相同的 Scheme 拦截授权码。
合法应用: myapp://callback
恶意应用: myapp://callback (注册相同 Scheme,OS 可能混淆)防护措施
- PKCE(首选方案): 即使授权码被拦截,攻击者没有 Code Verifier 也无法交换令牌
- 授权码绑定(DPoP / mTLS): 将授权码绑定到特定的客户端密钥
- 单次使用: 授权码只能使用一次,如果攻击者抢先使用,合法客户端的请求会失败
- 短期授权码: 授权码的默认有效期应极短(通常 10 秒内)
授权码重用检测
授权服务器应实现授权码重用检测机制:
存储状态:
- 授权码: pending(未使用)
- 首次交换请求: 标记为 used
- 二次使用同一授权码: 拒绝并吊销已颁发的所有令牌实际配置最佳实践
Spring Security OAuth 2.0 配置
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${oauth2.client-id}")
private String clientId;
@Value("${oauth2.client-secret}")
private String clientSecret;
@Value("${oauth2.issuer-uri}")
private String issuerUri;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.oauth2Login(oauth2 -> oauth2
.clientRegistrationRepository(clientRegistrationRepository())
.authorizedClientService(authorizedClientService())
)
.oauth2Client(Customizer.withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtDecoder(jwtDecoder())
)
);
return http.build();
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(
ClientRegistration.withRegistrationId("my-oauth2-client")
.clientId(clientId)
.clientSecret(clientSecret)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("openid", "profile", "email")
.authorizationUri(issuerUri + "/oauth2/authorize")
.tokenUri(issuerUri + "/oauth2/token")
.userInfoUri(issuerUri + "/userinfo")
.userNameAttributeName("sub")
.jwkSetUri(issuerUri + "/oauth2/jwks")
.clientName("My OAuth 2.0 Client")
.build()
);
}
@Bean
public JwtDecoder jwtDecoder() {
return NimbusJwtDecoder
.withJwkSetUri(issuerUri + "/oauth2/jwks")
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
}
}Node.js(Express)OAuth 2.0 实现
使用 openid-client 库实现 OAuth 2.0 + PKCE:
const { Issuer, generators } = require('openid-client');
const express = require('express');
const session = require('express-session');
const app = express();
// 配置 session
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: true,
cookie: {
secure: true,
httpOnly: true,
sameSite: 'lax'
}
}));
let client;
// 初始化 OIDC 客户端
async function init() {
const issuer = await Issuer.discover('https://auth.example.com');
client = new issuer.Client({
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
redirect_uris: ['https://localhost:3000/callback'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_basic'
});
}
// 登录入口
app.get('/login', async (req, res) => {
// 生成 PKCE 参数
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
// 存储到 session
req.session.codeVerifier = codeVerifier;
req.session.state = state;
// 构建授权 URL
const authorizationUrl = client.authorizationUrl({
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: state
});
res.redirect(authorizationUrl);
});
// 回调处理
app.get('/callback', async (req, res) => {
try {
// 验证 state
if (req.query.state !== req.session.state) {
throw new Error('State mismatch');
}
// 使用 PKCE 交换授权码
const tokenSet = await client.callback(
'https://localhost:3000/callback',
req.query,
{
code_verifier: req.session.codeVerifier,
state: req.session.state
}
);
// 验证 ID Token
const claims = tokenSet.claims();
req.session.user = {
sub: claims.sub,
name: claims.name,
email: claims.email
};
res.redirect('/dashboard');
} catch (error) {
console.error('OAuth callback error:', error);
res.status(401).send('Authentication failed');
}
});
// 获取 UserInfo
app.get('/userinfo', async (req, res) => {
const tokenSet = await client.refresh(req.session.refreshToken);
const userInfo = await client.userinfo(tokenSet.access_token);
res.json(userInfo);
});Python(FastAPI)OAuth 2.0 实现
使用 authlib 库实现 OAuth 2.0 服务端:
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import RedirectResponse
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
from starlette.middleware.sessions import SessionMiddleware
import secrets
app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key=secrets.token_urlsafe(32))
config = Config(environ={
'CLIENT_ID': 'your-client-id',
'CLIENT_SECRET': 'your-client-secret'
})
oauth = OAuth(config)
oauth.register(
name='my-oauth2-provider',
server_metadata_url='https://auth.example.com/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid profile email',
'code_challenge_method': 'S256'
}
)
@app.get('/login')
async def login(request: Request):
# 生成 state 和 nonce
state = secrets.token_urlsafe(32)
nonce = secrets.token_urlsafe(16)
request.session['oauth_state'] = state
request.session['oauth_nonce'] = nonce
redirect_uri = request.url_for('callback')
return await oauth.my_oauth2_provider.authorize_redirect(
request, redirect_uri, state=state, nonce=nonce
)
@app.get('/callback')
async def callback(request: Request):
# 验证 state
if request.query_params.get('state') != request.session.get('oauth_state'):
raise HTTPException(status_code=403, detail='State mismatch')
try:
token = await oauth.my_oauth2_provider.authorize_access_token(request)
# 验证 ID Token
user = token.get('userinfo')
if not user:
user = await oauth.my_oauth2_provider.userinfo(
token=token['access_token']
)
request.session['user'] = dict(user)
return RedirectResponse(url='/dashboard')
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
@app.get('/userinfo')
async def userinfo(request: Request):
user = request.session.get('user')
if not user:
raise HTTPException(status_code=401, detail='Not authenticated')
return user生产环境安全检查清单
| 检查项 | 说明 | 优先级 |
|---|---|---|
| HTTPS 强制 | 所有 OAuth 相关通信必须使用 HTTPS | 高 |
| PKCE 启用 | 所有公共客户端必须使用 PKCE | 高 |
| state 验证 | 授权请求和回调必须验证 state 参数 | 高 |
| redirect_uri 精确匹配 | 注册完整的回调 URI,不使用通配符 | 高 |
| 授权码单次使用 | 授权码使用后立即失效 | 高 |
| JWT 签名验证 | 验证 ID Token 和访问令牌的签名 | 高 |
| JWK Set 轮换 | 定期轮换签名密钥,客户端自动获取最新 | 中 |
| 令牌绑定(DPoP) | 将令牌绑定到特定客户端的密钥对 | 中 |
| 访问令牌短期有效 | 访问令牌有效期不超过 1 小时 | 中 |
| 刷新令牌轮换 | 每次使用后轮换刷新令牌 | 中 |
| 令牌吊销端点 | 实现 RFC 7009 令牌吊销 | 中 |
| 客户端认证加固 | 使用 mTLS 或 private_key_jwt 进行客户端认证 | 低 |
总结
OAuth 2.0 和 OIDC 构成了现代应用授权与认证的基石。随着安全威胁的不断演进,协议本身也在持续完善。理解并遵循以下核心原则,能够有效防范绝大多数已知安全风险:
- 始终使用授权码 + PKCE——不再使用隐式模式,即使是 SPA 也应通过 PKCE 使用授权码流程
- 严格验证所有参数——包括 state、redirect_uri、ID Token 的签名和 Claims
- 最小权限原则——只请求应用实际需要的 scope 和权限
- 及时轮换与吊销——使用短期令牌,实施令牌轮换和吊销机制
- 持续关注安全最佳实践——参考 OAuth 2.0 Security BCP(RFC 9700)、OAuth 2.0 for Native Apps(RFC 8252)及 OAuth 2.0 for Browser-Based Apps 等最新规范
参考资料
- RFC 6749 - The OAuth 2.0 Authorization Framework
- RFC 6750 - The OAuth 2.0 Authorization Framework: Bearer Token Usage
- RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients
- RFC 9700 - The OAuth 2.0 Security Best Current Practice
- OpenID Connect Core 1.0
- RFC 8252 - OAuth 2.0 for Native Apps
- RFC 7009 - OAuth 2.0 Token Revocation
- OAuth 2.0 for Browser-Based Apps