OAuth 2.0 和 OpenID Connect 构成了现代 Web 身份验证和授权的支柱。尽管它们无处不在,但这些协议经常被误解和错误配置,导致出现可预防的安全漏洞。本指南涵盖了将身份验证安全地集成到应用程序中所需的核心概念、实施模式和安全最佳实践。
OAuth 2.0 基础知识
OAuth 2.0 是一个授权框架,而不是一个身份验证协议。这种区别至关重要:OAuth 定义了客户端应用程序如何获得对受保护资源的委派访问权限,但它没有指定如何验证用户的身份。这就是 OpenID Connect 的用武之地。
该协议定义了四种角色:
| 角色 | 描述 | 示例 |
|---|---|---|
| 资源所有者 | 可以授予访问权限的实体 | 最终用户 |
| 客户 | 请求访问的应用程序 | 网络应用程序、移动应用程序 |
| 授权服务器 | 身份验证成功后颁发令牌 | Auth0、Keycloak |
| 资源服务器 | 托管受保护的资源 | API服务器 |
所有授权类型的抽象流程都是一致的:客户端请求授权,资源所有者授予同意,授权服务器颁发令牌,客户端使用这些令牌访问资源。
使用 PKCE 的授权代码流程
对于大多数应用程序(包括本机移动应用程序和单页应用程序),建议授予使用 PKCE 的授权代码流。 PKCE(代码交换证明密钥)通过加密质询将授权请求绑定到令牌请求,从而防止授权代码拦截攻击。
// Step 1: Generate code verifier and challenge
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto
.createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
// Step 2: Redirect user to authorization server
const { verifier, challenge } = generatePKCE();
const authUrl = new URL("https://authorization-server.com/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", CALLBACK_URL);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
authUrl.searchParams.set("state", crypto.randomUUID());
// Step 3: Exchange authorization code for tokens
async function exchangeCode(code, verifier) {
const response = await fetch("https://authorization-server.com/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: CALLBACK_URL,
client_id: CLIENT_ID,
code_verifier: verifier,
}),
});
return response.json(); // { access_token, refresh_token, id_token, expires_in }
}
state 参数提供 CSRF 保护。现在甚至建议服务器端 Web 应用程序使用 PKCE,因为它提供了针对授权代码拦截的深度防御。
补助金类型比较
| 补助金类型 | 使用案例 | 安全简介 |
|---|---|---|
| 授权码+PKCE | 网络、移动、SPA | 最强 - 推荐用于所有新应用 |
| 客户凭证 | 服务器到服务器、服务帐户 | 无用户上下文,安全后端通道 |
| 设备授权 | CLI、物联网、智能电视 | 要求用户访问另一台设备上的 URL |
| 授权码(无 PKCE) | 旧版服务器端应用程序 | 易受代码拦截 |
| 隐式(已弃用) | 传统 SPA | 从规范中删除 — 不再安全 |
OpenID 连接层
OpenID Connect (OIDC) 在 OAuth 2.0 之上添加了身份层。关键的添加是 id_token,这是一个 JWT,其中包含有关经过身份验证的用户的经过验证的声明。
// Verifying an ID token in Node.js
const jwksClient = require("jwks-rsa");
const jwt = require("jsonwebtoken");
const client = jwksClient({
jwksUri: "https://authorization-server.com/.well-known/jwks.json",
});
async function verifyIdToken(idToken) {
const decoded = jwt.decode(idToken, { complete: true });
const key = await client.getSigningKey(decoded.header.kid);
const signingKey = key.getPublicKey();
return jwt.verify(idToken, signingKey, {
issuer: "https://authorization-server.com",
audience: CLIENT_ID,
algorithms: ["RS256"],
});
}
标准 ID 令牌声明包括 sub(主体标识符)、iss(发行者)、aud(受众)、exp(过期)、iat(发行于)和 nonce(防止重放)。在信任 ID 令牌之前,请务必验证签名、颁发者、受众和过期时间。
ID 令牌与访问令牌
一个常见的混淆来源是 ID 令牌和访问令牌之间的区别。 ID 令牌用于身份验证——它们告诉客户端用户是谁。访问令牌用于授权——它们告诉资源服务器允许客户端做什么。
ID 令牌绝不能用于 API 授权。访问令牌可以是不透明的(资源服务器通过自省验证的随机字符串)或 JWT(带有嵌入式声明的独立的)。使用 userinfo 端点从授权服务器检索有关用户的其他声明。
刷新令牌轮换
刷新令牌轮换是一种安全机制,每次刷新操作都会返回一个新的刷新令牌并使前一个刷新令牌失效。这限制了刷新令牌被盗的机会之窗。
async function refreshAccessToken(refreshToken) {
const response = await fetch("https://authorization-server.com/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
const tokens = await response.json();
// Old refresh token is invalidated; store the new one
await secureStore.save("refresh_token", tokens.refresh_token);
return tokens.access_token;
}
包括 Auth0、Okta 和 Keycloak 在内的主要提供商现在默认启用刷新令牌轮换。与重用检测(如果重用轮换令牌则撤销所有令牌)相结合,这提供了针对令牌盗窃的强大保护。
安全最佳实践
令牌存储策略是最重要的安全决策之一。对于基于浏览器的应用程序,将访问令牌存储在内存中并使用 httpOnly cookie 来刷新令牌。切勿将令牌存储在 localStorage 中,因为同一来源的任何 JavaScript 都可以访问它。
| 储存方法 | 访问令牌 | 刷新令牌 |
|---|---|---|
| 内存中变量 | 最适合 SPA(不持久) | 不适合 |
| httpOnly cookie | 不推荐 | 最佳 — 免受 XSS |
| 本地存储 | 容易遭受 XSS | 容易遭受 XSS |
| 安全内存(WebAuthn) | 新兴方法 | 新兴方法 |
始终在服务器端验证重定向 URI,使用短期访问令牌(15-60 分钟),并将请求范围最小化到功能所需的最低限度。
结论
OAuth 2.0 和 OpenID Connect 是功能强大但微妙的协议。对所有新应用程序使用带有 PKCE 的授权代码流程,在客户端彻底验证 ID 令牌,实施刷新令牌轮换,并且绝不将令牌存储在 localStorage 中。通过遵循这些模式并了解每种授权类型的安全属性,您可以构建用户友好且可抵御常见攻击的身份验证系统。

