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