OAuth 2.0 是行业标准的授权协议,支撑着社交登录、API 集成和跨网络的安全资源共享。本综合指南将带你了解 OAuth 基础知识、JWT 令牌、OpenID Connect、会话管理以及生产安全模式,并提供 Node.js 和 Next.js 应用的实用代码示例。
OAuth 2.0 在不共享凭据的情况下委托授权。Web 应用使用授权码 + PKCE,API 使用 JWT 无状态认证,身份验证使用 OpenID Connect,始终将令牌存储在 httpOnly cookie 中。使用 RBAC 管理权限,敏感操作启用 MFA。
核心要点
- 使用授权码 + PKCE 流程,隐式授权已弃用
- 将令牌存储在 httpOnly cookie 中,避免 localStorage
- 使用 OpenID Connect 进行用户身份验证,OAuth 仅用于授权
- 实现刷新令牌轮换以检测令牌被盗
- 使用 RBAC 实现细粒度权限控制
- 敏感操作启用 MFA(TOTP 或 WebAuthn)
- 遵循 OWASP 指南验证重定向 URI 和防范注入
1. OAuth 2.0 基础知识
OAuth 2.0 是一个委托协议,允许第三方应用在不共享用户凭据的情况下访问服务器上的用户资源。它定义了四个角色:资源所有者(用户)、客户端(应用程序)、授权服务器(签发令牌)和资源服务器(托管受保护的资源)。
OAuth 2.0 支持多种授权类型:授权码(服务端应用)、客户端凭据(机器对机器)、设备码(智能电视、CLI 工具)和刷新令牌(静默令牌续期)。隐式授权在 OAuth 2.1 中已被弃用,改为使用带 PKCE 的授权码流程。
关键术语:access_token 授予资源访问权限(短期,5-60 分钟),refresh_token 获取新的访问令牌(长期,数天到数月),scope 限制令牌的操作范围(read:user、write:repos),state 防止授权流程中的 CSRF 攻击。
# OAuth 2.0 Grant Types Overview
1. Authorization Code (server-side web apps)
User -> Auth Server -> Code -> Token Exchange
2. Client Credentials (machine-to-machine)
Client -> Auth Server -> Token (no user)
3. Device Code (smart TV, CLI)
Device shows code -> User enters on phone
4. Refresh Token (token renewal)
Expired Token -> Refresh Token -> New Token
# Deprecated in OAuth 2.1:
5. Implicit -> Use Auth Code + PKCE
6. Password (ROPC) -> Use Auth Code + PKCE2. 带 PKCE 的授权码流程
授权码流程是 Web 和移动应用最安全的 OAuth 授权方式。客户端将用户重定向到授权服务器,通过回调接收授权码,然后通过安全的后端通道请求交换令牌。访问令牌永远不会出现在浏览器中。
PKCE(代码交换证明密钥)增加了对授权码拦截的保护。客户端生成随机 code_verifier,通过 SHA-256 派生 code_challenge,在授权请求中发送 challenge,并在令牌交换时证明拥有 verifier。PKCE 现在是所有公共客户端的必须项,也推荐用于机密客户端。
state 参数是防止 CSRF 攻击的随机字符串。客户端生成唯一的 state 值,将其包含在授权请求中,并在收到回调时验证其是否匹配。在交换授权码之前始终验证 state。
// Authorization Code Flow with PKCE
const crypto = require('crypto');
// Step 1: Generate PKCE pair
const codeVerifier = crypto
.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier).digest('base64url');
// Step 2: Build authorization URL
const authUrl = 'https://auth.example.com/authorize'
+ '?response_type=code'
+ '&client_id=my_app'
+ '&redirect_uri=https://app.com/callback'
+ '&scope=openid%20email%20profile'
+ '&state=' + csrfToken
+ '&code_challenge=' + codeChallenge
+ '&code_challenge_method=S256';3. JWT 深度解析
JSON Web Token(JWT)是紧凑的、URL 安全的令牌,由三个 Base64URL 编码部分组成,用点号分隔:头部(算法和类型)、载荷(声明)和签名(完整性证明)。JWT 实现无状态认证——服务器无需数据库查询即可验证签名。
标准声明包括:iss(签发者)、sub(主体/用户 ID)、aud(受众)、exp(过期时间)、iat(签发时间)、nbf(生效时间)和 jti(唯一令牌 ID)。可以添加 role、permissions 和 email 等自定义声明,但保持载荷精简,因为每个请求都会发送它。
始终彻底验证 JWT 令牌:验证签名算法是否符合预期(防止算法混淆攻击),检查 exp 和 nbf 时间戳,验证 iss 和 aud 声明,拒绝具有意外或缺失声明的令牌。分布式系统使用非对称密钥(RS256/ES256)。
// JWT verification with jose library
const { jwtVerify } = require('jose');
async function verifyToken(token, publicKey) {
const { payload } = await jwtVerify(
token,
publicKey,
{
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'my-api',
maxTokenAge: '15m',
}
);
// payload.sub = user ID
// payload.role = user role
return payload;
}JWT 结构
# JWT = Header.Payload.Signature
# Header (Base64URL)
{
"alg": "RS256",
"typ": "JWT",
"kid": "key-id-2024"
}
# Payload (Base64URL)
{
"iss": "https://auth.example.com",
"sub": "user_123",
"aud": "my-api",
"exp": 1709251200,
"iat": 1709250300,
"role": "editor"
}
# Signature
# RS256(base64(header) + "." + base64(payload),
# privateKey)4. OpenID Connect (OIDC)
OpenID Connect 是构建在 OAuth 2.0 之上的身份层。OAuth 处理授权(你能访问什么),OIDC 处理认证(你是谁)。它引入了 ID Token——包含用户身份声明的 JWT——并标准化了用于获取用户资料数据的 UserInfo 端点。
OIDC 定义了标准 scope:openid(必需,返回 sub 声明)、profile(姓名、头像、地区)、email(邮箱、邮箱验证状态)、address 和 phone。ID token 始终是 JWT,必须由客户端验证。访问令牌用于调用 UserInfo 端点获取额外声明。
OIDC Discovery 允许客户端通过从提供者获取 /.well-known/openid-configuration 来自动配置端点。它返回授权端点、令牌端点、UserInfo 端点、支持的 scope 和用于令牌验证的 JWKS URI。
// Fetch OIDC Discovery configuration
const discoveryUrl =
'https://accounts.google.com'
+ '/.well-known/openid-configuration';
const config = await fetch(discoveryUrl)
.then(r => r.json());
// config.authorization_endpoint
// config.token_endpoint
// config.userinfo_endpoint
// config.jwks_uri
// config.scopes_supported:
// ["openid","email","profile"]
// config.id_token_signing_alg_values:
// ["RS256"]5. 会话管理
基于 Cookie 的会话将会话 ID 存储在 httpOnly cookie 中。服务器在存储(Redis、数据库)中维护会话状态。浏览器自动发送 Cookie,使其非常适合传统 Web 应用。为会话 cookie 设置 secure、httpOnly、sameSite=lax 和适当的 maxAge。
基于令牌的会话将 JWT 访问令牌存储在客户端。客户端在 Authorization 头中包含令牌。这种方法是无状态的,适合 SPA 和移动应用。将令牌存储在 httpOnly cookie 中(而非 localStorage)以防止 XSS 令牌窃取。
刷新令牌轮换在每次使用时签发新的刷新令牌并使旧令牌失效。如果刷新令牌被使用两次,表明令牌被盗——撤销该用户的所有令牌。设置较长的刷新令牌过期时间(7-30 天),并安全地存储在服务端或 httpOnly cookie 中。
// Refresh token rotation middleware
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
const stored = await db.tokens.findOne({
token: refreshToken, revoked: false
});
if (!stored) {
// Token reuse detected - revoke all
await db.tokens.updateMany(
{ userId: stored?.userId },
{ revoked: true }
);
return res.status(401).json({
error: 'Token reuse detected'
});
}
await db.tokens.updateOne(
{ _id: stored._id }, { revoked: true }
);
// Issue new token pair...
});6. Node.js 中的 OAuth(Passport.js)
Passport.js 是 Node.js 最流行的认证中间件,拥有 500 多种策略。它通过 passport.initialize() 和 passport.session() 中间件与 Express 集成。每种策略处理特定的认证方式(本地、OAuth、SAML)。使用提供者凭据和验证回调配置策略。
验证回调接收来自 OAuth 提供者的访问令牌、刷新令牌和用户资料。用它在数据库中查找或创建用户,关联 OAuth 账户,并返回用户对象。Passport 为会话管理序列化和反序列化用户。
// Passport.js GitHub OAuth strategy
const passport = require('passport');
const GitHubStrategy =
require('passport-github2').Strategy;
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
callbackURL: '/auth/github/callback'
},
async (accessToken, refresh, profile, done) => {
let user = await db.users.findOne({
githubId: profile.id
});
if (!user) {
user = await db.users.create({
githubId: profile.id,
name: profile.displayName,
email: profile.emails?.[0]?.value
});
}
return done(null, user);
}
));7. Next.js 中的 OAuth(Auth.js)
Auth.js(前身为 NextAuth.js)为 Next.js 应用提供完整的认证解决方案。它开箱即用支持 50 多个 OAuth 提供者,自动处理会话管理、CSRF 保护和令牌轮换。配置集中在单个认证配置文件中。
Auth.js 支持多种会话策略:JWT(默认,无状态)和数据库会话(通过 Prisma、Drizzle 或其他适配器)。使用回调自定义 JWT 载荷、会话对象和登录行为。中间件在边缘保护路由以获得最佳性能。
// auth.ts - Auth.js configuration
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
export const { handlers, auth } = NextAuth({
providers: [
GitHub({ clientId: '', clientSecret: '' }),
Google({ clientId: '', clientSecret: '' }),
],
callbacks: {
async jwt({ token, user }) {
if (user) token.role = user.role;
return token;
},
async session({ session, token }) {
session.user.role = token.role;
return session;
},
},
});8. 社交登录集成
Google OAuth 需要在 Google Cloud Console 中创建凭据。配置 OAuth 同意屏幕的 scope(openid、email、profile),设置授权重定向 URI,获取 client_id 和 client_secret。Google 返回包含已验证邮箱、姓名和头像的 ID token。
GitHub OAuth 在 Settings > Developer Settings > OAuth Apps 中配置。GitHub 通过 scope(read:user、user:email、repo)提供用户资料、邮箱和仓库访问。Apple Sign In 需要 Apple Developer 账户,提供最少的用户数据(邮箱、姓名)——姓名只在首次登录时发送,请立即存储。
// Multi-provider social login config
const providers = {
google: {
authUrl: 'https://accounts.google.com'
+ '/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com'
+ '/token',
scopes: ['openid', 'email', 'profile'],
userInfoUrl: 'https://www.googleapis.com'
+ '/oauth2/v3/userinfo',
},
github: {
authUrl: 'https://github.com'
+ '/login/oauth/authorize',
tokenUrl: 'https://github.com'
+ '/login/oauth/access_token',
scopes: ['read:user', 'user:email'],
userInfoUrl: 'https://api.github.com/user',
},
};9. 基于角色的访问控制(RBAC)
RBAC 将权限分配给角色,然后将角色分配给用户。常见角色包括 admin、editor、viewer 和自定义角色。将权限定义为细粒度操作:users:read、users:write、posts:delete。在数据库中存储角色,并在 JWT 声明中包含角色以进行无状态授权检查。
实现授权中间件,从 JWT 中提取用户角色,查找角色权限,并检查是否授予了所需权限。使用层级权限模型,admin 继承所有 editor 权限,editor 继承所有 viewer 权限。
// RBAC middleware with permission check
const ROLES = {
admin: ['users:*', 'posts:*', 'settings:*'],
editor: ['posts:read', 'posts:write',
'posts:delete', 'users:read'],
viewer: ['posts:read', 'users:read'],
};
function authorize(permission) {
return (req, res, next) => {
const userRole = req.user?.role;
const perms = ROLES[userRole] || [];
const allowed = perms.some(p =>
p === permission ||
p === permission.split(':')[0] + ':*'
);
if (!allowed) return res.status(403)
.json({ error: 'Forbidden' });
next();
};
}10. 令牌安全
永远不要将令牌存储在 localStorage 或 sessionStorage 中——它们可被页面上的任何 JavaScript 访问,容易受到 XSS 攻击。将访问令牌存储在带有 secure 和 sameSite 标志的 httpOnly cookie 中。对于 SPA,使用 BFF(Backend-for-Frontend)模式,由服务器处理令牌存储。
安全警告: localStorage 和 sessionStorage 中的令牌可被任何注入的脚本读取。一个 XSS 漏洞就能导致所有用户令牌被盗。始终使用 httpOnly cookie。
使用 SameSite cookie 属性(lax 或 strict)、自定义请求头(X-Requested-With)和同步令牌模式为基于 cookie 的令牌实现 CSRF 保护。对于 API,使用短期访问令牌(5-15 分钟)并在每次使用时轮换刷新令牌。
// Secure cookie token configuration
function setAuthCookies(res, tokens) {
res.cookie('access_token', tokens.access, {
httpOnly: true, // No JS access
secure: true, // HTTPS only
sameSite: 'lax', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 min
path: '/',
});
res.cookie('refresh_token', tokens.refresh, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7d
path: '/auth/refresh', // Limit scope
});
}11. 多因素认证(MFA)
TOTP(基于时间的一次性密码)使用共享密钥和当前时间戳生成 6 位代码。服务器生成密钥,用户使用认证器应用(Google Authenticator、Authy)扫描二维码,并在登录时输入代码。始终提供备用代码以防用户丢失设备。
WebAuthn(FIDO2)使用硬件安全密钥(YubiKey)或平台认证器(Touch ID、Windows Hello)实现无密码认证。它使用公钥密码学——私钥永远不会离开设备。WebAuthn 能抵御钓鱼攻击,因为浏览器在签署质询之前会验证来源。
// TOTP setup and verification
const { authenticator } = require('otplib');
const QRCode = require('qrcode');
// Generate secret for user
app.post('/mfa/setup', auth, async (req, res) => {
const secret = authenticator.generateSecret();
const otpauth = authenticator.keyuri(
req.user.email, 'MyApp', secret
);
const qrCode = await QRCode.toDataURL(
otpauth
);
await db.users.updateOne(
{ _id: req.user.id },
{ mfaSecret: secret, mfaEnabled: false }
);
res.json({ qrCode, secret });
});
// Verify TOTP code
const isValid = authenticator.verify({
token: userCode, secret: user.mfaSecret
});12. API 认证模式
API 密钥标识调用的应用程序,而非用户。用于服务器间通信、速率限制和使用跟踪。在 X-API-Key 头中发送密钥(永远不要放在查询参数中)。实现密钥轮换、每密钥速率限制和 scope 限制。
OAuth scope 限制访问令牌的操作。定义细粒度 scope(read:users、write:posts、admin:settings)并在中间件中验证。使用令牌桶或滑动窗口算法按客户端/用户实现速率限制。返回带有 Retry-After 头的标准 429 响应。
// API key + OAuth scope middleware
function apiAuth(requiredScope) {
return async (req, res, next) => {
const apiKey = req.headers['x-api-key'];
const bearer = req.headers.authorization;
if (apiKey) {
const client = await db.apiKeys.findOne({
key: apiKey, active: true
});
if (!client)
return res.status(401)
.json({ error: 'Invalid API key' });
if (!client.scopes.includes(requiredScope))
return res.status(403)
.json({ error: 'Insufficient scope' });
req.client = client;
} else if (bearer) {
// Verify OAuth bearer token + scope
req.user = verifyBearer(bearer);
}
next();
};
}13. 安全最佳实践
遵循 OWASP 指南:根据白名单验证所有重定向 URI(防止开放重定向攻击),使用精确字符串匹配重定向 URI(不使用通配符),全面强制 HTTPS,实施适当的令牌撤销,并记录所有认证事件以供审计追踪。
需要防范的常见漏洞:授权码注入(使用 PKCE)、通过 Referrer 头的令牌泄露(使用基于片段的重定向)、重定向 URI 验证不足(使用精确匹配)、跨站请求伪造(使用 state 参数)和不安全的令牌存储(使用 httpOnly cookie)。
安全检查清单: 始终使用 PKCE,验证 state 参数,精确匹配重定向 URI,令牌存储于 httpOnly cookie,使用短期访问令牌(15 分钟),轮换刷新令牌,全面启用 HTTPS,记录所有认证事件。
// Security headers middleware
app.use((req, res, next) => {
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// Strict transport security
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
// Content security policy
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'"
);
// Prevent MIME sniffing
res.setHeader(
'X-Content-Type-Options', 'nosniff'
);
next();
});认证方案选择指南
- 服务端 Web 应用: 授权码流程 + 服务端会话 + httpOnly cookie
- 单页应用 (SPA): 授权码 + PKCE + BFF 模式 + httpOnly cookie
- 移动应用: 授权码 + PKCE + 安全存储 (Keychain/Keystore)
- 微服务间通信: 客户端凭据流程 + JWT + mTLS
- 第三方 API 集成: API 密钥 + OAuth scope + 速率限制
- IoT / CLI 工具: 设备码流程 + 短期令牌
OAuth 流程对比
| 流程 | 适用场景 | 安全级别 | PKCE |
|---|---|---|---|
| 授权码 | 服务端 Web 应用 | 高 | 推荐 |
| 授权码 + PKCE | SPA、移动应用 | 高 | 必须 |
| 客户端凭据 | 机器对机器 | 中 | N/A |
| 设备码 | 智能电视、CLI | 中 | N/A |
| 隐式(已弃用) | 请勿使用 | 低 | N/A |
令牌存储方式对比
| 存储方式 | XSS 安全 | CSRF 安全 | 推荐 |
|---|---|---|---|
| localStorage | 否 | 是 | 不推荐 |
| sessionStorage | 否 | 是 | 不推荐 |
| httpOnly Cookie | 是 | 需 SameSite | 推荐 |
| 内存变量 | 是 | 是 | 刷新时丢失 |
常见问题
OAuth 2.0 和 OpenID Connect 有什么区别?
OAuth 2.0 是一个授权框架,授予对资源的访问权限。OpenID Connect 是 OAuth 2.0 之上的身份层,添加了认证——验证用户是谁。OAuth 回答"你能访问什么",而 OIDC 通过提供包含用户身份声明的 ID token 回答"你是谁"。
我应该使用 JWT 还是会话 Cookie 进行认证?
传统服务端渲染的 Web 应用使用会话 cookie。无状态 API、微服务和移动应用使用 JWT。对于 SPA,考虑 BFF 模式——在后端代理中将 JWT 存储在 httpOnly cookie 中,兼得 cookie 的安全性和 JWT 的灵活性。
隐式授权类型还安全吗?
不安全。隐式授权在 OAuth 2.1 中已被弃用,因为它在 URL 片段中暴露访问令牌,容易受到浏览器历史攻击和 Referrer 泄露。请改用带 PKCE 的授权码流程,它对公共和机密客户端都是安全的。
如何在浏览器应用中存储 OAuth 令牌?
永远不要将令牌存储在 localStorage 或 sessionStorage 中,它们容易受到 XSS 攻击。将它们存储在 httpOnly、secure、sameSite cookie 中。对于 SPA,使用 BFF 模式,由服务器管理令牌并为浏览器设置安全 cookie。
什么是 PKCE,我需要它吗?
PKCE(代码交换证明密钥)防止授权码拦截攻击。客户端创建随机验证器,在授权时发送 SHA-256 哈希作为挑战,并在令牌交换时证明拥有验证器。是的,你需要它——PKCE 是所有公共客户端的必须项,也推荐用于所有 OAuth 流程。
如何在不注销用户的情况下实现令牌刷新?
使用存储在 httpOnly cookie 中的刷新令牌。当访问令牌过期时(通常 5-15 分钟后),客户端或后端代理向令牌端点发送刷新令牌以获取新的访问令牌。使用拦截器实现静默刷新,在刷新令牌后重试失败的请求。
社交登录应该请求哪些 scope?
请求最少需要的 scope。社交登录通常 openid、email 和 profile 就足够了。Google 提供已验证的邮箱、姓名和头像。GitHub 使用 read:user 和 user:email。除非你的应用真正需要,否则避免请求写入 scope——用户更可能批准最小 scope 请求。
如何防范 OAuth 中的 CSRF 攻击?
在所有 OAuth 授权请求中使用 state 参数。生成加密随机字符串,将其存储在会话中,包含在授权请求中,并在收到回调时验证是否匹配。此外,在会话 cookie 上设置 SameSite=lax 或 strict,并在令牌交换请求上验证 Origin 头。