DevToolBox免费
博客

OAuth 2.0完整指南:授权码、PKCE、OpenID Connect、JWT和安全最佳实践

14 分钟阅读作者 DevToolBox
TL;DR

OAuth 2.0 是一个委托授权框架——它让用户能够授权第三方应用有限访问其资源,而无需共享密码。对于 SPA 和移动应用使用授权码 + PKCE 流程,机器间通信使用客户端凭证流程,当需要身份认证(用户是谁,而不仅仅是能访问什么)时,在 OAuth 2.0 之上使用 OpenID Connect(OIDC)。

核心要点
  • OAuth 2.0 用于授权(访问权限);OpenID Connect 在其之上添加了认证(身份)功能。
  • 对于浏览器和移动应用,始终使用授权码 + PKCE 流程——隐式流程已被废弃。
  • 访问令牌应短期有效(15 分钟);刷新令牌可以长期有效,但需配合轮换机制。
  • 在 httpOnly Cookie 中存储令牌以防止 XSS;使用 CSRF 令牌防止 CSRF 攻击。
  • 始终验证 state 参数,以防止 OAuth 回调中的 CSRF 攻击。
  • JWT 访问令牌是自包含的——在每次请求时验证签名、过期时间、颁发者和受众。

OAuth 2.0 是什么?为什么要创建它?

在 OAuth 出现之前,委托 API 访问权限非常痛苦。如果你想让第三方应用访问你的 Google Drive,你必须把 Google 用户名和密码都给它。该应用拥有对你整个账户的完全访问权限,你无法在不更改密码的情况下撤销访问,第三方可以做任何人类能做的事情。

OAuth 2.0(RFC 6749,2012 年发布)通过委托模型解决了这个问题。用户不共享凭证,而是授予第三方应用一个有限范围的访问令牌。用户批准特定权限(作用域),令牌自动过期,用户可以随时撤销访问权限,无需更改密码。

OAuth 2.0 定义了四个角色:资源所有者(用户)、客户端(第三方应用)、授权服务器(颁发令牌的服务,如 Google 的认证服务器)和资源服务器(持有受保护资源的 API,如 Google Drive API)。这些角色通过一系列重定向和后端通道请求进行通信。

OAuth 2.0 的关键洞见是,用户的凭证永远不会到达第三方应用。用户直接向授权服务器(其信任的服务)进行认证,只有生成的令牌才会交给客户端。

OAuth 2.0 vs OAuth 1.0 vs OpenID Connect

OAuth 1.0(2007 年)要求对每个 API 调用进行复杂的密码学请求签名(HMAC-SHA1)。每个请求都必须包含时间戳、nonce 和从请求参数与共享密钥计算出的签名。虽然安全,但实现起来极其困难,开发体验很差。

OAuth 2.0 放弃了强制性的请求签名,转而使用 TLS(HTTPS)进行传输安全。这使实现更简单,但通过多种授权类型引入了新的复杂性。OAuth 2.0 是一个框架,而非协议——不同提供商的实现可能差异显著。

OpenID Connect(OIDC)是构建在 OAuth 2.0 之上的轻量级身份层。OAuth 2.0 只处理授权(该应用能否访问此资源?),OIDC 在此基础上添加了认证(这个用户是谁?)。OIDC 引入了 ID 令牌——一个包含 name、email 和头像等身份声明的 JWT。如果你需要知道用户是谁(而不仅仅是他们授权了你的应用),请使用 OIDC。

快速对比

ProtocolPurposeToken FormatBest For
OAuth 1.0AuthorizationOpaqueLegacy systems requiring request signing
OAuth 2.0AuthorizationOpaque or JWTAPI access delegation, modern apps
OpenID ConnectAuthentication + AuthorizationJWT (ID token)User identity + API access
SAML 2.0Authentication + AuthorizationXML AssertionEnterprise SSO with Active Directory

核心概念:角色、作用域和令牌

OAuth 2.0 的四个角色

每次 OAuth 2.0 交互都涉及四个角色。资源所有者通常是拥有数据的终端用户。客户端是请求访问权限的应用程序(你的 Web 应用、移动应用或后端服务)。授权服务器在认证资源所有者并获得授权后颁发访问令牌。资源服务器托管受保护的资源,并在每次请求时验证访问令牌。

Resource Owner

The user who owns the data and grants access

Client

The application requesting access to the resource

Authorization Server

Issues tokens after authenticating the user

Resource Server

Hosts protected resources, validates tokens

作用域(Scopes)

作用域定义了正在请求的权限。它们是客户端请求、用户批准的空格分隔字符串。示例包括 read:user、repo、openid、email 和 profile。设计良好的 OAuth 服务器使用细粒度作用域,让用户可以授予最小必要权限。始终只请求应用实际需要的作用域。

scope=openid email profile read:user repo write:calendar

令牌类型

OAuth 2.0 使用三种类型的令牌。访问令牌是短期凭证(通常 15-60 分钟),客户端在 API 请求中使用。刷新令牌是长期凭证(数天到数月),用于在不重新认证的情况下获取新的访问令牌。ID 令牌(仅限 OIDC)是包含用户身份声明的 JWT——供客户端读取,不应发送给资源服务器。

