理解 OAuth 2.0:驱动现代 API 的授权框架

  1. OAuth 2.0 实际解决的问题
  2. OAuth 2.0 角色和组件
  3. 授权码流程:黄金标准
  4. 隐式流程:已弃用且危险
  5. PKCE:保护公开客户端
  6. 客户端凭证流程:服务对服务
  7. 常见的 OAuth 误解
  8. 令牌安全最佳实践
  9. 刷新令牌:延长访问
  10. 结论

OAuth 2.0 已成为 API 授权的事实标准,从社交媒体登录到企业 API 访问无所不在。然而尽管如此普及,OAuth 仍被广泛误解。开发者经常混淆授权与身份验证、错误实现流程,或以危害安全的方式误用访问令牌。

本文通过检视核心概念、授权流程和安全考量来揭开 OAuth 2.0 的神秘面纱。我们将追踪令牌在不同授权类型中的旅程,理解各方角色,并识别常见的实现错误。通过图表和真实场景,您将获得对 OAuth 如何在提供无缝用户体验的同时保护资源的实用理解。

OAuth 2.0 实际解决的问题

在 OAuth 之前,需要访问第三方服务上用户数据的应用程序面临一个根本问题:它们需要用户分享密码。这造成了多重安全性和可用性问题。

OAuth 之前的问题

考虑一个需要访问您存储在云端存储平台上照片的照片打印服务:

🚫 密码分享问题

传统方法

  • 用户向打印服务提供云端存储的用户名和密码
  • 打印服务存储凭证(安全风险)
  • 打印服务拥有对所有用户数据的完整访问权
  • 无法限制访问范围或持续时间
  • 密码更改会中断所有集成服务
  • 无法审计哪个服务访问了什么

安全影响

  • 凭证暴露给第三方
  • 无细粒度权限控制
  • 无法在不更改密码的情况下撤销访问权
  • 违反最小权限原则
  • 显著增加攻击面

OAuth 2.0 通过引入委托授权来解决这个问题:用户可以在不分享凭证的情况下授予对其资源的有限访问权。

OAuth 的核心创新

OAuth 将身份验证(证明您是谁)与授权(授予资源访问权)分离:

✅ OAuth 授权模型

委托访问

  • 用户直接向资源拥有者进行身份验证(例如云端存储)
  • 用户为第三方应用程序授权特定权限
  • 应用程序接收有时限的访问令牌
  • 令牌仅授予请求的权限
  • 用户可随时撤销访问权而无需更改密码

主要优势

  • 无需密码分享
  • 细粒度权限范围
  • 有时限的访问令牌
  • 每个应用程序独立撤销
  • 清楚的访问授权审计轨迹

这个模型实现了现代 API 生态系统,让应用程序能够无缝集成同时维持安全边界。

OAuth 2.0 角色和组件

OAuth 定义了四个参与授权流程的不同角色:

🎭 OAuth 角色

资源拥有者

  • 拥有数据的用户
  • 授权访问其资源
  • 示例:您,授权打印服务访问您的照片

客户端

  • 请求访问资源的应用程序
  • 必须向授权服务器注册
  • 示例:照片打印服务应用程序

授权服务器

  • 在验证资源拥有者身份后发行访问令牌
  • 验证授权许可
  • 示例:云端存储提供商的 OAuth 服务器

资源服务器

  • 托管受保护的资源
  • 验证访问令牌并提供请求服务
  • 示例:提供照片文件的云端存储 API

这些角色通过一系列明确定义的流程互动,每个流程都针对不同的应用程序类型和安全需求进行优化。

授权码流程:黄金标准

授权码流程是最安全的 OAuth 流程,建议用于能够安全存储客户端密钥的应用程序。

流程概览

