**更新时间:** 2026-03-27
**阅读时间:** 约 15 分钟
**分类:** 安全与远程访问
---
OAuth2.0(Open Authorization 2.0)是一个开放标准的授权协议,允许第三方应用在不获取用户密码的情况下,安全地访问用户在资源服务器上的受保护资源。
简单来说,OAuth2.0 解决了这样一个问题:**如何让用户安全地授权第三方应用访问自己的数据,而无需把密码告诉对方?**
---
理解 OAuth2.0 之前,需要先了解协议中的四个核心角色:
通常是**用户**,拥有受保护资源的控制权,可以授权第三方访问这些资源。
**第三方应用程序**,希望代表用户访问受保护资源。例如:一个想要读取用户 GitHub 仓库列表的 Web 应用。
存储受保护资源的服务器,能够接受访问令牌并提供资源。例如:GitHub API 服务器。
在资源所有者成功认证并获得授权后,向客户端颁发访问令牌的服务器。通常与资源服务器是同一个提供商。
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 用户 │────▶│ 客户端 │────▶│ 资源服务器 │
│ (Resource │ │ (Client) │ │(Resource │
│ Owner) │ │ │ │ Server) │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ 授权服务器 │
│(Auth Server)│
└─────────────┘
---
OAuth2.0 定义了四种授权模式(Grant Types),适用于不同的应用场景:
授权码模式完整流程
sequenceDiagram
participant U as 用户
participant CA as 客户端应用
participant AS as 授权服务器
participant RS as 资源服务器
U->>CA: 点击"使用XX登录"
CA->>U: 重定向到 AS
Note over U: https://auth.example.com/authorize
?client_id=xxx
&redirect_uri=callback
&response_type=code
&scope=profile
U->>AS: 在授权服务器登录并授权
AS->>U: 授权确认页面
U->>AS: 用户同意授权
AS->>CA: 重定向到 callback
?code=AUTH_CODE_xxx
CA->>AS: 后端请求 Token
code=AUTH_CODE&client_secret=xxx
AS->>CA: {access_token, refresh_token, expires_in}
CA->>RS: 请求资源
Authorization: Bearer xxx
RS->>CA: 受保护资源
**最常用、最安全的模式**,适用于有后端服务器的 Web 应用。
用户点击"使用 GitHub 登录"
客户端 → 用户浏览器
重定向到授权服务器
用户浏览器 → 授权服务器
GET /authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=read:user
&state=RANDOM_STATE
用户登录并授权
用户 ↔ 授权服务器
重定向回客户端,带上授权码
授权服务器 → 用户浏览器 → 客户端
GET /callback?code=AUTHORIZATION_CODE
&state=RANDOM_STATE
用授权码换取访问令牌
客户端 → 授权服务器(后端通信)
POST /token
{
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "REDIRECT_URI",
"client_id": "CLIENT_ID",
"client_secret": "CLIENT_SECRET"
}
返回访问令牌
授权服务器 → 客户端
{
"access_token": "ACCESS_TOKEN",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN",
"scope": "read:user"
}
使用访问令牌访问资源
客户端 → 资源服务器
GET /user
Authorization: Bearer ACCESS_TOKEN
const express = require('express');
const axios = require('axios');
const crypto = require('crypto');
const app = express();
// 配置
const GITHUB_CLIENT_ID = 'your_client_id';
const GITHUB_CLIENT_SECRET = 'your_client_secret';
const REDIRECT_URI = 'http://localhost:3000/callback';
// 生成随机 state 用于防 CSRF
function generateState() {
return crypto.randomBytes(16).toString('hex');
}
// 1. 重定向用户到 GitHub 授权页面
app.get('/login', (req, res) => {
const state = generateState();
// 在实际应用中,应该将 state 存储到 session 或 cookie 中
res.cookie('oauth_state', state);
const authUrl = https://github.com/login/oauth/authorize? +
client_id=${GITHUB_CLIENT_ID}& +
redirect_uri=${encodeURIComponent(REDIRECT_URI)}& +
scope=read:user& +
state=${state};
res.redirect(authUrl);
});
// 2. 处理回调,用授权码换取访问令牌
app.get('/callback', async (req, res) => {
const { code, state } = req.query;
// 验证 state 防止 CSRF
if (state !== req.cookies.oauth_state) {
return res.status(400).send('Invalid state');
}
try {
// 用授权码换取访问令牌
const tokenResponse = await axios.post(
'https://github.com/login/oauth/access_token',
{
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code: code,
redirect_uri: REDIRECT_URI
},
{
headers: {
'Accept': 'application/json'
}
}
);
const { access_token, token_type, scope } = tokenResponse.data;
// 使用访问令牌获取用户信息
const userResponse = await axios.get('https://api.github.com/user', {
headers: {
'Authorization': ${token_type} ${access_token}
}
});
res.json({
message: '登录成功',
user: userResponse.data,
scope: scope
});
} catch (error) {
res.status(500).send('授权失败:' + error.message);
}
});
app.listen(3000, () => {
console.log('服务器运行在 http://localhost:3000');
});
---
**已不推荐使用**,适用于纯前端应用(无后端服务器)。由于安全性问题,OAuth2.1 已废弃此模式。
重定向用户到授权服务器
用户浏览器 → 授权服务器
GET /authorize?response_type=token
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&scope=read:user
用户登录并授权
重定向回客户端,直接在 URL 中带上访问令牌
授权服务器 → 用户浏览器
#access_token=ACCESS_TOKEN
&token_type=bearer
&expires_in=3600
对于纯前端应用(SPA),推荐使用**授权码模式 + PKCE**(见下文)。
---
**仅限高度可信的应用**,例如操作系统或设备厂商的官方应用。
用户输入用户名和密码到客户端
用户 → 客户端
客户端直接用密码换取令牌
客户端 → 授权服务器
POST /token
{
"grant_type": "password",
"username": "USER@example.com",
"password": "USER_PASSWORD",
"client_id": "CLIENT_ID",
"client_secret": "CLIENT_SECRET"
}
返回访问令牌
---
**适用于机器对机器的认证**,不涉及用户授权。
客户端直接请求令牌
客户端 → 授权服务器
POST /token
{
"grant_type": "client_credentials",
"client_id": "CLIENT_ID",
"client_secret": "CLIENT_SECRET"
}
返回访问令牌
{
"access_token": "ACCESS_TOKEN",
"token_type": "bearer",
"expires_in": 3600
}
const axios = require('axios');
async function getClientCredentialsToken() {
const response = await axios.post(
'https://api.example.com/oauth2/token',
{
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET
}
);
return response.data.access_token;
}
// 使用令牌调用 API
async function callProtectedAPI() {
const token = await getClientCredentialsToken();
const response = await axios.get(
'https://api.example.com/protected/resource',
{
headers: {
'Authorization': Bearer ${token}
}
}
);
return response.data;
}
---
**PKCE**(Proof Key for Code Exchange,RFC 7636)是授权码模式的扩展,专为移动应用和单页应用(SPA)设计,防止授权码拦截攻击。
PKCE 流程(防止授权码拦截)
sequenceDiagram
participant C as 客户端
participant AS as 授权服务器
C->>C: 生成 code_verifier (随机字符串)
C->>C: 计算 code_challenge
= BASE64URL(SHA256(code_verifier))
C->>AS: authorize?
code_challenge=xxx
&code_challenge_method=S256
AS->>C: 重定向用户
C->>AS: 提交授权
code=xxx
C->>AS: token request
code_verifier=xxx
AS->>AS: 验证 code_challenge
AS->>C: access_token
Note over C,AS: 即使授权码被拦截
攻击者没有 code_verifier
无法获取 Token
在移动应用或 SPA 中,无法安全存储 client_secret。攻击者可能:
PKCE 通过添加动态生成的密钥对来防止这种攻击。
客户端生成 code_verifier 和 code_challenge
code_verifier = 随机字符串(43-128 字符)
code_challenge = SHA256(code_verifier) 的 Base64URL 编码
重定向到授权服务器,带上 code_challenge
GET /authorize?response_type=code
&client_id=CLIENT_ID
&redirect_uri=REDIRECT_URI
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
用户授权,返回授权码
用授权码和 code_verifier 换取令牌
POST /token
{
"grant_type": "authorization_code",
"code": "AUTHORIZATION_CODE",
"redirect_uri": "REDIRECT_URI",
"client_id": "CLIENT_ID",
"code_verifier": "CODE_VERIFIER"
}
授权服务器验证 code_verifier
计算 SHA256(code_verifier) 并与之前收到的 code_challenge 比较
匹配则颁发令牌
// 生成 code_verifier(43-128 字符的随机字符串)
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.substring(0, 128);
}
// 生成 code_challenge
async function generateCodeChallenge(verifier) {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
// 使用示例
async function loginWithPKCE() {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// 存储 code_verifier(sessionStorage 或内存)
sessionStorage.setItem('code_verifier', codeVerifier);
// 重定向到授权服务器
const authUrl = https://auth.example.com/authorize? +
response_type=code& +
client_id=CLIENT_ID& +
redirect_uri=${encodeURIComponent(REDIRECT_URI)}& +
code_challenge=${codeChallenge}& +
code_challenge_method=S256;
window.location.href = authUrl;
}
// 回调处理
async function handleCallback(code) {
const codeVerifier = sessionStorage.getItem('code_verifier');
const response = await fetch('https://auth.example.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: REDIRECT_URI,
client_id: 'CLIENT_ID',
code_verifier: codeVerifier
})
});
const data = await response.json();
return data.access_token;
}
---
Access Token vs Refresh Token
flowchart LR
subgraph AT["Access Token"]
A1["短期(通常15min-1h)"]
A2["包含权限范围"]
A3["每次API请求携带"]
A4["无状态,可自验证"]
end
subgraph RT["Refresh Token"]
R1["长期(数天到数月)"]
R2["仅用于获取新 Access Token"]
R3["安全存储在后端"]
R4["支持 Token 轮换"]
end
AT -->|用途| API["访问 API"]
RT -->|用途| 获取新AT
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
访问令牌过期,API 返回 401
资源服务器 → 客户端
HTTP 401 Unauthorized
用刷新令牌换取新的访问令牌
客户端 → 授权服务器
POST /token
{
"grant_type": "refresh_token",
"refresh_token": "REFRESH_TOKEN",
"client_id": "CLIENT_ID",
"client_secret": "CLIENT_SECRET"
}
返回新的访问令牌(和可选的新刷新令牌)
{
"access_token": "NEW_ACCESS_TOKEN",
"token_type": "bearer",
"expires_in": 3600,
"refresh_token": "NEW_REFRESH_TOKEN"
}
---
所有 OAuth2.0 通信必须通过 HTTPS 进行,防止中间人攻击和令牌窃听。
始终生成随机 state 参数并在回调时验证,防止 CSRF 攻击。
// 生成随机 state
const state = crypto.randomBytes(16).toString('hex');
// 存储到 session
session.oauth_state = state;
// 回调时验证
if (req.query.state !== session.oauth_state) {
throw new Error('Invalid state');
}
只请求必要的 scope,不要过度授权。
不好 - 请求所有权限
scope=read:user+write:user+delete:user
好 - 只请求需要的权限
scope=read:user
每次使用刷新令牌时,颁发新的刷新令牌并使旧的失效。这可以检测令牌泄露。
提供用户撤销第三方应用授权的入口,并在授权服务器端实现令牌黑名单。
---
**错误信息**
{
"error": "redirect_uri_mismatch",
"error_description": "The redirect URI provided does not match registered URI(s)."
}
**原因** - 回调 URL 与在授权服务器注册的 URL 不一致
**解决方案**
redirect_uri 参数完全匹配(包括 http/https、端口、路径)http://localhost:3000/callback**错误信息**
{
"error": "invalid_grant",
"error_description": "Authorization code has been used or expired."
}
**原因** - 授权码是一次性的,重复使用会失败
**解决方案**
**风险** - 攻击者可以用 client_secret 冒充你的应用
**解决方案**
// 不好 - 硬编码在代码中
const CLIENT_SECRET = 'ghp_xxxxxxxxxxxx';
// 好 - 使用环境变量
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
**错误信息**
Access to fetch at 'https://auth.example.com/token' from origin 'http://localhost:3000'
has been blocked by CORS policy
**原因** - 浏览器阻止跨域请求
**解决方案**
---
- Application name: 你的应用名称
- Homepage URL: https://yourdomain.com
- Authorization callback URL: https://yourdomain.com/callback
完整代码示例见上文"授权码模式"部分。
GitHub 返回的用户信息示例:
{
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"name": "monalisa octocat",
"company": "GitHub",
"blog": "https://github.com/blog",
"location": "San Francisco",
"email": "octocat@github.com",
"hireable": false,
"bio": "There once was...",
"twitter_username": "monatheoctocat",
"public_repos": 2,
"public_gists": 1,
"followers": 20,
"following": 0,
"created_at": "2008-01-14T04:33:35Z",
"updated_at": "2008-01-14T04:33:35Z"
}
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 1 天
}
}));
// 登录成功后创建会话
app.get('/callback', async (req, res) => {
// ... 获取 access_token 和用户信息 ...
// 创建本地会话
req.session.user = {
id: githubUser.id,
login: githubUser.login,
avatar: githubUser.avatar_url,
accessToken: access_token
};
res.redirect('/dashboard');
});
// 保护路由的中间件
function requireAuth(req, res, next) {
if (req.session.user) {
next();
} else {
res.redirect('/login');
}
}
app.get('/dashboard', requireAuth, (req, res) => {
res.json({
message: '欢迎 ' + req.session.user.login,
user: req.session.user
});
});
---
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoiY2xpZW50X2lkIiwiZXhwIjoxNTE2MjM5MDIyLCJpYXQiOjE1MTYyMzkwMjIsIm5hbWUiOiJKb2huIERvZSIsImVtYWlsIjoiam9obkBleGFtcGxlLmNvbSJ9.signature
解码后的 Payload:
{
"iss": "https://auth.example.com",
"sub": "1234567890",
"aud": "client_id",
"exp": 1516239022,
"iat": 1516239022,
"name": "John Doe",
"email": "john@example.com"
}
---
| 模式 | 适用场景 | 安全性 | 是否需要后端 |
|------|----------|--------|--------------|
| 授权码模式 | 有后端的 Web 应用 | 高 | 是 |
| 授权码 + PKCE | 移动应用、SPA | 高 | 否 |
| 隐式模式 | 纯前端应用(已废弃) | 低 | 否 |
| 密码模式 | 官方可信应用 | 中 | 是 |
| 客户端凭证 | 机器对机器 | 高 | 是 |
**GitHub**
read:user - 读取用户基本信息user:email - 读取用户邮箱repo - 完全控制私有仓库public_repo - 控制公有仓库**Google**
openid - OpenID Connect 认证email - 查看邮箱地址profile - 查看基本信息https://www.googleapis.com/auth/drive.readonly - 只读访问 Google Drive| 状态码 | 含义 |
|--------|------|
| 200 | 成功 |
| 302 | 重定向到授权页面 |
| 400 | 请求错误(参数缺失、redirect_uri 不匹配等) |
| 401 | 未授权(client_secret 错误等) |
| 403 | 禁止访问(用户拒绝授权) |
---
---
OAuth2.0 是现代 Web 应用认证的基石,理解其工作原理对于开发者至关重要:
掌握 OAuth2.0,让你的应用安全地集成第三方登录和 API 访问!
---
**最后更新:** 2026-03-27
**字数:** 约 9500 字