授权类型:该使用哪种流程?

OAuth 2.0 为不同的客户端类型和使用场景定义了多种授权类型(流程)。选错授权类型是最常见的 OAuth 错误。

授权码流程

对于服务端 Web 应用最广泛使用、最安全的流程。客户端将用户重定向到授权服务器,用户认证并同意后,授权服务器带着短期授权码将用户重定向回客户端。客户端随后通过直接的服务器对服务器 POST 请求将此授权码换取令牌。访问令牌永远不会接触浏览器。

适用场景:你有一个可以安全存储 client_secret 的服务端组件。Node.js、Python、Ruby、Java 和 PHP Web 应用都符合条件。后端通道令牌交换防止访问令牌出现在浏览器历史记录或日志中。

隐式流程(已废弃)

隐式流程是为无法发起服务端请求的 SPA 设计的。授权服务器直接在 URL 片段(#access_token=...)中返回访问令牌。这很方便但很危险——URL 片段中的令牌会出现在浏览器历史记录、Referrer 头和服务器日志中。隐式流程现已被 RFC 9700 废弃。对于所有基于浏览器的应用,请改用授权码 + PKCE。

客户端凭证流程

用于无用户参与的机器间(M2M)通信。客户端使用其 client_id 和 client_secret 直接向授权服务器进行认证,并获取访问令牌。适用于后端服务、定时任务、微服务间通信和 CI/CD 流水线。没有重定向,没有用户交互。

// Client Credentials Flow — Machine-to-Machine
// Node.js example

async function getM2MToken() {
  const response = await fetch('https://auth.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      scope: 'read:reports write:data',
    }),
  });

  const { access_token, expires_in } = await response.json();

  // Cache the token until it expires (minus a buffer)
  tokenCache.set('m2m_token', {
    token: access_token,
    expiresAt: Date.now() + (expires_in - 60) * 1000, // 60s buffer
  });

  return access_token;
}

async function getCachedM2MToken() {
  const cached = tokenCache.get('m2m_token');
  if (cached && cached.expiresAt > Date.now()) {
    return cached.token;
  }
  return getM2MToken();
}

// Usage in a service call
async function fetchInternalData() {
  const token = await getCachedM2MToken();
  const res = await fetch('https://internal-api.example.com/reports', {
    headers: { Authorization: 'Bearer ' + token },
  });
  return res.json();
}

设备授权流程

专为输入能力有限的设备(智能电视、游戏机、CLI 工具)设计。设备显示一个短代码和一个 URL。用户在手机或电脑上访问该 URL,输入代码并认证。设备轮询授权服务器直到用户完成认证。被 GitHub CLI 和 AWS CLI 等工具使用。

授权码 + PKCE(SPA 和移动端推荐)

PKCE(Proof Key for Code Exchange,发音为"pixy")是授权码流程的扩展,无需公共客户端提供 client_secret。客户端生成一个随机的 code_verifier,将其哈希为 code_challenge,并在授权请求中发送该 challenge。在用授权码换取令牌时,客户端发送原始的 code_verifier。服务器验证 hash(code_verifier) == code_challenge,证明令牌请求来自发起流程的同一客户端。

适用于:所有单页应用(SPA)、所有移动应用(iOS/Android)、所有原生桌面应用,以及任何无法安全存储 client_secret 的客户端。截至 2024 年,PKCE 也被推荐用于机密客户端(服务端应用)作为额外的保护层。
// Authorization Code + PKCE — Complete Implementation
// Works for SPAs, mobile apps, and server-side apps

// Step 1: Generate PKCE code_verifier and code_challenge
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// Step 2: Build authorization URL and redirect
async function startOAuthFlow() {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = crypto.randomUUID(); // CSRF protection

  // Store verifier and state securely (sessionStorage is OK for this)
  sessionStorage.setItem('pkce_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'YOUR_CLIENT_ID',
    redirect_uri: 'https://yourapp.com/callback',
    scope: 'openid email profile',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  window.location.href = 'https://accounts.google.com/o/oauth2/v2/auth?' + params;
}

// Step 3: Handle callback — exchange code for tokens
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');
  const error = params.get('error');

  if (error) throw new Error('OAuth error: ' + error);

  // Validate state to prevent CSRF
  const storedState = sessionStorage.getItem('oauth_state');
  if (state !== storedState) throw new Error('State mismatch — possible CSRF attack');

  const codeVerifier = sessionStorage.getItem('pkce_verifier');
  sessionStorage.removeItem('pkce_verifier');
  sessionStorage.removeItem('oauth_state');

  // Exchange code for tokens
  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'YOUR_CLIENT_ID',
      redirect_uri: 'https://yourapp.com/callback',
      code,
      code_verifier: codeVerifier,
    }),
  });

  const tokens = await tokenResponse.json();
  // tokens.access_token, tokens.refresh_token, tokens.id_token
  return tokens;
}
Grant TypeClient TypeUser InteractionStatus
Authorization Code + PKCESPA, Mobile, ServerYesRecommended
Authorization CodeServer-sideYesRecommended
Client CredentialsBackend serviceNo (M2M)Recommended
Device FlowTV, CLI, IoTYes (on other device)Supported
ImplicitBrowser (legacy)YesDeprecated
Password (ROPC)Trusted 1st-partyYes (credentials)Deprecated

