理解 OAuth 2.0 和 OpenID Connect:开发者实现指南
OAuth 2.0 已成为过去十年网络上委托授权的支柱,然而它仍然是生产软件中最常被错误实现的规范之一。将其与 OpenID Connect 配合使用,你就拥有了一个强大而微妙的认证和授权堆栈,它奖励精心设计的工程,却无情地惩罚走捷径的行为。
这份 OAuth 2.0 OpenID Connect 实现指南面向那些需要超越教程、构建能够承受真实流量、真实攻击者和真实合规要求的开发者。目标是提供实用的清晰度:哪种授权类型适合你的架构,令牌的实际工作方式,团队经常在哪里栽跟头,以及哪些库值得你的信任。
OAuth 2.0:授权层
OAuth 2.0 是一个授权框架,而不是认证协议。这个区别比大多数团队意识到的更为重要。OAuth 告诉资源服务器,客户端已被授予特定权限。它本身并不说明谁是用户。这个空白正是 OpenID Connect 所填补的,但我们稍后会讲到这一点。
该框架定义了几种授权类型,每种都针对不同的交互模式而设计。选择错误的授权类型,最好的情况是增加摩擦,最坏的情况是引入安全漏洞。
授权码授权
这是 OAuth 2.0 的主力,也是当涉及用户时你应该默认选择的授权类型。流程如下:客户端将用户重定向到授权服务器,用户进行身份验证并同意,授权服务器带着短期有效的授权码重定向回来,客户端在后端交换该代码以获取令牌。
这里的关键属性是令牌永远不会通过浏览器的地址栏或前端通道传递。授权码本身没有客户端密钥(或 PKCE 验证器)就毫无用处,这限制了拦截造成的损害。
使用场景:服务器端 Web 应用程序、单页应用程序(使用 PKCE)、移动应用程序(使用 PKCE)。
客户端凭据授权
当没有用户参与时,客户端凭据是正确的选择。服务使用自己的凭据直接向授权服务器进行身份验证,并接收一个访问令牌。没有重定向,没有浏览器,没有同意屏幕。
使用场景: 服务间通信、后台任务、微服务架构、代表应用程序而非用户操作的 CLI 工具。
设备授权授权(设备流程)
此授权类型解决了一个真实的用户体验问题:如何在输入能力有限的设备上进行身份验证?智能电视、物联网设备和无法轻松打开浏览器的 CLI 工具使用设备流程。设备显示一个代码,用户在具有完整浏览器的其他设备上输入该代码,然后原始设备轮询授权服务器直到获取到令牌。
使用场景: 智能电视、物联网设备、浏览器重定向不切实际的 CLI 工具。
授权类型比较
| 授权类型 | 是否涉及用户 | 客户端类型 | 令牌传递方式 | 是否需要 PKCE | 典型用例 |
|---|---|---|---|---|---|
| 授权码 | 是 | 机密或公共 | 后通道 | 公共客户端必需,所有客户端推荐 | Web 应用、SPA、移动应用 |
| 客户端凭据 | 否 | 机密 | 直接响应 | 否 | 服务间通信、后台任务 |
| 设备授权 | 是 | 公共 | 轮询 | 否(独立用户代理) | 智能电视、物联网、CLI 工具 |
关于已弃用流程的说明: 隐式授权和资源所有者密码凭据授权实际上已经过时。OAuth 2.1 草案已移除两者。如果您仍在生产环境中使用其中任何一种,迁移应成为优先事项。隐式授权在 URL 片段中暴露令牌,而 ROPC 要求用户将密码交给第三方客户端,这违背了 OAuth 的整个目的。
OpenID Connect:身份层
OpenID Connect (OIDC) 构建于 OAuth 2.0 之上,回答了 OAuth 故意留白的问题:这个用户是谁?它引入了 ID 令牌,这是一个包含关于已认证用户、其认证时间、颁发者和受众声明的 JWT。
OIDC 还标准化了 UserInfo 端点、发现机制(/.well-known/openid-configuration)和一组范围(openid、profile、email),使身份数据在不同提供商之间具有互操作性。
OAuth 2.0 和 OIDC 之间的关系让许多开发者感到困惑。可以这样理解:OAuth 2.0 为您的应用程序提供了一把特定房间的钥匙。OIDC 告诉您的应用程序是谁交出了这把钥匙。您几乎总是需要两者。
ID 令牌
ID 令牌是一个具有明确定义结构的签名 JWT。您应该在每次身份验证时验证的声明:
- iss (发行者): 必须匹配预期的授权服务器
- aud (受众): 必须包含您的客户端 ID
- exp (过期时间): 不能是过去的时间
- iat (签发时间): 用于新鲜度检查
- nonce: 必须与您在授权请求中发送的 nonce 匹配(防止重放攻击)
- sub (主题): 稳定的用户标识符
跳过任何这些验证都是一个安全缺陷。我审查过一些生产代码库,它们验证签名但完全忽略受众声明,这意味着由同一提供商为不同应用程序颁发的任何令牌都会被接受。
实际中的令牌管理
一个有效的 OAuth/OIDC 实现需要处理三种类型的令牌,每种都有不同的用途和生命周期。
访问令牌
访问令牌授权 API 请求。它们通常是短期的(5 到 60 分钟),可以是字符串或 JWT。资源服务器在每次请求时都会验证它们,通过内省(调用授权服务器)或本地 JWT 验证。
保持访问令牌的生命周期短。5 分钟的访问令牌限制了被盗凭证的影响范围。更长的生命周期虽然可以减少刷新流量,但这种权衡通常不值得。
刷新令牌
刷新令牌是长期凭证,用于在没有用户交互的情况下获取新的访问令牌。它们功能强大但也危险。被盗的刷新令牌会让攻击者持续访问,直到被撤销。
刷新令牌的最佳实践:尽可能在服务器端存储它们,实现刷新令牌轮换(每次使用都会颁发新的刷新令牌并使旧的失效),设置绝对过期时间,并将重用检测为受损的信号。
ID 令牌
ID 令牌用于客户端应用程序。它们在特定时间点断言身份。不要将 ID 令牌发送到您的 API 作为授权。那是访问令牌的用途。ID 令牌应在登录时使用一次,进行验证,并使用其声明来建立会话。
令牌交换流程:带有 PKCE 的授权码
以下是交换授权代码获取令牌的具体流程,这是您将实现的最常见模式:
// 第1步:在重定向用户前生成PKCE参数
const codeVerifier = generateRandomString(64); // 加密随机数
const codeChallenge = base64urlEncode(sha256(codeVerifier));
// 第2步:构建授权URL
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', generateRandomString(32));
authUrl.searchParams.set('nonce', generateRandomString(32));
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// 将state、nonce和codeVerifier存储在会话中以便后续验证
// 将用户重定向到authUrl
// 第3步:处理回调 - 交换代码获取令牌
async function handleCallback(code, returnedState) {
// 验证state参数与会话中存储的值是否匹配
if (returnedState !== storedState) {
throw new Error('State不匹配 - 可能存在CSRF攻击');
}
const tokenResponse = 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: storedCodeVerifier // PKCE证明
})
});
const tokens = await tokenResponse.json();
// tokens包含: access_token, refresh_token, id_token, expires_in
// 第4步:在信任ID token的声明前验证它
const idTokenClaims = await verifyIdToken(tokens.id_token, {
issuer: 'https://auth.example.com',
audience: CLIENT_ID,
nonce: storedNonce
});
return { accessToken: tokens.access_token, user: idTokenClaims };
}
这里的每一步都很重要。移除state验证会使您容易受到CSRF攻击。移除PKCE会使公共客户端容易受到授权代码拦截攻击。跳过ID token验证意味着您在信任未经验证的声明。
实际会出问题的安全陷阱
安全文档往往追求详尽无遗。在这里,我想重点关注我在代码审查和安全评估中最常遇到的错误。
PKCE不是可选的
代码交换证明密钥(PKCE)最初设计用于无法安全存储客户端机密的移动和SPA客户端。当前的最佳实践以及OAuth 2.1的要求是,为所有授权代码流程使用PKCE,包括机密客户端。它在复杂性方面没有任何成本,并且为防止代码拦截攻击提供了深度防御。
使用 S256 作为挑战方法。规范允许使用 Plain,但它不提供任何安全优势。如果您的授权服务器不支持 PKCE,那是一个关于其维护状态的重大危险信号。
State 参数
state 参数可防止针对重定向 URI 的跨站请求伪造。没有它,攻击者可以构建一个 URL,导致受害者的浏览器使用攻击者的授权代码完成 OAuth 流程,从而将受害者的会话绑定到攻击者的账户。
使用加密安全的随机数生成器生成 state 值。将它们绑定到用户的会话中。在每次回调时验证它们。这是不可协商的。
令牌存储
令牌的存储位置取决于您的客户端类型,而存储错误是 OAuth 实现中最常见的漏洞之一:
- 服务器端应用程序:将令牌存储在加密的服务器端会话中。这是最安全的选择,因为令牌永远不会到达浏览器。
- 单页应用程序:将令牌保存在内存中(JavaScript 变量)。避免使用 localStorage,因为它可以被同源上的任何脚本访问,使得 XSS 攻击具有毁灭性。如果您需要在页面重新加载之间保持持久性,考虑使用后端为前端(BFF)模式,将令牌保存在服务器端。
- 移动应用程序:使用平台特定的安全存储:iOS 上的 Keychain,Android 上的 EncryptedSharedPreferences。切勿将令牌以明文形式存储在文件或未加密的数据库中。
BFF 模式值得比当前更广泛的采用。通过通过轻量级后端代理令牌操作,您可以获得 SPA 的用户体验和服务器端应用程序的令牌安全性。增加的复杂性很小,但安全性改进是巨大的。
重定向 URI 验证
授权服务器必须对重定向 URI 执行精确匹配验证。客户端必须注册完整的 URI,而不仅仅是域名。OAuth 流程中的开放重定向漏洞允许攻击者通过操纵重定向目标来窃取授权代码。如果您的提供商在生产环境中允许通配符重定向 URI,请坚决反对。
错误的 JWT 验证
一类反复出现的漏洞源于不完整的 JWT 验证。常见故障包括:
- 不验证
alg头部,这 enables 算法混淆攻击,攻击者可以从 RS256 切换到 HS256 并使用公钥签名 - 不检查
aud声明,接受针对其他客户端的令牌 - 在验证签名之前使用 JWT 载荷
- 从攻击者控制的 URL 而非配置的颁发者获取 JWKS(公钥)
使用维护良好的库进行 JWT 验证。不要自己实现。规范中有足够的边缘情况,手动实现的代码几乎总是存在缺陷。
常见实现错误
除了安全问题外,几个架构性错误会使 OAuth 实现变得脆弱或难以维护。
过度使用 ID token。 团队经常将自定义声明塞入 ID token 并将其发送给 API。ID token 用于让客户端确定用户身份。使用带有适当范围的 access token 进行 API 授权。
优雅地忽略 token 过期。 当 access token 过期时,您的应用程序需要透明地刷新它。许多实现在第一次收到 401 错误时显示登录提示,而不是尝试刷新。将 token 续订功能构建到您的 HTTP 客户端层中,使其自动且对应用程序的其他部分不可见。
未实现正确的登出功能。 OAuth 和 OIDC 定义了几种登出机制:RP-initiated logout(发起方发起的登出)、back-channel logout(后通道登出)和 front-channel logout(前通道登出)。许多团队完美实现了登录功能,但忘记结束会话需要撤销 token 并通知授权服务器。不完整的登出意味着用户无法有效退出,这既是安全问题也是合规问题。
硬编码提供者配置。 使用 OIDC 发现端点(/.well-known/openid-configuration)动态获取提供者元数据。硬编码端点、JWKS URI 和支持的范围意味着每当提供者进行更改时,您的集成就会中断,而且直到生产环境出现问题时您才会发现。
库推荐
OAuth 实现的最佳安全建议是不要自己实现。使用经过实战检验的库来处理协议细节、token 管理和加密操作。
服务器端
- Node.js:
openid-client用于 OIDC 依赖方操作;jose用于 JWT/JWK/JWS 操作。两者都由 Filip Skokan 维护,他也是多个 OAuth/OIDC 规范的编辑。 - Python:
Authlib为客户端和服务器角色提供了全面的 OAuth/OIDC 实现。特别是对于 Django,django-allauth能很好地处理常见的提供者集成。 - Go:
coreos/go-oidc用于 OIDC 客户端操作,配合golang.org/x/oauth2处理 OAuth 层。 - Java/.NET: 分别使用 Spring Security OAuth2 和 Microsoft.Identity.Web。两者都有供应商支持且维护良好。
客户端(SPA 和移动应用)
- 单页应用:
oidc-client-ts是原始 oidc-client-js 的一个稳定维护的分支。对于 React,react-oidc-context使用适当的 hooks 对其进行了封装。 - 移动端: AppAuth (
AppAuth-iOS,AppAuth-Android) 实现了原生应用的当前最佳实践,包括 PKCE 和自定义 URI 方案处理。
评估库时,请检查是否有积极的维护、是否符合当前规范(OAuth 2.1, FAPI 2.0)以及是否正确支持 PKCE。最后更新于 2021 年的库可能缺少关键的安全改进。
整合实践
一个良好实现的 OAuth 2.0 和 OpenID Connect 堆栈不仅是一项安全要求,它还是一个影响用户体验、运营复杂性和与外部服务集成能力的基础。这一协议对已经成熟,规范完善,并有优秀的工具支持。问题几乎总是来自于实现上的捷径,而不是规范本身的缺陷。
从授权码授权和 PKCE 开始。使用 OIDC 发现来配置您的客户端。验证每个令牌中的所有声明。根据客户端类型适当存储令牌。实现刷新令牌轮换。使用成熟的库并保持更新。
那些将认证基础设施与数据库层或部署管道同等对待的团队,正是那些能够避免因令牌验证漏洞导致用户数据泄露的深夜事件的团队。OAuth 和 OIDC 奖励谨慎、彻底的实现。如果走捷径,它们通常会在最糟糕的时候提醒你。
Michael Sun 为 NovVista 撰写关于安全基础设施、开发者工具和系统架构的文章。可通过 X @sunjianyin 联系他。
