OAuth 简化模式 #

概述 #

简化模式(Implicit Grant)是 OAuth 2.0 中为纯前端应用设计的授权方式,它直接在授权端点返回访问令牌,无需授权码交换步骤。

text
┌─────────────────────────────────────────────────────────────┐
│                    ⚠️ 重要提示                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  简化模式已被 OAuth 2.1 规范弃用                             │
│                                                             │
│  推荐替代方案:                                              │
│  ✅ 授权码模式 + PKCE                                        │
│                                                             │
│  原因:                                                     │
│  - 令牌直接暴露在 URL 中                                    │
│  - 无法安全存储刷新令牌                                     │
│  - 存在令牌泄露风险                                         │
│                                                             │
│  本文档仅供了解历史和现有系统维护参考                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

历史背景 #

为什么会有简化模式? #

text
┌─────────────────────────────────────────────────────────────┐
│                    简化模式的历史                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  2012年 OAuth 2.0 发布时:                                  │
│                                                             │
│  背景:                                                     │
│  - 单页应用(SPA)开始流行                                  │
│  - 浏览器不支持 CORS                                        │
│  - 前端无法安全存储 client_secret                           │
│  - 无法进行后端令牌交换                                     │
│                                                             │
│  解决方案:                                                 │
│  - 简化授权流程                                             │
│  - 直接在前端获取令牌                                       │
│  - 无需后端参与                                             │
│                                                             │
│  当时认为的安全假设:                                       │
│  - 令牌在 URL fragment 中,不会发送到服务器                 │
│  - 短期令牌风险可控                                         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

为什么被弃用? #

text
┌─────────────────────────────────────────────────────────────┐
│                    简化模式的问题                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 令牌暴露在 URL 中                                       │
│     - 浏览器历史记录                                        │
│     - Referer 头                                           │
│     - 日志记录                                              │
│     - 浏览器扩展                                            │
│                                                             │
│  2. 无法使用刷新令牌                                        │
│     - 前端无法安全存储                                      │
│     - 令牌过期后需重新授权                                  │
│                                                             │
│  3. 安全风险                                                │
│     - 令牌泄露后影响大                                      │
│     - 无法撤销单个令牌                                      │
│                                                             │
│  4. 现代浏览器变化                                          │
│     - CORS 普遍支持                                         │
│     - PKCE 提供了更好的安全方案                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

授权流程 #

流程图 #

text
┌─────────────────────────────────────────────────────────────┐
│                    简化模式流程                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────┐     ┌─────────┐     ┌─────────┐               │
│  │  用户   │     │  前端   │     │ 授权服务│               │
│  │Resource │     │ Client  │     │ Server  │               │
│  │ Owner   │     │  (SPA)  │     │         │               │
│  └────┬────┘     └────┬────┘     └────┬────┘               │
│       │               │               │                     │
│       │ (A) 点击登录  │               │                     │
│       │──────────────>│               │                     │
│       │               │               │                     │
│       │               │ (B) 重定向到授权端点                │
│       │               │ response_type=token                 │
│       │<──────────────────────────────│                     │
│       │               │               │                     │
│       │ (C) 用户认证并授权            │                     │
│       │──────────────────────────────>│                     │
│       │               │               │                     │
│       │               │ (D) 重定向,令牌在 URL fragment 中  │
│       │               │<──────────────│                     │
│       │               │               │                     │
│       │               │ (E) 前端提取令牌                    │
│       │               │               │                     │
│       │               │ (F) 使用令牌访问资源                │
│       │               │────────────────────────────────────>│
│       │               │               │                     │
│       │               │ (G) 返回数据  │                     │
│       │               │<────────────────────────────────────│
│       │               │               │                     │
└─────────────────────────────────────────────────────────────┘

步骤详解 #

步骤 A-B:发起授权请求 #