访问令牌 vs 刷新令牌 vs ID 令牌

Access Token

访问令牌是持有者凭证——持有它的任何人都可以使用它。它们通常短期有效(15 分钟到 1 小时),以限制被盗的损失。客户端通过 Authorization: Bearer 头在每个 API 请求中包含访问令牌。访问令牌可以是不透明字符串(资源服务器通过调用 introspection 端点来验证)或自包含的 JWT(资源服务器使用公钥在本地验证)。

Refresh Token

刷新令牌是长期凭证(1 天到 1 年,或永不过期),客户端用它在当前访问令牌过期时获取新的访问令牌。与访问令牌不同,刷新令牌只发送给授权服务器,永远不发送给资源服务器。它们必须安全存储。刷新令牌轮换(每次使用刷新令牌时颁发新的)可防止被盗的刷新令牌被无限期使用。

ID Token (OIDC)

ID 令牌是 OpenID Connect 提供商颁发的 JWT。它们包含关于已认证用户的身份声明(sub、name、email、picture 等)。ID 令牌供客户端应用读取——不应发送给资源服务器。要认证 API 请求,使用访问令牌。ID 令牌回答"这个用户是谁?",而访问令牌回答"这个应用可以做什么?"

令牌生命周期与刷新

// Token refresh with rotation — Express.js backend
const jwt = require('jsonwebtoken');

// In-memory store (use Redis or database in production)
const refreshTokenStore = new Map();

app.post('/auth/token/refresh', async (req, res) => {
  const { refresh_token } = req.body;
  if (!refresh_token) {
    return res.status(400).json({ error: 'refresh_token required' });
  }

  let payload;
  try {
    payload = jwt.verify(refresh_token, process.env.REFRESH_SECRET);
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  const stored = refreshTokenStore.get(payload.sub);
  if (!stored || stored.token !== refresh_token) {
    // Token reuse detected — invalidate family
    refreshTokenStore.delete(payload.sub);
    return res.status(401).json({ error: 'Refresh token reuse detected' });
  }

  // Issue new access token
  const accessToken = jwt.sign(
    { sub: payload.sub, email: payload.email, scope: payload.scope },
    process.env.ACCESS_SECRET,
    { expiresIn: '15m', issuer: 'https://api.example.com', audience: 'https://api.example.com' }
  );

  // Rotate refresh token
  const newRefreshToken = jwt.sign(
    { sub: payload.sub, email: payload.email, scope: payload.scope },
    process.env.REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  refreshTokenStore.set(payload.sub, {
    token: newRefreshToken,
    issuedAt: Date.now(),
  });

  res.json({
    access_token: accessToken,
    refresh_token: newRefreshToken,
    token_type: 'Bearer',
    expires_in: 900,
  });
});

JWT 结构与验证

JSON Web Token(JWT,RFC 7519)是 OAuth 2.0 访问令牌和 OIDC ID 令牌最常见的格式。JWT 由三个 Base64URL 编码的部分组成,用点分隔:Header.Payload.Signature。

HeaderPayloadSignature
{"alg":"RS256","typ":"JWT"}{"sub":"1234","email":"user@example.com","exp":1735689600}RSASHA256(base64url(header)+"."+base64url(payload), privateKey)

Header 指定签名算法(alg)和令牌类型(typ)。常见算法有 HS256(HMAC-SHA256,对称——签名和验证使用同一密钥)和 RS256(RSA-SHA256,非对称——私钥签名,公钥验证)。对于分布式系统,RS256 更优,因为资源服务器可以使用公钥验证令牌,无需私钥。

Payload 包含声明——关于用户和令牌的陈述。注册声明包括 iss(颁发者)、sub(主题/用户 ID)、aud(受众)、exp(过期时间)、iat(颁发时间)和 jti(用于撤销的 JWT ID)。公共和私有声明携带应用特定数据如 role、email 和 scope。Payload 是 Base64URL 编码的,而非加密——永远不要在 JWT Payload 中放置密码等敏感数据。

签名计算方式为:对 Base64URL(Header) + "." + Base64URL(Payload) 使用 header 中指定的算法和密钥进行签名。验证签名可证明令牌由受信任方颁发且未被篡改。

JWT 验证清单

  • 使用正确的算法和密钥验证签名(从 JWKS 端点获取当前公钥)
  • 检查 exp——拒绝 exp < 当前时间的令牌
  • 检查 iss——颁发者必须与你的预期授权服务器 URL 匹配
  • 检查 aud——受众必须包含你的 API 标识符
  • 检查 nbf——如果存在,令牌在此时间之前不得使用
  • 检查 header 中的算法是否与预期算法匹配(防止算法混淆攻击)
// JWT verification — Node.js with jose library (recommended)
import { createRemoteJWKSet, jwtVerify } from 'jose';

// Fetch public keys from Authorization Server's JWKS endpoint
const JWKS = createRemoteJWKSet(
  new URL('https://accounts.google.com/.well-known/jwks.json')
);

async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://accounts.google.com',
    audience: 'https://api.yourapp.com',
    algorithms: ['RS256'],
  });

  // payload is verified — access claims safely
  return {
    userId: payload.sub,
    email: payload.email,
    scope: payload.scope,
    expiresAt: new Date(payload.exp * 1000),
  };
}

