OAuth 授权码模式 #
概述 #
授权码模式(Authorization Code Grant)是 OAuth 2.0 中最完整、最安全的授权方式。它通过授权码作为中间凭证,确保访问令牌不会直接暴露给前端,是服务器端应用的首选授权方式。
text
┌─────────────────────────────────────────────────────────────┐
│ 授权码模式特点 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✅ 安全性最高 │
│ - 令牌不暴露给前端 │
│ - 需要客户端认证 │
│ │
│ ✅ 功能完整 │
│ - 支持刷新令牌 │
│ - 支持长期访问 │
│ │
│ ✅ 适用广泛 │
│ - Web 服务器应用 │
│ - 移动应用(配合 PKCE) │
│ - 单页应用(配合 PKCE) │
│ │
└─────────────────────────────────────────────────────────────┘
授权流程 #
完整流程图 #
text
┌─────────────────────────────────────────────────────────────┐
│ 授权码模式流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ │ 用户 │ │ 客户端 │ │ 授权服务│ │ 资源服务│
│ │Resource │ │ Client │ │ Auth │ │ Resource│
│ │ Owner │ │ │ │ Server │ │ Server │
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │ │
│ │ (A) 用户点击登录 │ │
│ │──────────────>│ │ │
│ │ │ │ │
│ │ │ (B) 重定向到授权端点 │
│ │ │ 请求授权码 │ │
│ │<──────────────────────────────│ │
│ │ │ │ │
│ │ (C) 用户认证并授权 │ │
│ │──────────────────────────────>│ │
│ │ │ │ │
│ │ │ (D) 重定向回客户端 │
│ │ │ 携带授权码 │ │
│ │ │<──────────────│ │
│ │ │ │ │
│ │ │ (E) 用授权码换令牌 │
│ │ │──────────────>│ │
│ │ │ │ │
│ │ │ (F) 返回访问令牌 │
│ │ │<──────────────│ │
│ │ │ │ │
│ │ │ (G) 使用令牌访问资源 │
│ │ │──────────────────────────────>│
│ │ │ │ │
│ │ │ (H) 返回受保护资源 │
│ │ │<──────────────────────────────│
│ │ │ │ │
│ │ (I) 登录成功 │ │ │
│ │<──────────────│ │ │
│ │ │ │ │
└─────────────────────────────────────────────────────────────┘
步骤详解 #
步骤 A:用户发起登录 #
text
用户在客户端应用点击"使用 XX 登录"按钮
步骤 B:重定向到授权端点 #
客户端将用户重定向到授权服务器的授权端点:
http
GET /authorize?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
&scope=profile%20email
&state=xyzABC123 HTTP/1.1
Host: auth.example.com
参数说明:
| 参数 | 必需 | 描述 |
|---|---|---|
| response_type | 是 | 固定值 code |
| client_id | 是 | 客户端标识 |
| redirect_uri | 是 | 回调地址,必须与注册时一致 |
| scope | 推荐 | 请求的授权范围 |
| state | 推荐 | 防 CSRF 的随机值 |
步骤 C:用户认证并授权 #
text
授权服务器:
1. 检查用户是否已登录
2. 如未登录,显示登录页面
3. 显示授权确认页面
4. 用户同意授权
步骤 D:返回授权码 #
授权服务器重定向回客户端,携带授权码:
http
HTTP/1.1 302 Found
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA&state=xyzABC123
步骤 E:用授权码换取令牌 #
客户端在后台使用授权码获取令牌:
http
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
步骤 F:返回访问令牌 #
授权服务器返回令牌:
json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"scope": "profile email"
}
步骤 G-H:访问受保护资源 #
http
GET /api/userinfo HTTP/1.1
Host: resource.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
授权请求详解 #
构建授权 URL #
javascript
function buildAuthorizationUrl(config) {
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scope,
state: generateState()
});
return `${config.authorizationEndpoint}?${params.toString()}`;
}
const authUrl = buildAuthorizationUrl({
authorizationEndpoint: 'https://auth.example.com/authorize',
clientId: 's6BhdRkqt3',
redirectUri: 'https://client.example.com/callback',
scope: 'profile email'
});
State 参数 #
text
┌─────────────────────────────────────────────────────────────┐
│ State 参数处理 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 生成 State: │
│ - 使用加密安全的随机数生成器 │
│ - 长度至少 16 字节 │
│ - 绑定到用户会话 │
│ │
│ 存储 State: │
│ - 存储在服务器会话中 │
│ - 或使用签名/加密的 JWT │
│ │
│ 验证 State: │
│ - 比较返回的 state 与存储的值 │
│ - 验证后立即删除 │
│ - 不匹配则拒绝请求 │
│ │
└─────────────────────────────────────────────────────────────┘
State 生成示例 #
javascript
const crypto = require('crypto');
function generateState() {
return crypto.randomBytes(32).toString('hex');
}
function storeState(session, state) {
session.oauthState = state;
session.oauthStateExpiry = Date.now() + 10 * 60 * 1000;
}
function validateState(session, state) {
if (!session.oauthState) {
return false;
}
if (Date.now() > session.oauthStateExpiry) {
return false;
}
const isValid = session.oauthState === state;
delete session.oauthState;
delete session.oauthStateExpiry;
return isValid;
}
可选参数 #
text
┌─────────────────────────────────────────────────────────────┐
│ 可选授权参数 │
├─────────────────────────────────────────────────────────────┤
│ │
│ prompt:控制授权页面显示 │
│ ├── none:不显示授权页面,静默授权 │
│ ├── login:强制重新登录 │
│ ├── consent:强制显示授权同意页面 │
│ └── select_account:让用户选择账户 │
│ │
│ login_hint:预填充用户名/邮箱 │
│ login_hint=user@example.com │
│ │
│ acr_values:指定认证要求 │
│ acr_values=urn:mace:incommon:iap:silver │
│ │
│ ui_locales:指定界面语言 │
│ ui_locales=zh-CN │
│ │
│ display:指定显示模式 │
│ ├── page:完整页面(默认) │
│ ├── popup:弹出窗口 │
│ ├── touch:触摸设备优化 │
│ └── wap:移动设备优化 │
│ │
└─────────────────────────────────────────────────────────────┘
令牌请求详解 #
基本请求 #
http
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
&client_id=s6BhdRkqt3
&client_secret=7Fjfp0ZBr1KtDRbnfVdmIw
客户端认证方式 #
方式一:HTTP Basic 认证(推荐) #
http
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
javascript
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authCode,
redirect_uri: redirectUri
})
});
方式二:请求体参数 #
http
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcb
&client_id=s6BhdRkqt3
&client_secret=7Fjfp0ZBr1KtDRbnfVdmIw
令牌响应 #
成功响应 #
json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"scope": "profile email"
}
响应字段:
| 字段 | 描述 |
|---|---|
| access_token | 访问令牌 |
| token_type | 令牌类型,通常为 Bearer |
| expires_in | 令牌有效期(秒) |
| refresh_token | 刷新令牌(可选) |
| scope | 授予的权限范围 |
错误响应 #
json
{
"error": "invalid_grant",
"error_description": "The provided authorization code is invalid or expired."
}
完整实现示例 #
Node.js + Express 实现 #
javascript
const express = require('express');
const crypto = require('crypto');
const session = require('express-session');
const app = express();
app.use(session({
secret: 'your-session-secret',
resave: false,
saveUninitialized: false
}));
const config = {
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
authorizationEndpoint: 'https://auth.example.com/authorize',
tokenEndpoint: 'https://auth.example.com/token',
redirectUri: 'https://your-app.example.com/callback',
scope: 'profile email'
};
app.get('/login', (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
response_type: 'code',
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scope,
state: state
});
res.redirect(`${config.authorizationEndpoint}?${params}`);
});
app.get('/callback', async (req, res) => {
const { code, state, error } = req.query;
if (error) {
return res.status(400).send(`授权失败: ${error}`);
}
if (!state || state !== req.session.oauthState) {
return res.status(400).send('无效的 state 参数');
}
delete req.session.oauthState;
try {
const tokenResponse = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + Buffer.from(
`${config.clientId}:${config.clientSecret}`
).toString('base64')
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: config.redirectUri
})
});
if (!tokenResponse.ok) {
throw new Error('令牌请求失败');
}
const tokens = await tokenResponse.json();
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
res.redirect('/dashboard');
} catch (err) {
res.status(500).send('授权失败');
}
});
app.listen(3000);
Python + Flask 实现 #
python
from flask import Flask, request, redirect, session
import requests
import secrets
import base64
app = Flask(__name__)
app.secret_key = 'your-secret-key'
config = {
'client_id': 'your-client-id',
'client_secret': 'your-client-secret',
'authorization_endpoint': 'https://auth.example.com/authorize',
'token_endpoint': 'https://auth.example.com/token',
'redirect_uri': 'https://your-app.example.com/callback',
'scope': 'profile email'
}
@app.route('/login')
def login():
state = secrets.token_hex(32)
session['oauth_state'] = state
params = {
'response_type': 'code',
'client_id': config['client_id'],
'redirect_uri': config['redirect_uri'],
'scope': config['scope'],
'state': state
}
auth_url = f"{config['authorization_endpoint']}?{urlencode(params)}"
return redirect(auth_url)
@app.route('/callback')
def callback():
code = request.args.get('code')
state = request.args.get('state')
error = request.args.get('error')
if error:
return f'授权失败: {error}', 400
if not state or state != session.get('oauth_state'):
return '无效的 state 参数', 400
session.pop('oauth_state', None)
credentials = base64.b64encode(
f"{config['client_id']}:{config['client_secret']}".encode()
).decode()
token_response = requests.post(
config['token_endpoint'],
headers={
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': f'Basic {credentials}'
},
data={
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': config['redirect_uri']
}
)
if not token_response.ok:
return '授权失败', 500
tokens = token_response.json()
session['access_token'] = tokens['access_token']
session['refresh_token'] = tokens.get('refresh_token')
return redirect('/dashboard')
if __name__ == '__main__':
app.run(port=3000)
安全特性 #
为什么授权码模式最安全? #
text
┌─────────────────────────────────────────────────────────────┐
│ 授权码模式安全机制 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 令牌不暴露给前端 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 授权码通过前端传递 │ │
│ │ 令牌在后端服务器获取 │ │
│ │ 前端永远看不到令牌 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 2. 客户端认证 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 获取令牌需要 client_secret │ │
│ │ 只有合法客户端才能获取令牌 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 3. 授权码一次性使用 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 授权码使用后立即失效 │ │
│ │ 防止重放攻击 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 4. 授权码有效期短 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 通常只有几分钟有效期 │ │
│ │ 减少被截获后的风险 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 5. redirect_uri 验证 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 必须与注册时一致 │ │
│ │ 防止授权码被发送到攻击者 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 6. state 参数防 CSRF │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 验证请求来源 │ │
│ │ 防止跨站请求伪造 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
安全最佳实践 #
text
1. 始终使用 HTTPS
- 所有通信必须加密
- 防止中间人攻击
2. 验证 state 参数
- 每次请求生成新的 state
- 验证后立即删除
3. 严格验证 redirect_uri
- 完全匹配注册的 URI
- 不允许通配符
4. 使用短期授权码
- 有效期不超过 10 分钟
- 使用后立即失效
5. 安全存储 client_secret
- 不要提交到代码仓库
- 使用环境变量或密钥管理服务
6. 访问令牌短期有效
- 有效期不超过 1 小时
- 使用刷新令牌获取新令牌
7. 实现令牌撤销
- 用户可以撤销授权
- 令牌泄露时可以撤销
刷新令牌 #
刷新流程 #
text
┌─────────────────────────────────────────────────────────────┐
│ 刷新令牌流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 访问令牌过期 │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 检测令牌过期 │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 使用刷新令牌 │ │
│ │ 请求新令牌 │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ 获取新令牌 │ │
│ │ 继续访问资源 │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
刷新令牌请求 #
http
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=8xLOxBtZp8
刷新令牌实现 #
javascript
async function refreshAccessToken(refreshToken) {
const response = await fetch(config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + Buffer.from(
`${config.clientId}:${config.clientSecret}`
).toString('base64')
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken
})
});
if (!response.ok) {
throw new Error('刷新令牌失败');
}
return await response.json();
}
async function fetchWithTokenRefresh(url, accessToken, refreshToken) {
let response = await fetch(url, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (response.status === 401) {
const newTokens = await refreshAccessToken(refreshToken);
response = await fetch(url, {
headers: {
'Authorization': `Bearer ${newTokens.access_token}`
}
});
}
return response;
}
常见问题 #
问题一:授权码无效 #
text
原因:
- 授权码已使用
- 授权码已过期
- redirect_uri 不匹配
- 客户端认证失败
解决:
- 确保授权码只使用一次
- 在有效期内使用授权码
- 确保 redirect_uri 完全匹配
- 检查 client_id 和 client_secret
问题二:State 验证失败 #
text
原因:
- Session 丢失
- State 过期
- CSRF 攻击
解决:
- 确保 Session 正确配置
- 设置合理的 State 过期时间
- 使用安全的 State 存储方式
问题三:刷新令牌无效 #
text
原因:
- 刷新令牌已过期
- 刷新令牌已被撤销
- 用户修改了密码
解决:
- 重新进行授权流程
- 提示用户重新登录
授权码模式 vs 其他模式 #
| 特性 | 授权码模式 | 简化模式 | 密码模式 | 客户端凭证 |
|---|---|---|---|---|
| 安全性 | 最高 | 低 | 中 | 高 |
| 刷新令牌 | 支持 | 不支持 | 支持 | 不支持 |
| 用户参与 | 需要 | 需要 | 需要 | 不需要 |
| 客户端认证 | 需要 | 不需要 | 需要 | 需要 |
| 适用场景 | Web 服务器 | SPA(已弃用) | 第一方应用 | 服务间通信 |
| 推荐程度 | 推荐 | 不推荐 | 谨慎使用 | 推荐 |
下一步 #
现在你已经掌握了授权码模式,接下来学习 PKCE 扩展,了解如何让授权码模式在移动端和单页应用中更安全!
最后更新:2026-03-28