sequenceDiagram participant User as 👤 资源拥有者 participant Browser as 🌐 浏览器 participant Client as 📱 客户端应用程序 participant AuthServer as 🔐 授权服务器 participant ResourceServer as 💾 资源服务器 User->>Browser: 1. 点击「连接账户」 Browser->>AuthServer: 2. 授权请求
(client_id, redirect_uri, scope) AuthServer->>User: 3. 显示登录和同意画面 User->>AuthServer: 4. 验证身份并批准 AuthServer->>Browser: 5. 重定向并附带授权码 Browser->>Client: 6. 授权码 Client->>AuthServer: 7. 交换授权码获得令牌
(code, client_id, client_secret) AuthServer->>Client: 8. 访问令牌 + 刷新令牌 Client->>ResourceServer: 9. 使用访问令牌的 API 请求 ResourceServer->>Client: 10. 受保护的资源数据

逐步分解

📋 授权码流程步骤

1. 用户启动授权

  • 用户在客户端应用程序中点击「使用 [服务] 连接」
  • 客户端将用户重定向至授权服务器

2. 授权请求

GET /authorize?

response_type=code& client_id=CLIENT_ID& redirect_uri=https://client.app/callback& scope=read:photos& state=RANDOM_STRING - `response_type=code`:请求授权码 - `client_id`:识别客户端应用程序 - `redirect_uri`:发送授权码的位置 - `scope`:请求的权限 - `state`:CSRF 保护令牌
**3. 用户身份验证和同意**
- 授权服务器验证用户身份(登录画面)
- 显示包含请求权限的同意画面
- 用户批准或拒绝访问

**4. 发行授权码**
HTTP/1.1 302 Found
Location: https://client.app/callback?
  code=AUTHORIZATION_CODE&
  state=RANDOM_STRING
- 短期授权码(通常 10 分钟) - 通过浏览器重定向返回 - 仅限单次使用 **5. 令牌交换**
POST /token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://client.app/callback&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET
- 客户端交换授权码获得令牌 - 包含客户端密钥(服务器对服务器) - 授权码被消耗并失效 **6. 访问令牌响应**
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
  "scope": "read:photos"
}
- 用于 API 请求的访问令牌 - 用于获得新访问令牌的刷新令牌 - 以秒为单位的过期时间 **7. API 访问**
GET /api/photos
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
- 客户端在请求中包含访问令牌 - 资源服务器验证令牌 - 返回受保护的资源

为什么这个流程是安全的

授权码流程提供多层安全保护:

🔒 安全功能

关注点分离

  • 授权码暴露给浏览器(较不敏感)
  • 访问令牌从不暴露给浏览器
  • 客户端密钥仅用于服务器对服务器通信

短期凭证

  • 授权码快速过期(10 分钟)
  • 授权码仅限单次使用
  • 访问令牌有时限(通常 1 小时)

CSRF 保护

  • state 参数防止跨站请求伪造
  • 客户端验证 state 符合原始请求

客户端身份验证

  • 客户端密钥在令牌交换期间证明客户端身份
  • 防止授权码拦截攻击

这个流程适用于网络应用程序、具有后端服务器的移动应用程序,以及任何能够保护客户端密钥的场景。

隐式流程:已弃用且危险

隐式流程是为无法安全存储客户端密钥的浏览器应用程序设计的。然而,由于安全漏洞,它已被弃用。

隐式流程如何运作

sequenceDiagram participant User as 👤 资源拥有者 participant Browser as 🌐 浏览器/SPA participant AuthServer as 🔐 授权服务器 participant ResourceServer as 💾 资源服务器 User->>Browser: 1. 点击「登录」 Browser->>AuthServer: 2. 授权请求
(response_type=token) AuthServer->>User: 3. 验证身份并同意 AuthServer->>Browser: 4. 重定向并附带访问令牌
(在 URL 片段中) Browser->>Browser: 5. 从 URL 提取令牌 Browser->>ResourceServer: 6. 使用令牌的 API 请求 ResourceServer->>Browser: 7. 受保护的资源

为什么隐式流程被弃用

🚫 隐式流程安全问题

访问令牌暴露

  • 令牌在 URL 片段中返回
  • 在浏览器历史记录中可见
  • 记录在服务器访问日志中
  • 暴露给浏览器扩展
  • 容易受到 XSS 攻击