// Express.js middleware
async function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Bearer token required' });
  }
  try {
    const token = header.slice(7);
    req.user = await verifyAccessToken(token);
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token', details: err.message });
  }
}

// Python — JWT verification with PyJWT
"""
import jwt
from jwt import PyJWKClient

jwks_client = PyJWKClient("https://accounts.google.com/.well-known/jwks.json")

def verify_token(token: str) -> dict:
    signing_key = jwks_client.get_signing_key_from_jwt(token)
    payload = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        audience="https://api.yourapp.com",
        issuer="https://accounts.google.com",
        options={"verify_exp": True}
    )
    return payload
"""

实现示例:Node.js 和 Python

Node.js:使用 Passport.js 的完整 OAuth 2.0 服务器

// Node.js / Express — OAuth 2.0 with Passport.js
// npm install passport passport-google-oauth20 express-session

const passport = require('passport');
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const session = require('express-session');

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 24 * 3600 * 1000 },
}));
app.use(passport.initialize());
app.use(passport.session());

passport.use(new GoogleStrategy({
  clientID: process.env.GOOGLE_CLIENT_ID,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  callbackURL: 'https://yourapp.com/auth/google/callback',
  scope: ['openid', 'email', 'profile'],
}, async (accessToken, refreshToken, profile, done) => {
  // Find or create user in database
  let user = await db.users.findOne({ googleId: profile.id });
  if (!user) {
    user = await db.users.create({
      googleId: profile.id,
      email: profile.emails[0].value,
      name: profile.displayName,
      picture: profile.photos[0].value,
    });
  }
  // Store tokens if you need to call Google APIs on behalf of user
  await db.tokens.upsert({
    userId: user.id,
    accessToken,          // short-lived Google access token
    refreshToken,         // long-lived, only sent on first auth
  });
  return done(null, user);
}));

passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
  const user = await db.users.findById(id);
  done(null, user);
});

// Routes
app.get('/auth/google', passport.authenticate('google'));
app.get('/auth/google/callback',
  passport.authenticate('google', { failureRedirect: '/login' }),
  (req, res) => res.redirect('/dashboard')
);
app.get('/auth/logout', (req, res) => {
  req.logout(() => res.redirect('/'));
});

// Protected route
app.get('/api/me', ensureAuthenticated, (req, res) => {
  res.json({ user: req.user });
});

function ensureAuthenticated(req, res, next) {
  if (req.isAuthenticated()) return next();
  res.status(401).json({ error: 'Not authenticated' });
}

Python:FastAPI + OAuth 2.0(GitHub)

# Python FastAPI — GitHub OAuth 2.0
# pip install fastapi httpx python-dotenv

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import RedirectResponse
import httpx
import secrets
import os

app = FastAPI()
state_store = {}  # Use Redis in production

GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET")
REDIRECT_URI = "https://yourapp.com/auth/github/callback"

@app.get("/auth/github")
async def github_login():
    state = secrets.token_urlsafe(32)
    state_store[state] = True
    params = {
        "client_id": GITHUB_CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": "read:user user:email",
        "state": state,
    }
    url = "https://github.com/login/oauth/authorize?" + "&".join(
        f"{k}={v}" for k, v in params.items()
    )
    return RedirectResponse(url)

@app.get("/auth/github/callback")
async def github_callback(code: str, state: str):
    # Validate state to prevent CSRF
    if state not in state_store:
        raise HTTPException(status_code=400, detail="Invalid state parameter")
    del state_store[state]

    async with httpx.AsyncClient() as client:
        # Exchange code for access token
        token_res = await client.post(
            "https://github.com/login/oauth/access_token",
            json={
                "client_id": GITHUB_CLIENT_ID,
                "client_secret": GITHUB_CLIENT_SECRET,
                "code": code,
                "redirect_uri": REDIRECT_URI,
            },
            headers={"Accept": "application/json"},
        )
        token_data = token_res.json()
        access_token = token_data["access_token"]

        # Fetch user profile
        user_res = await client.get(
            "https://api.github.com/user",
            headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
        )
        user = user_res.json()

    # Create session or JWT for your app
    return {"user_id": user["id"], "login": user["login"], "email": user["email"]}

OAuth 2.0 安全最佳实践

对公共客户端始终使用 PKCE