http
GET /authorize?response_type=token
&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=token(而非 code

步骤 C-D:用户授权后返回 #

http
HTTP/1.1 302 Found
Location: https://client.example.com/cb#
  access_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
  &token_type=Bearer
  &expires_in=3600
  &scope=profile%20email
  &state=xyzABC123

注意:令牌在 URL fragment(#后面)中

步骤 E:前端提取令牌 #

javascript
function extractTokenFromUrl() {
  const hash = window.location.hash.substring(1);
  const params = new URLSearchParams(hash);
  
  return {
    accessToken: params.get('access_token'),
    tokenType: params.get('token_type'),
    expiresIn: params.get('expires_in'),
    scope: params.get('scope'),
    state: params.get('state')
  };
}

const tokens = extractTokenFromUrl();

授权请求参数 #

必需参数 #

参数 描述
response_type 固定值 token
client_id 客户端标识
redirect_uri 回调地址

推荐参数 #

参数 描述
scope 授权范围
state 防 CSRF 状态值

可选参数 #

参数 描述
nonce 随机值,用于防止重放攻击
prompt 提示模式

前端实现示例 #

完整实现(仅供参考) #

javascript
const config = {
  clientId: 'your-client-id',
  authorizationEndpoint: 'https://auth.example.com/authorize',
  redirectUri: 'https://your-spa.example.com/callback',
  scope: 'profile email'
};

function generateState() {
  return Array.from(crypto.getRandomValues(new Uint8Array(32)))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

function login() {
  const state = generateState();
  sessionStorage.setItem('oauth_state', state);
  
  const params = new URLSearchParams({
    response_type: 'token',
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: config.scope,
    state: state
  });
  
  window.location.href = `${config.authorizationEndpoint}?${params}`;
}

function handleCallback() {
  const hash = window.location.hash.substring(1);
  const params = new URLSearchParams(hash);
  
  const state = params.get('state');
  const storedState = sessionStorage.getItem('oauth_state');
  
  if (!state || state !== storedState) {
    throw new Error('Invalid state');
  }
  
  sessionStorage.removeItem('oauth_state');
  
  const error = params.get('error');
  if (error) {
    throw new Error(params.get('error_description') || error);
  }
  
  const accessToken = params.get('access_token');
  const expiresIn = params.get('expires_in');
  
  sessionStorage.setItem('access_token', accessToken);
  sessionStorage.setItem('token_expiry', Date.now() + expiresIn * 1000);
  
  window.location.hash = '';
  
  return accessToken;
}

async function fetchUserInfo(accessToken) {
  const response = await fetch('https://api.example.com/userinfo', {
    headers: {
      'Authorization': `Bearer ${accessToken}`
    }
  });
  
  return response.json();
}

安全问题详解 #

问题一:令牌暴露 #

text
┌─────────────────────────────────────────────────────────────┐
│                    令牌暴露风险                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 浏览器历史记录                                          │
│     https://app.example.com/cb#access_token=xxx             │
│     ↑ 令牌保存在历史中                                      │
│                                                             │
│  2. Referer 头                                              │
│     用户点击外部链接时,令牌可能被发送                      │
│                                                             │
│  3. 服务器日志                                              │
│     某些代理/日志可能记录完整 URL                           │
│                                                             │
│  4. 浏览器扩展                                              │
│     恶意扩展可以读取 URL                                    │
│                                                             │
│  5. XSS 攻击                                                │
│     一旦存在 XSS,令牌可被窃取                              │
│                                                             │
└─────────────────────────────────────────────────────────────┘

问题二:无刷新令牌 #

text
┌─────────────────────────────────────────────────────────────┐
│                    无刷新令牌问题                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  简化模式不支持刷新令牌:                                   │
│                                                             │
│  原因:                                                     │
│  - 刷新令牌比访问令牌更敏感                                 │
│  - 前端无法安全存储                                         │
│                                                             │
│  后果:                                                     │
│  - 访问令牌过期后需重新授权                                 │
│  - 用户体验差                                               │
│  - 频繁弹出授权窗口                                         │
│                                                             │
│  变通方案(都不安全):                                     │
│  - 使用长期有效的访问令牌                                   │
│  - 使用隐藏 iframe 静默获取新令牌                           │
│                                                             │
└─────────────────────────────────────────────────────────────┘

迁移到授权码 + PKCE #

为什么迁移? #

text
┌─────────────────────────────────────────────────────────────┐
│                 授权码 + PKCE 优势                           │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 更安全                                                  │
│     - 授权码可被 PKCE 保护                                  │
│     - 令牌不暴露在 URL 中                                   │
│                                                             │
│  2. 支持刷新令牌                                            │
│     - 可以安全地刷新访问令牌                                │
│     - 更好的用户体验                                        │
│                                                             │
│  3. 标准化                                                  │
│     - OAuth 2.1 推荐方案                                    │
│     - 广泛支持                                              │
│                                                             │
│  4. 统一授权流程                                            │
│     - 与服务器端应用使用相同模式                            │
│     - 减少实现复杂度                                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

迁移步骤 #

javascript
const config = {
  clientId: 'your-client-id',
  authorizationEndpoint: 'https://auth.example.com/authorize',
  tokenEndpoint: 'https://auth.example.com/token',
  redirectUri: 'https://your-spa.example.com/callback',
  scope: 'profile email'
};

async function generatePKCE() {
  const verifier = generateRandomString(128);
  const challenge = await sha256(verifier);
  
  return {
    codeVerifier: verifier,
    codeChallenge: challenge
  };
}

function generateRandomString(length) {
  const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  const values = crypto.getRandomValues(new Uint8Array(length));
  return values.map(v => charset[v % charset.length]).join('');
}

async function sha256(plain) {
  const encoder = new TextEncoder();
  const data = encoder.encode(plain);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(hash);
}

function base64UrlEncode(buffer) {
  const bytes = new Uint8Array(buffer);
  let str = '';
  bytes.forEach(b => str += String.fromCharCode(b));
  return btoa(str)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

async function login() {
  const state = generateRandomString(32);
  const pkce = await generatePKCE();
  
  sessionStorage.setItem('oauth_state', state);
  sessionStorage.setItem('code_verifier', pkce.codeVerifier);
  
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: config.scope,
    state: state,
    code_challenge: pkce.codeChallenge,
    code_challenge_method: 'S256'
  });
  
  window.location.href = `${config.authorizationEndpoint}?${params}`;
}

async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  
  const state = params.get('state');
  const storedState = sessionStorage.getItem('oauth_state');
  
  if (!state || state !== storedState) {
    throw new Error('Invalid state');
  }
  
  const code = params.get('code');
  const codeVerifier = sessionStorage.getItem('code_verifier');
  
  const tokenResponse = await fetch(config.tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: config.redirectUri,
      client_id: config.clientId,
      code_verifier: codeVerifier
    })
  });
  
  const tokens = await tokenResponse.json();
  
  sessionStorage.setItem('access_token', tokens.access_token);
  sessionStorage.setItem('refresh_token', tokens.refresh_token);
  
  return tokens;
}