无客户端身份验证

  • 无客户端密钥验证
  • 无法验证客户端身份
  • 更容易伪装客户端

无刷新令牌

  • 无法安全发行刷新令牌
  • 令牌过期时用户必须重新验证
  • 用户体验不佳

令牌泄漏途径

  • Referrer 标头可能泄漏令牌
  • 浏览器历史记录存储令牌
  • 共用计算机暴露令牌
  • 无安全存储机制

现代应用程序应该使用带有 PKCE 的授权码流程。

PKCE:保护公开客户端

代码交换证明密钥(PKCE,发音为「pixie」)扩展了授权码流程,以保护无法存储密钥的公开客户端。

公开客户端问题

移动应用程序和单页应用程序面临根本挑战:

⚠️ 公开客户端挑战

无法安全存储密钥

  • 移动应用程序代码可以被反编译
  • JavaScript 源代码在浏览器中可见
  • 无客户端密钥的安全存储
  • 密钥会暴露给所有用户

授权码拦截

  • 恶意应用程序可以注册相同的重定向 URI
  • 授权码被攻击者拦截
  • 攻击者交换授权码获得令牌
  • 无客户端密钥来防止这种情况

PKCE 通过添加无法预先提取的动态、每次请求密钥来解决这个问题。

PKCE 流程

sequenceDiagram participant User as 👤 资源拥有者 participant Client as 📱 移动应用程序/SPA participant AuthServer as 🔐 授权服务器 participant ResourceServer as 💾 资源服务器 Client->>Client: 1. 生成 code_verifier
(随机字符串) Client->>Client: 2. 创建 code_challenge
(验证器的 SHA256 哈希) User->>Client: 3. 启动登录 Client->>AuthServer: 4. 授权请求
(code_challenge, method) AuthServer->>User: 5. 验证身份并同意 AuthServer->>Client: 6. 授权码 Client->>AuthServer: 7. 令牌请求
(code, code_verifier) AuthServer->>AuthServer: 8. 验证 code_challenge
符合 code_verifier AuthServer->>Client: 9. 访问令牌 Client->>ResourceServer: 10. API 请求 ResourceServer->>Client: 11. 受保护的资源

PKCE 实现细节

🔐 PKCE 参数

代码验证器

  • 随机字符串:43-128 个字符
  • 密码学随机
  • 每次授权请求都重新生成
  • 示例:dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

代码挑战

  • 代码验证器的 SHA256 哈希(建议)
  • 或纯文本代码验证器(不建议)
  • 在授权请求中发送
  • 示例:E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

授权请求

GET /authorize?

response_type=code& client_id=CLIENT_ID& redirect_uri=https://app.example.com/callback& scope=read:photos& code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM& code_challenge_method=S256
**令牌请求**
POST /token

grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=https://app.example.com/callback&
client_id=CLIENT_ID&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
**服务器验证**
SHA256(code_verifier) == stored_code_challenge
- 授权服务器将 code_challenge 与授权码一起存储 - 在令牌交换期间验证 code_verifier 符合 - 防止授权码拦截攻击

为什么 PKCE 有效

✅ PKCE 安全优势

动态密钥

  • 每次请求生成代码验证器
  • 无法从应用程序预先提取
  • 攻击者无法预测或重复使用验证器

授权码绑定

  • 代码挑战将授权码绑定到客户端实例
  • 被拦截的授权码在没有代码验证器的情况下无用
  • 只有启动流程的客户端才能交换授权码

无需存储密钥

  • 不需要客户端密钥
  • 对移动应用程序和 SPA 安全
  • 在不存储密钥的情况下维持安全性

向后兼容

  • 与现有 OAuth 服务器兼容(如果支持)
  • 可与客户端密钥一起使用
  • 现在建议所有 OAuth 客户端使用

PKCE 现在建议所有 OAuth 客户端使用,包括能够存储密钥的机密客户端,作为额外的安全层。

客户端凭证流程:服务对服务

客户端凭证流程是为不涉及用户的机器对机器通信设计的。