任何无法安全存储 client_secret 的客户端都必须使用 PKCE。这包括所有基于浏览器的应用和所有移动/桌面应用。PKCE 防止授权码拦截攻击,即攻击者拦截回调 URL 并窃取授权码。没有 PKCE,被盗的授权码可以被换取令牌。

验证 state 参数

state 参数是 OAuth 流程中的主要 CSRF 防御手段。在重定向前生成密码学随机值并存储在 session 中,回调到来时验证它完全匹配。拒绝任何 state 缺失或不匹配的回调。不验证 state 会使用户暴露于 CSRF 攻击,攻击者可以将自己的 OAuth 码与受害者账户关联。

令牌存储策略

对于服务端应用,将令牌存储在服务端 session(Redis 支持)中,永远不要存在客户端。对于 SPA,访问令牌使用内存存储,刷新令牌使用 httpOnly Cookie。永远不要将令牌存储在 localStorage——XSS 攻击可以窃取它们。

短期访问令牌 + 刷新令牌轮换

将访问令牌设置为 15-60 分钟过期。使用刷新令牌轮换,每次刷新请求都颁发新的刷新令牌并使之前的令牌失效。如果刷新令牌被使用两次(被窃取的令牌被攻击者使用),服务器检测到重用并使整个令牌族失效,保护用户。

严格的重定向 URI 验证

在授权服务器注册精确的重定向 URI(不是通配符模式)。服务器应拒绝任何 redirect_uri 与注册 URI 不完全匹配的授权请求。redirect_uri 中的开放重定向漏洞允许攻击者拦截授权码。永远不允许包含用户提供数据的 redirect_uri 值。

// Security checklist implementation

// 1. Validate redirect_uri strictly (server-side)
const ALLOWED_REDIRECT_URIS = [
  'https://yourapp.com/auth/callback',
  'https://staging.yourapp.com/auth/callback',
];

function validateRedirectUri(uri) {
  return ALLOWED_REDIRECT_URIS.includes(uri);
}

// 2. Validate state parameter
function generateState() {
  return Buffer.from(crypto.randomBytes(32)).toString('base64url');
}

function validateState(received, stored) {
  if (!received || !stored) return false;
  // Use constant-time comparison to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(received),
    Buffer.from(stored)
  );
}

// 3. Token introspection — verify opaque tokens
async function introspectToken(token) {
  const res = await fetch('https://auth.example.com/oauth/introspect', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': 'Basic ' + Buffer.from(
        process.env.CLIENT_ID + ':' + process.env.CLIENT_SECRET
      ).toString('base64'),
    },
    body: new URLSearchParams({ token }),
  });
  const data = await res.json();
  if (!data.active) throw new Error('Token is not active');
  return data;
}

// 4. Revoke tokens on logout
async function revokeToken(token, tokenTypeHint = 'access_token') {
  await fetch('https://auth.example.com/oauth/revoke', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      token,
      token_type_hint: tokenTypeHint,
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
    }),
  });
}

社交登录:Google、GitHub 和 Facebook OAuth

社交登录(使用 Google/GitHub/Facebook 登录)在底层使用 OAuth 2.0。每个提供商的作用域、端点和令牌行为略有不同。以下是三个最流行提供商的对比。

Google OAuth 2.0 / OIDCOAuth 2.0 + OIDC

Google 同时实现了 OAuth 2.0 和 OpenID Connect。授权端点为 https://accounts.google.com/o/oauth2/v2/auth。使用 openid 作用域获取 ID 令牌,email 获取用户邮件,profile 获取姓名和头像。Google 的令牌可以通过 https://www.googleapis.com/oauth2/v3/certs 的公钥进行验证。

GitHub OAuth 2.0OAuth 2.0 only

GitHub 使用经典 OAuth 2.0(非 OIDC——没有 ID 令牌)。授权端点:https://github.com/login/oauth/authorize。令牌端点:https://github.com/login/oauth/access_token。使用 read:user 和 user:email 作用域获取用户配置文件和邮件。除非撤销,GitHub 访问令牌不会过期,因此必须明确处理令牌撤销。

Facebook OAuth 2.0OAuth 2.0

Facebook Graph API 使用 OAuth 2.0。授权端点:https://www.facebook.com/v18.0/dialog/oauth。请求权限(作用域)如 email 和 public_profile。Facebook 访问令牌可以是短期的(1-2 小时),可选择使用 client secret 换取长期令牌(60 天)。使用带访问令牌的 /me 端点获取用户数据。

// Unified social login handler — Node.js
// Works with Google, GitHub, Facebook

const PROVIDERS = {
  google: {
    authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
    tokenUrl: 'https://oauth2.googleapis.com/token',
    userUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
    scope: 'openid email profile',
    clientId: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  },
  github: {
    authUrl: 'https://github.com/login/oauth/authorize',
    tokenUrl: 'https://github.com/login/oauth/access_token',
    userUrl: 'https://api.github.com/user',
    scope: 'read:user user:email',
    clientId: process.env.GITHUB_CLIENT_ID,
    clientSecret: process.env.GITHUB_CLIENT_SECRET,
  },
  facebook: {
    authUrl: 'https://www.facebook.com/v18.0/dialog/oauth',
    tokenUrl: 'https://graph.facebook.com/v18.0/oauth/access_token',
    userUrl: 'https://graph.facebook.com/me?fields=id,name,email,picture',
    scope: 'email,public_profile',
    clientId: process.env.FACEBOOK_APP_ID,
    clientSecret: process.env.FACEBOOK_APP_SECRET,
  },
};

