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