何时使用客户端凭证

🤖 客户端凭证使用案例

后端服务

  • 微服务之间的通信
  • 访问 API 的计划作业
  • 服务器对服务器集成
  • 不需要用户上下文

特征

  • 客户端代表自己行动
  • 不涉及资源拥有者
  • 客户端凭证就是授权
  • 通常是长期或缓存的令牌

客户端凭证流程

sequenceDiagram participant Client as 🖥️ 客户端服务 participant AuthServer as 🔐 授权服务器 participant ResourceServer as 💾 资源服务器 Client->>AuthServer: 1. 令牌请求
(client_id, client_secret, scope) AuthServer->>AuthServer: 2. 验证客户端凭证 AuthServer->>Client: 3. 访问令牌 Client->>ResourceServer: 4. 使用令牌的 API 请求 ResourceServer->>Client: 5. 受保护的资源

实现

📋 客户端凭证请求

令牌请求

POST /token

Content-Type: application/x-www-form-urlencoded grant_type=client_credentials& client_id=CLIENT_ID& client_secret=CLIENT_SECRET& scope=api:read api:write
**令牌响应**
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "api:read api:write"
}
**API 请求**
GET /api/resources
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

这个流程很直接,但需要安全存储客户端凭证,且应该只用于服务对服务通信。

常见的 OAuth 误解

OAuth 经常被误解,导致安全漏洞和实现错误。

OAuth 不是身份验证

最常见的误解是 OAuth 提供身份验证。它并不提供。

🚫 OAuth ≠ 身份验证

OAuth 提供什么

  • 授权:访问资源的权限
  • 委托的 API 访问
  • 范围权限

OAuth 不提供什么

  • 用户身份验证
  • 用户身份验证
  • 身份信息

问题

  • 访问令牌证明授权,不是身份
  • 令牌可能发行给不同用户
  • 没有标准方式获得用户信息
  • 容易受到令牌替换攻击

「使用 OAuth 登录」反模式

许多应用程序仅使用 OAuth 实现「使用 [服务] 登录」:

⚠️ 不安全的 OAuth 登录模式

有缺陷的实现

// 不安全:不要这样做