app.get('/auth/:provider', (req, res) => {
  const provider = PROVIDERS[req.params.provider];
  if (!provider) return res.status(404).json({ error: 'Unknown provider' });

  const state = generateState();
  req.session.oauthState = state;
  req.session.oauthProvider = req.params.provider;

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: provider.clientId,
    redirect_uri: 'https://yourapp.com/auth/callback',
    scope: provider.scope,
    state,
  });
  res.redirect(provider.authUrl + '?' + params);
});

app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;
  const providerName = req.session.oauthProvider;
  const provider = PROVIDERS[providerName];

  if (!validateState(state, req.session.oauthState)) {
    return res.status(403).json({ error: 'Invalid state' });
  }

  // Exchange code for token
  const tokenRes = await fetch(provider.tokenUrl, {
    method: 'POST',
    headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: provider.clientId,
      client_secret: provider.clientSecret,
      code,
      redirect_uri: 'https://yourapp.com/auth/callback',
    }),
  });
  const { access_token } = await tokenRes.json();

  // Fetch user profile
  const userRes = await fetch(provider.userUrl, {
    headers: { Authorization: 'Bearer ' + access_token },
  });
  const profile = await userRes.json();

  // Upsert user in your database
  const user = await upsertUser(providerName, profile);
  req.session.userId = user.id;
  res.redirect('/dashboard');
});

OpenID Connect(OIDC)详解

OpenID Connect 是直接构建在 OAuth 2.0 之上的身份层。它添加了一种标准化的方式来认证用户并获取其配置文件信息。OAuth 2.0 回答"这个应用能访问此资源吗?",而 OIDC 回答"这个用户是谁?"

OIDC 在 OAuth 2.0 基础上引入了三个关键补充:ID 令牌(包含用户身份声明的 JWT)、UserInfo 端点(用于获取超出 ID 令牌容量的额外声明)和标准化的发现文档(.well-known/openid-configuration,描述提供商的端点和功能)。

标准 OIDC 声明

OIDC 在 ID 令牌 Payload 中定义了一组标准声明。sub 声明是唯一用户标识符(不要使用 email 作为用户标识符——它可能会变化)。name、given_name 和 family_name 声明提供用户的显示名称。email 和 email_verified 声明给出用户的邮件及其是否已验证。picture 声明是用户头像的 URL。

{ "sub": "10769150350006150715113082367", "iss": "https://accounts.google.com", "aud": "your-client-id.apps.googleusercontent.com", "exp": 1735689600, "iat": 1735686000, "email": "alice@example.com", "email_verified": true, "name": "Alice Smith", "picture": "https://lh3.googleusercontent.com/...", "nonce": "abc123" }

OIDC 授权码流程

// OpenID Connect — Complete Implementation
// Using the openid-client library (recommended)
// npm install openid-client

import { Issuer, generators } from 'openid-client';

// Discovery — fetches .well-known/openid-configuration automatically
const googleIssuer = await Issuer.discover('https://accounts.google.com');

const client = new googleIssuer.Client({
  client_id: process.env.GOOGLE_CLIENT_ID,
  client_secret: process.env.GOOGLE_CLIENT_SECRET,
  redirect_uris: ['https://yourapp.com/auth/callback'],
  response_types: ['code'],
});

// Generate auth URL with PKCE
app.get('/auth/login', (req, res) => {
  const codeVerifier = generators.codeVerifier();
  const codeChallenge = generators.codeChallenge(codeVerifier);
  const state = generators.state();
  const nonce = generators.nonce(); // OIDC-specific replay protection

  req.session.codeVerifier = codeVerifier;
  req.session.state = state;
  req.session.nonce = nonce;

  const url = client.authorizationUrl({
    scope: 'openid email profile',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state,
    nonce,
  });
  res.redirect(url);
});

// Handle callback
app.get('/auth/callback', async (req, res) => {
  const params = client.callbackParams(req);

  // Exchange code for tokens — validates state, nonce, and token claims
  const tokenSet = await client.callback(
    'https://yourapp.com/auth/callback',
    params,
    {
      code_verifier: req.session.codeVerifier,
      state: req.session.state,
      nonce: req.session.nonce,
    }
  );

  // tokenSet.access_token — use to call APIs
  // tokenSet.id_token — contains user identity
  // tokenSet.claims() — parsed ID token payload

  const claims = tokenSet.claims();
  // claims.sub — unique user ID (stable, use as primary key)
  // claims.email — user email
  // claims.name — display name
  // claims.picture — avatar URL
  // claims.email_verified — boolean

  const user = await upsertUser({
    oidcSub: claims.sub,
    email: claims.email,
    name: claims.name,
    picture: claims.picture,
  });

  req.session.userId = user.id;
  res.redirect('/dashboard');
});

