RFC 6749 - OAuth 2.0授权框架
文档信息
- RFC编号: 6749
- 标题: The OAuth 2.0 Authorization Framework
- 标题(中文): OAuth 2.0授权框架
- 发布日期: 2012年10月
- 作者: D. Hardt (Microsoft)
- 状态: 标准轨道 (Standards Track)
- 更新: RFC 7636 (PKCE), RFC 8252 (Native Apps)
摘要
OAuth 2.0授权框架使第三方应用能够获得对HTTP服务的有限访问权限。它通过协调资源所有者和HTTP服务之间的审批交互,或允许第三方应用代表自己获得访问权限。OAuth 2.0是OAuth 1.0的完全重写,不向后兼容。
核心概念
传统授权模式的问题
问题场景:用户想让打印服务访问云存储中的照片
传统方式(不安全):
1. 用户给打印服务提供云存储的用户名和密码
2. 打印服务使用密码访问云存储
问题:
❌ 打印服务获得了完全访问权限
❌ 打印服务可以访问所有文件,不仅是照片
❌ 用户无法撤销访问(除非改密码)
❌ 任何第三方应用泄露都会暴露密码
❌ 难以追踪谁在访问数据
OAuth 2.0方式(安全):
1. 用户在云存储授权打印服务访问
2. 云存储给打印服务颁发访问令牌
3. 打印服务使用令牌访问照片
优势:
✅ 打印服务从不知道用户密码
✅ 访问权限受限(仅照片,只读)
✅ 可以随时撤销令牌
✅ 令牌可以过期
✅ 每个应用独立授权
OAuth 2.0角色
四个核心角色
┌─────────────────────────────────────────────────┐
│ 资源所有者 (Resource Owner) │
│ (用户) │
└────────────┬────────────────────────┬───────────┘
│ │
│ (1)授权请求 │ (2)授权许可
│ │
↓ ↓
┌────────────────────┐ (3)授权许可 ┌──────────────────┐
│ 客户端 │─────────────────>│ 授权服务器 │
│ (Client) │ │ (Authorization │
│ (第三方应用) │<─────────────────│ Server) │
│ │ (4)访问令牌 │ (云存储授权) │
└────────────────────┘ └──────────────────┘
│ │
│ (5)访问令牌 │
│ │
↓ │
┌────────────────────────────────────────────────┐│
│ 资源服务器 (Resource Server) ││
│ (云存储API) ││
└────────────────────────────────────────────────┘│
│ │
│ (6)受保护的资源 │
│ │
└─────────────────────────────────────┘
角色说明:
-
Resource Owner(资源所有者) - 通常是终端用户
- 拥有受保护资源的实体
- 能够授予对资源的访问权限
-
Client(客户端) - 第三方应用
- 代表资源所有者请求访问受保护资源
- 可以是Web应用、移动应用、桌面应用等
-
Authorization Server(授权服务器)
- 认证资源所有者
- 获得授权后颁发访问令牌
-
Resource Server(资源服务器)
- 托管受保护资源
- 接受并验证访问令牌
- 响应受保护资源请求
四种授权模式
OAuth 2.0定义了四种获取访问令牌的方式:
1. 授权码模式(Authorization Code)⭐ 最安全
适用场景:Web服务器应用(有后端)
流程图:
客户端 浏览器 授权服务器 资源服务器
│ │ │ │
│ 1.重定向到授权页面 │ │ │
│─────────────────────────>│ │ │
│ │ 2.授权请求 │ │
│ │────────────────────>│ │
│ │ │ 3.显示登录页面 │
│ │<────────────────────│ │
│ │ 4.用户登录并授权 │ │
│ │────────────────────>│ │
│ │ 5.重定向+code │ │
│ 6.接收code │<────────────────────│ │
│<─────────────────────────│ │ │
│ │ │ │
│ 7.用code换token │ │
│──────────────────────────────────────────────>│ │
│ │ │ │
│ 8.返回access_token │ │
│<──────────────────────────────────────────────│ │
│ │ │ │
│ 9.使用token访问API │
│─────────────────────────────────────────────────────────────────>│
│ │ │ │
│ 10.返回受保护资源 │
│<─────────────────────────────────────────────────────────────────│
示例代码:
// 第1步:重定向到授权页面
const authUrl = new URL('https://auth.example.com/oauth/authorize');
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('client_id', 'YOUR_CLIENT_ID');
authUrl.searchParams.append('redirect_uri', 'https://yourapp.com/callback');
authUrl.searchParams.append('scope', 'read write');
authUrl.searchParams.append('state', generateRandomState());
window.location.href = authUrl.toString();
// 第2步:处理回调,接收授权码
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// 验证state防止CSRF
if (state !== req.session.state) {
return res.status(403).send('Invalid state');
}
// 第3步:用授权码换取访问令牌
const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'https://yourapp.com/callback'
})
});
const tokens = await tokenResponse.json();
// {
// access_token: "eyJhbGc...",
// token_type: "Bearer",
// expires_in: 3600,
// refresh_token: "tGzv3JOkF0XG5Qx2TlKWIA",
// scope: "read write"
// }
// 保存令牌
req.session.accessToken = tokens.access_token;
res.redirect('/dashboard');
});
// 第4步:使用访问令牌访问API
app.get('/api/photos', async (req, res) => {
const response = await fetch('https://api.example.com/photos', {
headers: {
'Authorization': `Bearer ${req.session.accessToken}`
}
});
const photos = await response.json();
res.json(photos);
});
2. 隐式模式(Implicit)⚠️ 已弃用
注意:此模式已不推荐使用,应使用"授权码+PKCE"代替。
原始用途:单页应用(SPA)
问题:
- ❌ 令牌在URL中暴露
- ❌ 无刷新令牌
- ❌ 容易受到令牌泄露
流程(仅供了解):
客户端 → 授权服务器: 授权请求
授权服务器 → 客户端: 直接返回access_token(在URL片段中)
现代替代方案:使用授权码模式 + PKCE(RFC 7636)
3. 资源所有者密码凭证模式(Password)⚠️ 谨慎使用
适用场景:高度可信的应用(如官方应用)
流程:
客户端 → 授权服务器: 直接发送username + password
授权服务器 → 客户端: 返回access_token
示例:
const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'password',
username: '[email protected]',
password: 'user_password',
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET',
scope: 'read write'
})
});
const tokens = await tokenResponse.json();
使用限制:
- ⚠️ 仅用于第一方应用
- ⚠️ 用户必须绝对信任客户端
- ⚠️ 不建议用于第三方集成
- ⚠️ 应尽快迁移到其他模式
4. 客户端凭证模式(Client Credentials)
适用场景:机器对机器(M2M)通信,无用户参与
流程:
客户端 → 授权服务器: client_id + client_secret
授权服务器 → 客户端: access_token
示例:
// 微服务间调用
const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'api.read api.write'
})
});
const { access_token } = await tokenResponse.json();
// 使用令牌调用API
const apiResponse = await fetch('https://api.example.com/data', {
headers: {
'Authorization': `Bearer ${access_token}`
}
});
适用场景:
- ✅ 后端服务间调用
- ✅ CLI工具
- ✅ 守护进程
- ✅ 定时任务
刷新令牌(Refresh Token)
为什么需要刷新令牌?
问题:访问令牌会过期(通常1小时)
过期后用户需要重新登录?
解决方案:使用刷新令牌获取新的访问令牌
无需用户再次授权!
访问令牌:短期有效(1小时),用于访问API
刷新令牌:长期有效(数周/数月),仅用于获取新访问令牌
刷新令牌流程
客户端 授权服务器
│ │
│ 使用access_token访问API │
│ ────────────> [过期!] │
│ │
│ POST /token │
│ grant_type=refresh_token │
│ refresh_token=xxx │
│─────────────────────────────────────>│
│ │
│ 新的access_token │
│ 新的refresh_token (可选)│
│<─────────────────────────────────────│
│ │
│ 使用新token继续访问API │
│ ────────────> [成功!] │
实现示例
class OAuth2Client {
constructor(config) {
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = null;
this.config = config;
}
async getAccessToken() {
// 检查令牌是否需要刷新
if (this.needsRefresh()) {
await this.refreshAccessToken();
}
return this.accessToken;
}
needsRefresh() {
if (!this.accessToken) return true;
if (!this.expiresAt) return false;
// 提前5分钟刷新
const buffer = 5 * 60 * 1000;
return Date.now() >= (this.expiresAt - buffer);
}
async refreshAccessToken() {
if (!this.refreshToken) {
throw new Error('No refresh token available');
}
const response = await fetch(this.config.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': 'Basic ' + btoa(
`${this.config.clientId}:${this.config.clientSecret}`
)
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken
})
});
if (!response.ok) {
// 刷新失败,清除令牌,需要重新授权
this.clearTokens();
throw new Error('Token refresh failed');
}
const tokens = await response.json();
this.updateTokens(tokens);
}
updateTokens(tokens) {
this.accessToken = tokens.access_token;
if (tokens.refresh_token) {
this.refreshToken = tokens.refresh_token;
}
if (tokens.expires_in) {
this.expiresAt = Date.now() + (tokens.expires_in * 1000);
}
// 持久化存储
this.saveTokens();
}
async apiCall(url, options = {}) {
const token = await this.getAccessToken();
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
// 处理401,尝试刷新后重试
if (response.status === 401) {
await this.refreshAccessToken();
return this.apiCall(url, options);
}
return response;
}
}
// 使用
const client = new OAuth2Client({
clientId: 'YOUR_CLIENT_ID',
clientSecret: 'YOUR_CLIENT_SECRET',
tokenUrl: 'https://auth.example.com/oauth/token'
});
// 自动处理令牌刷新
const response = await client.apiCall('https://api.example.com/data');
访问令牌类型
Bearer Token(最常用)
GET /api/resource HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
特点:
- 简单易用
- 任何拥有令牌的人都可以使用(Bearer = "持有者")
- 必须通过HTTPS传输
- 应设置合理的过期时间
MAC Token(较少使用)
使用消息认证码(MAC)保护令牌:
GET /api/resource HTTP/1.1
Host: api.example.com
Authorization: MAC id="h480djs93hd8",
ts="137131200",
nonce="dj83hs9s",
mac="bhCQXTVyfj5cmA9uKkPFx1zeOXM="
作用域(Scope)
作用域用于限制访问权限:
// 请求特定权限
const authUrl = new URL('https://auth.example.com/oauth/authorize');
authUrl.searchParams.append('scope', 'read:photos write:photos delete:photos');
// 用户可以批准部分权限
// 返回的令牌可能只有: "read:photos write:photos"
常见作用域设计:
资源:操作模式:
- read:photos 只读照片
- write:photos 创建/修改照片
- delete:photos 删除照片
角色模式:
- admin 管理员权限
- user 普通用户权限
- guest 访客权限
OpenID Connect:
- openid 必需,表示OpenID Connect请求
- profile 访问用户基本信息
- email 访问用户邮箱
- address 访问用户地址
错误处理
授权错误
HTTP/1.1 302 Found
Location: https://client.example.com/callback?
error=access_denied
&error_description=The%20user%20denied%20access
&state=xyz
常见错误码:
| 错误码 | 说明 |
|---|---|
invalid_request | 请求缺少必需参数或参数无效 |
unauthorized_client | 客户端无权使用此授权模式 |
access_denied | 资源所有者拒绝授权 |
unsupported_response_type | 不支持的响应类型 |
invalid_scope | 请求的作用域无效或未知 |
server_error | 服务器内部错误 |
temporarily_unavailable | 服务器暂时不可用 |
令牌错误
{
"error": "invalid_grant",
"error_description": "The authorization code has expired"
}
常见令牌错误:
| 错误码 | 说明 |
|---|---|
invalid_request | 请求格式错误 |
invalid_client | 客户端认证失败 |
invalid_grant | 授权码/刷新令牌无效或过期 |
unauthorized_client | 客户端无权使用此授权模式 |
unsupported_grant_type | 不支持的授权类型 |
API调用错误
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="example",
error="invalid_token",
error_description="The access token expired"
安全最佳实践
1. 使用HTTPS
⚠️ OAuth 2.0必须在HTTPS上运行
原因:
- 令牌在HTTP头部传输
- 防止中间人攻击
- 保护用户凭证
2. 验证重定向URI
// 服务器端严格验证
const allowedRedirectUris = [
'https://app.example.com/callback',
'https://app.example.com/oauth/callback'
];
if (!allowedRedirectUris.includes(requestedRedirectUri)) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'Invalid redirect_uri'
});
}
3. 使用State参数防止CSRF
// 生成并存储state
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;
// 授权请求包含state
const authUrl = `...&state=${state}`;
// 回调时验证
if (req.query.state !== req.session.oauthState) {
throw new Error('CSRF attack detected');
}
4. 限制令牌作用域
// 只请求必要的权限
scope: 'read:user read:repo' // ✅ 最小权限
scope: 'admin' // ❌ 过度权限
5. 安全存储令牌
// ✅ 服务器端:加密存储
const encryptedToken = encrypt(accessToken, ENCRYPTION_KEY);
await db.saveToken(userId, encryptedToken);
// ✅ 浏览器:httpOnly cookie
res.cookie('token', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 3600000
});
// ❌ 避免:localStorage(易受XSS攻击)
localStorage.setItem('token', accessToken);
6. 定期轮换刷新令牌
// 每次刷新都返回新的refresh_token
{
"access_token": "new_access_token",
"refresh_token": "new_refresh_token", // 新的!
"expires_in": 3600
}
// 旧的refresh_token失效
完整实现示例
Express + Passport OAuth2
const express = require('express');
const passport = require('passport');
const OAuth2Strategy = require('passport-oauth2');
const session = require('express-session');
const app = express();
// 配置session
app.use(session({
secret: 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true }
}));
app.use(passport.initialize());
app.use(passport.session());
// 配置OAuth2策略
passport.use('oauth2', new OAuth2Strategy({
authorizationURL: 'https://auth.example.com/oauth/authorize',
tokenURL: 'https://auth.example.com/oauth/token',
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: 'https://yourapp.com/auth/callback',
scope: ['read', 'write']
},
async (accessToken, refreshToken, profile, done) => {
try {
// 获取用户信息
const user = await getUserProfile(accessToken);
// 保存令牌
await saveTokens(user.id, {
accessToken,
refreshToken,
expiresAt: Date.now() + 3600000
});
done(null, user);
} catch (error) {
done(error);
}
}
));
// 序列化用户
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await findUserById(id);
done(null, user);
} catch (error) {
done(error);
}
});
// 路由
app.get('/auth/login', passport.authenticate('oauth2'));
app.get('/auth/callback',
passport.authenticate('oauth2', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
app.get('/auth/logout', (req, res) => {
req.logout(() => {
res.redirect('/');
});
});
// 受保护的路由
app.get('/api/data', ensureAuthenticated, async (req, res) => {
const tokens = await getTokens(req.user.id);
// 检查令牌是否过期
if (Date.now() >= tokens.expiresAt) {
tokens = await refreshAccessToken(tokens.refreshToken);
}
// 调用API
const response = await fetch('https://api.example.com/data', {
headers: {
'Authorization': `Bearer ${tokens.accessToken}`
}
});
const data = await response.json();
res.json(data);
});
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect('/auth/login');
}
app.listen(3000);
总结
OAuth 2.0是现代Web授权的标准:
- 信: 准确实现安全授权
- 达: 清晰的四种授权模式
- 雅: 优雅的令牌机制
核心价值:
- 🔐 无需共享密码
- 📱 支持多种客户端类型
- ⚖️ 细粒度权限控制
- 🔄 令牌刷新机制
- 🛡️ 可撤销的访问权限
选择指南:
- Web应用: 授权码模式 ✅
- 移动应用: 授权码模式 + PKCE ✅
- SPA: 授权码模式 + PKCE ✅
- 服务间: 客户端凭证模式 ✅
- 第一方: 密码模式 ⚠️
- 任何场景: 隐式模式 ❌ 已弃用
相关RFC:
- RFC 6749: OAuth 2.0核心(本文档)
- RFC 6750: OAuth 2.0 Bearer Token
- RFC 7636: PKCE扩展
- RFC 8252: OAuth for Native Apps
- RFC 7519: JWT