app.get('/callback', async (req, res) => { const { code } = req.query; const token = await exchangeCodeForToken(code); // 假设令牌属于合法用户 const user = await getUserFromToken(token); req.session.userId = user.id; // 有漏洞 });
**攻击场景**
- 攻击者获得其账户的有效访问令牌
- 攻击者诱骗受害者使用攻击者的令牌
- 受害者以攻击者身份登录
- 受害者的行动归因于攻击者的账户
- 数据泄漏和账户混淆

使用 OpenID Connect 进行身份验证

OpenID Connect (OIDC) 扩展 OAuth 2.0 以提供身份验证:

✅ OpenID Connect 解决方案

OIDC 添加什么

  • ID 令牌:包含用户身份声明的 JWT
  • UserInfo 端点:标准化的用户信息
  • 身份验证验证
  • 标准化声明(sub、name、email 等)

安全实现

// 安全:使用 OpenID Connect

app.get('/callback', async (req, res) => { const { code } = req.query; const tokens = await exchangeCodeForTokens(code); // 验证 ID 令牌签名和声明 const idToken = await verifyIdToken(tokens.id_token); // ID 令牌包含已验证的用户身份 req.session.userId = idToken.sub; req.session.email = idToken.email; });
**ID 令牌结构**
{
  "iss": "https://auth.example.com",
  "sub": "user123",
  "aud": "client_id",
  "exp": 1661529600,
  "iat": 1661526000,
  "email": "user@example.com",
  "email_verified": true
}

使用 OAuth 2.0 进行 API 授权。使用 OpenID Connect 进行用户身份验证。

令牌安全最佳实践

访问令牌是持有者令牌——任何拥有令牌的人都可以使用它。适当的令牌处理至关重要。

🔒 令牌安全指南

存储

  • 永远不要将令牌存储在 localStorage(容易受到 XSS 攻击)
  • 对网络应用程序使用 httpOnly cookies
  • 在移动设备上使用安全存储 API(Keychain、KeyStore)
  • 如果存储在数据库中,请加密令牌

传输

  • 始终使用 HTTPS/TLS
  • 在 Authorization 标头中包含令牌,不要在 URL 中
  • 永远不要在应用程序日志中记录令牌
  • 避免在查询参数中发送令牌

验证

  • 验证令牌签名(对于 JWT)
  • 检查令牌过期
  • 验证令牌受众和发行者
  • 验证令牌范围符合所需权限

生命周期管理

  • 使用短期访问令牌(通常 1 小时)
  • 实现刷新令牌轮换
  • 登出时撤销令牌
  • 监控令牌重复使用尝试

范围原则

  • 请求最少必要的范围
  • 在资源服务器上验证范围
  • 不要授予过多权限
  • 实现基于范围的访问控制

刷新令牌:延长访问

刷新令牌实现长期访问,而无需用户频繁重新验证。

刷新令牌流程

sequenceDiagram participant Client as 📱 客户端应用程序 participant AuthServer as 🔐 授权服务器 participant ResourceServer as 💾 资源服务器 Client->>ResourceServer: 1. 使用访问令牌的 API 请求 ResourceServer->>Client: 2. 401 未授权(令牌过期) Client->>AuthServer: 3. 刷新令牌请求
(refresh_token, client_id) AuthServer->>AuthServer: 4. 验证刷新令牌 AuthServer->>Client: 5. 新访问令牌 + 新刷新令牌 Client->>ResourceServer: 6. 使用新令牌的 API 请求 ResourceServer->>Client: 7. 受保护的资源

刷新令牌安全

⚠️ 刷新令牌风险

高价值目标

  • 长期凭证(数天到数月)
  • 可以生成新的访问令牌
  • 对攻击者比访问令牌更有价值
  • 需要更强的保护

安全措施

  • 刷新令牌轮换:每次使用时发行新的刷新令牌
  • 检测刷新令牌重复使用(表示被盗)
  • 将刷新令牌绑定到客户端
  • 在可疑活动时撤销令牌系列
  • 安全存储刷新令牌(加密)
  • 实现刷新令牌过期

刷新令牌轮换

请求:  refresh_token=OLD_TOKEN

响应: access_token=NEW_ACCESS refresh_token=NEW_REFRESH 旧刷新令牌立即失效 重复使用旧令牌触发安全警报

刷新令牌轮换现在被认为是最佳实践,防止令牌重放攻击并限制令牌被盗的损害。

结论

OAuth 2.0 提供了一个强大的委托授权框架,使应用程序能够在不分享密码的情况下访问用户资源。通过将授权与身份验证分离,OAuth 创建了清楚的安全边界,同时实现了现代 API 生态系统。

带有 PKCE 的授权码流程代表了大多数应用程序的当前最佳实践,为机密和公开客户端提供强大的安全性。已弃用的隐式流程应该完全避免。客户端凭证流程服务于不涉及用户的机器对机器场景。每个流程都解决特定的安全需求和应用程序架构。

理解 OAuth 提供授权而非身份验证是至关重要的。需要用户身份验证的应用程序应该使用 OpenID Connect,它以标准化身份验证扩展 OAuth。将 OAuth 访问令牌视为身份证明会造成严重的安全漏洞。

令牌安全需要仔细注意存储、传输、验证和生命周期管理。访问令牌应该是短期的,通过 HTTPS 传输,并安全存储。刷新令牌作为长期且强大的凭证,需要更强的保护,包括轮换和重复使用检测。

OAuth 2.0 不是单一协议,而是为不同场景提供多种流程的框架。选择适当的流程、正确实现,并遵循安全最佳实践,对于构建安全、用户友好的应用程序至关重要。OAuth 的复杂性反映了它所解决的安全问题的复杂性——在不分享凭证的情况下进行委托访问本质上是具有挑战性的,而 OAuth 为这些挑战提供了经过实战考验的解决方案。

分享到