OAuth 2.0 vs SAML vs JWT:如何选择?

三种协议主导企业身份和 API 安全领域。了解何时使用哪种协议可以省去大量架构上的痛苦。

ProtocolToken FormatComplexityIdentityBest For
OAuth 2.0Opaque / JWTMediumNoAPI authorization, 3rd-party access
OAuth 2.0 + OIDCJWT (ID token)MediumYesSocial login, modern SSO
SAML 2.0XML AssertionHighYesEnterprise SSO, Active Directory
JWT (standalone)JWTLowOptionalStateless sessions, microservices
SAML 2.0

SAML 2.0(安全断言标记语言)是一种基于 XML 的协议,专为企业单点登录(SSO)设计。它被企业身份提供商(如 Active Directory 联合服务、Okta 和 OneLogin)广泛支持。SAML 断言比 JWT 更大,基于 XML,通常使用 XML-DSig 签名。当你的客户是拥有现有 Active Directory/LDAP 基础设施的企业,需要支持在企业身份下访问多个服务的 SSO 时,SAML 是正确的选择。

JWT (standalone)

JWT 本身不是认证协议——它是一种令牌格式。JWT 可以用作 OAuth 2.0 访问令牌、OIDC ID 令牌或独立的无状态 session 令牌。混淆来自于许多系统在没有实现 OAuth 2.0 的情况下使用 JWT 进行认证。对于内部系统这是可以的,但缺少 OAuth 2.0 提供的标准化委托、作用域和令牌撤销功能。

OAuth 2.0

OAuth 2.0 是 API 授权、第三方集成和构建身份平台(与 OIDC 结合时)的正确选择。它是三者中最灵活的,并受到所有主要身份提供商的支持。

常见 OAuth 漏洞与防御

OAuth 回调中的 CSRF 攻击

攻击者发起 OAuth 流程,获得有效的授权码,然后诱骗受害者点击带有攻击者授权码的回调 URL。如果使用受害者的 session 来换取令牌,受害者的账户将与攻击者的身份关联。防御:始终验证 state 参数。state 值必须与重定向前存储在用户 session 中的值完全匹配。

通过 redirect_uri 的开放重定向

如果授权服务器不严格验证 redirect_uri,攻击者可以提供恶意 URI,在授权码被重定向到那里时窃取它。防御:注册精确的 URI(不使用通配符),在服务端严格验证,永远不接受包含用户控制数据的 redirect_uri 值。

通过 Referrer 泄露授权码/令牌

如果回调页面加载第三方资源(分析、广告、字体),URL 中的授权码可能通过 Referrer 头泄露。防御:使用 Referrer-Policy: no-referrer 头,提取后立即处理并从 URL 中删除授权码,使用 PKCE 确保被盗的授权码在没有 code_verifier 的情况下无法被换取。

混淆代理/受众混淆

攻击者诱骗服务接受为不同受众颁发的令牌。例如,使用为应用 A 颁发的 Google ID 令牌向应用 B 认证。防御:始终验证 aud(受众)声明是否与你自己的 API 标识符匹配。永远不要接受没有受众验证的令牌。

令牌替换

攻击者将一个上下文中的令牌换到另一个上下文。例如,如果端点不检查作用域,使用为读取作用域颁发的访问令牌调用写入端点。防御:在每个受保护端点验证作用域,而不仅仅是在网关处。

// Scope validation middleware — Express.js
function requireScope(...requiredScopes) {
  return (req, res, next) => {
    const tokenScopes = (req.user.scope || '').split(' ');
    const hasAllScopes = requiredScopes.every(s => tokenScopes.includes(s));
    if (!hasAllScopes) {
      return res.status(403).json({
        error: 'insufficient_scope',
        required: requiredScopes,
        granted: tokenScopes,
      });
    }
    next();
  };
}

// Usage
app.get('/api/reports', requireAuth, requireScope('read:reports'), getReports);
app.post('/api/data', requireAuth, requireScope('write:data'), createData);
app.delete('/api/data/:id', requireAuth, requireScope('write:data', 'admin'), deleteData);

// Audience validation in token verification
async function verifyTokenStrict(token) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: process.env.EXPECTED_ISSUER,
    audience: process.env.API_IDENTIFIER, // e.g. "https://api.yourapp.com"
    algorithms: ['RS256'],
    clockTolerance: 30, // 30 second clock skew allowance
  });
  return payload;
}

试试我们的相关安全工具

FAQ

OAuth 2.0 和 OpenID Connect 有什么区别?

OAuth 2.0 是一个授权框架,允许应用代表用户请求对资源的访问权限。它没有定义如何认证用户或交换用户身份信息。OpenID Connect(OIDC)是构建在 OAuth 2.0 之上的轻量级身份层,添加了用户认证功能。OIDC 引入了 ID 令牌(包含 name、email 和头像等身份声明的 JWT)和标准化的 UserInfo 端点。当你只需要资源访问时使用 OAuth 2.0;当你需要知道用户是谁时使用 OIDC。