简化模式 vs 授权码 + PKCE #

特性 简化模式 授权码 + PKCE
令牌位置 URL fragment POST 响应体
安全性
刷新令牌 不支持 支持
PKCE 保护 不支持 支持
OAuth 2.1 已弃用 推荐
浏览器历史 令牌可见 令牌不可见
用户体验 差(需频繁授权) 好(可刷新)

常见问题 #

问题一:现有系统使用简化模式怎么办? #

text
建议:
1. 评估风险
   - 令牌有效期
   - 令牌权限范围
   - 用户数据敏感程度

2. 制定迁移计划
   - 新应用使用授权码 + PKCE
   - 逐步迁移现有应用

3. 缓解措施(迁移前)
   - 使用短期令牌
   - 限制令牌权限范围
   - 监控异常使用

问题二:授权服务器不支持 PKCE? #

text
解决方案:
1. 联系授权服务器提供商
   - 请求支持 PKCE
   - 了解支持计划

2. 临时方案
   - 使用后端代理
   - 后端完成授权码交换
   - 前端通过安全会话获取令牌

3. 更换授权服务器
   - 选择支持 PKCE 的服务

下一步 #

简化模式已被弃用,推荐学习 PKCE 扩展,了解如何安全地在单页应用和移动应用中使用授权码模式!

最后更新:2026-03-28