我的 SPA 应该使用隐式流程还是授权码 + PKCE?

对于 SPA,始终使用授权码 + PKCE。隐式流程(访问令牌直接在 URL 片段中返回)已被 OAuth 2.0 安全最佳当前实践(RFC 9700)废弃。隐式流程在浏览器历史记录和 Referrer 头中暴露令牌。PKCE 解决了同样的问题(公共客户端不需要 client_secret),但没有安全问题。

应该在浏览器中的哪里存储 OAuth 令牌?

最安全的方法是将刷新令牌存储在 httpOnly、Secure、SameSite=Strict Cookie 中(JavaScript 无法访问,因此受到 XSS 保护),并将访问令牌只保存在内存中(JavaScript 变量或闭包)。永远不要将刷新令牌存储在 localStorage——你页面上的任何 XSS 漏洞都可以窃取它们。如果使用 Cookie 存储,请使用 SameSite 属性和 CSRF 令牌实现 CSRF 保护。

刷新令牌和访问令牌轮换是如何工作的?

访问令牌是短期的(15 分钟到 1 小时)。当它们过期时,客户端使用其刷新令牌向授权服务器请求新的访问令牌。通过刷新令牌轮换,每次刷新请求都颁发新的刷新令牌并使之前的令牌失效。如果被盗的刷新令牌在合法用户已经使用后再次被使用,服务器检测到重用(旧令牌已失效)并撤销整个令牌族,强制重新认证。这限制了被盗刷新令牌的损害窗口。

PKCE 是什么,为什么它是必要的?

PKCE(Proof Key for Code Exchange)是一个扩展,可以防止无法安全存储 client_secret 的公共客户端(SPA、移动应用)遭受授权码拦截攻击。客户端生成随机的 code_verifier,将其哈希为 code_challenge,并在授权请求中发送 challenge。换取令牌时,客户端发送原始的 verifier。服务器对其哈希并检查是否与存储的 challenge 匹配。拦截授权码的攻击者无法在没有原始 code_verifier 的情况下换取令牌。

OAuth 2.0 作用域和角色有什么区别?

OAuth 2.0 中的作用域代表客户端应用代表用户请求的权限(如 read:contacts、write:calendar)。它们是粗粒度的,由资源服务器定义。角色(RBAC——基于角色的访问控制)通常是用户级属性(admin、editor、viewer),决定用户在系统中可以做什么。两者通常一起使用:OAuth 作用域限制应用可以做什么,而角色(作为访问令牌中的声明嵌入)决定用户在这些作用域内被允许做什么。

如何撤销 OAuth 访问令牌?

对于 JWT 访问令牌,真正的撤销很困难,因为它们是自包含的,在过期前有效。常见方法包括:维护在每次请求时检查的令牌撤销列表(使用 Redis 可以快速实现)、使用短期访问令牌(15 分钟)使被盗令牌快速过期,以及为刷新令牌调用授权服务器的撤销端点(RFC 7009)。对于不透明令牌,在每次请求时调用 introspection 端点——授权服务器跟踪活跃令牌,对已撤销的令牌返回 active: false。

OAuth 2.0 能替代传统的用户名/密码登录吗?

OAuth 2.0 + OpenID Connect 可以完全替代传统登录。用户通过受信任的身份提供商(Google、GitHub、你自己的 OIDC 服务器)进行认证,你的应用接收经过验证的身份声明。你永远不需要存储密码。这提高了安全性(没有密码数据库可供攻击)和用户体验(没有注册表单)。但是,你增加了对身份提供商的依赖。为了最大弹性,同时支持社交登录和本地密码选项,或使用同时处理两者的身份服务如 Auth0 或 Supabase Auth。

𝕏 Twitterin LinkedIn
这篇文章有帮助吗?

保持更新

获取每周开发技巧和新工具通知。

无垃圾邮件,随时退订。

试试这些相关工具

JWTJWT DecoderB→Base64 Encoder🔐HMAC Generator

相关文章

API设计指南:REST最佳实践、OpenAPI、认证、分页和缓存

掌握API设计。涵盖REST原则、版本控制策略、JWT/OAuth 2.0认证、OpenAPI/Swagger规范、速率限制、RFC 7807错误处理、分页模式、ETags缓存以及REST vs GraphQL vs gRPC vs tRPC对比。

NestJS完整指南:模块、控制器、服务、依赖注入、TypeORM、JWT认证和测试

从零掌握NestJS。涵盖模块、控制器、服务、Provider、依赖注入、TypeORM/Prisma数据库集成、JWT认证、守卫、管道、拦截器、异常过滤器以及Jest单元测试和e2e测试。

Web安全指南:OWASP Top 10、XSS、CSRF、SQL注入和最佳实践

掌握Web安全。涵盖OWASP Top 10、XSS、CSRF、SQL注入、认证安全、JWT、HTTPS、安全响应头、CORS、限速和漏洞测试完整指南。