认证方案设计

系统讲解Session-based认证与Token-based认证的原理、适用场景和安全权衡。

认证方案设计

概述

认证是Web应用安全的基石,但"认证"并非单一的技术问题,而是涉及用户体验、系统架构、安全策略、运维成本等多个维度的系统工程。选择错误的认证方案可能导致严重的性能问题、安全漏洞,或者糟糕的用户体验。

在过去的二十年中,Web应用的认证方案经历了显著的演变。早期的Web应用以服务端渲染为主,Session-cookie模式几乎是唯一的选择。随着SPA(单页应用)的兴起和微服务架构的普及,Token-based认证逐渐成为主流。而今天,我们面临着更复杂的场景:移动端应用、第三方集成、微服务间认证、无刷新体验——这些都对认证方案提出了新的要求。

本节从认证的核心问题出发,深入分析Session-based认证和Token-based认证的原理、适用场景和安全考量。我们将讨论JWT的结构与安全陷阱、OAuth 2.0与PKCE的工程价值,以及如何在具体场景中做出合适的技术决策。

目标

  • 深入理解Session-based和Token-based认证的底层原理
  • 掌握JWT的结构、适用场景和已知安全陷阱
  • 理解OAuth 2.0的授权流程和使用场景
  • 掌握PKCE的原理及其对SPA安全的重要性
  • 学会根据业务场景选择合适的认证方案

知识体系

1. Session-based 认证

Session-based认证是传统的Web认证模式,源自早期的HTTP协议无状态特性。其核心思想是:服务端为每个用户会话创建一个唯一的会话标识(Session ID),并将会话数据存储在服务端(通常是内存数据库如Redis,或直接存储在数据库中)。客户端通过Cookie携带Session ID,服务端通过Session ID查找用户数据。

Session认证的工作流程清晰而直观。用户首次登录时,服务端验证用户名密码,创建Session记录,并将Session ID通过Set-Cookie头返回给浏览器。后续请求中,浏览器自动携带该Cookie,服务端通过Session ID查询到用户的会话数据,从而识别用户身份。

Session 认证流程:
Client                          Server
  │                                │
  ├── POST /login ────────────────→│
  │   { username, password }       │
  │                                ├── 验证凭证
  │                                ├── 创建 Session(存入 Redis/DB)
  │←── Set-Cookie: sid=abc123 ─────┤
  │                                │
  ├── GET /api/profile ───────────→│
  │   Cookie: sid=abc123           │
  │                                ├── 查找 Session
  │←── { user data } ─────────────┤
  │                                │
  ├── POST /logout ────────────────→│
  │                                ├── 删除 Session
  │←── Set-Cookie: sid=; Max-Age=0│

Session方案的核心优势在于可控性。由于会话数据存储在服务端,可以随时查看当前活跃的会话列表、强制终止某个会话(登出)、或者修改某个会话的权限。这种即时可控性对于需要管理用户会话的高安全要求场景至关重要。例如,检测到账户异常时,管理员可以立即撤销该用户的所有会话。

然而,Session方案也面临挑战。在分布式系统中,Session需要存储在共享存储中(如Redis集群),这增加了系统复杂性和运维成本。当系统规模扩大时,Session存储可能成为性能瓶颈。此外,跨域场景下Cookie的传输需要额外的CORS配置,移动端和第三方集成也不如Token方案灵活。

// 服务端 Session 实现
import session from 'express-session';
import RedisStore from 'connect-redis';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

app.use(
  session({
    store: new RedisStore({ client: redis }),
    name: 'sid',
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,    // 防止XSS读取Cookie
      secure: true,      // 仅在HTTPS传输
      sameSite: 'lax',   // CSRF防护
      maxAge: 24 * 60 * 60 * 1000, // 24小时
      domain: '.example.com',
    },
  })
);

Session 方案优劣:

优势 劣势
服务端可随时撤销会话 需要服务端存储(Redis/DB)
Cookie 自动携带,前端简单 跨域需要额外配置
不存在 Token 泄露风险(HttpOnly) 水平扩展需要共享 Session Store
天然防 CSRF(配合 SameSite) 移动端/第三方集成不便

2. JWT(JSON Web Token)认证

JWT是一种自包含的令牌格式,设计用于在各方之间安全地传输信息。与Session不同,JWT的验证不需要查询数据库——令牌本身包含所有验证所需的信息,服务端只需要用密钥验证签名即可。

JWT由三部分组成:Header(头部)、Payload(负载)和Signature(签名)。Header通常包含令牌类型和签名算法;Payload包含声明(claims),如用户ID、角色、过期时间等;Signature是前两部分的签名,用于防篡改。这三部分分别Base64编码后用句点连接,形成我们常见的JWT字符串。

Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTYiLCJuYW1lIjoiSm9obiIsImlhdCI6MTcxNjE1OTAyMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header: { "alg": "HS256", "typ": "JWT" }
Payload: { "sub": "123456", "name": "John", "iat": 1716159020, "exp": 1716245420 }
Signature: HMAC-SHA256(base64(header) + "." + base64(payload), secret)

JWT的核心优势是无状态验证。由于验证过程不需要查询数据库或Session存储,非常适合水平扩展的微服务架构。多个服务端实例可以独立验证JWT,无需共享Session存储。同时,JWT可以携带丰富的用户信息,减少了后续API调用的数据查询。

但JWT也带来了新的安全考量。令牌一旦签发,在到期前无法撤销。这意味着即使用户立刻更改密码或管理员封禁账户,只要JWT仍在有效期内,令牌仍然可以使用。这是一个重大的安全权衡。常见的解决方案是维护一个短期的Access Token(如15分钟)和一个长期的Refresh Token,Refresh Token存储在服务端数据库中,可以随时撤销。

// JWT 签发与验证
import jwt from 'jsonwebtoken';

// 签发
function issueTokens(user) {
  const accessToken = jwt.sign(
    {
      sub: user.id,
      email: user.email,
      role: user.role,
    },
    process.env.JWT_SECRET,
    {
      expiresIn: '15m',
      issuer: 'example.com',
      audience: 'example.com',
    }
  );

  const refreshToken = jwt.sign(
    { sub: user.id, tokenType: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  return { accessToken, refreshToken };
}

// 验证中间件
function authenticateJWT(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  const token = authHeader.substring(7);
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET, {
      issuer: 'example.com',
      audience: 'example.com',
    });
    req.user = payload;
    next();
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    return res.status(403).json({ error: 'Invalid token' });
  }
}

JWT的安全陷阱之一是算法篡改(Algorithm Confusion)。攻击者可能将JWT Header中的alg字段从HS256改为none,然后使用空密钥签名。由于旧版本的某些库会信任这种"none"算法,攻击者可以伪造任意payload。防御措施是在验证时明确指定算法,不要依赖JWT Header中的算法声明。

// ❌ 不安全的验证:信任JWT Header中的算法声明
jwt.verify(token, secret); // 攻击者可以指定 alg: 'none'

// ✅ 安全的验证:明确指定算法
jwt.verify(token, secret, { algorithms: ['HS256'] });

另一个常见错误是在JWT Payload中存储敏感信息。Payload只是Base64编码,不是加密——任何人都可以解码查看。因此,绝对不能将密码、信用卡号等敏感信息放入JWT Payload。

// ❌ 在 JWT Payload 中存放敏感信息
const badToken = jwt.sign({
  sub: user.id,
  password: user.password,   // ❌ 绝对不要,密码会被暴露
  creditCard: user.card,     // ❌ 绝对不要
}, secret);

// ✅ 只存放必要的非敏感信息
const goodToken = jwt.sign({
  sub: user.id,
  role: user.role,
}, secret, { expiresIn: '15m' });

3. Token 刷新机制

Refresh Token Rotation是一种安全最佳实践。每次使用Refresh Token获取新的Access Token时,同时颁发一个新的Refresh Token,旧的Refresh Token立即失效。这种设计可以防止攻击者窃取Refresh Token后长期使用——因为攻击者只能用一次,用完就作废了。

配合刷新令牌的机制,服务端可以检测到"令牌重用"。如果一个Refresh Token被使用两次(正常情况下不应该发生),说明可能存在令牌被盗用的情况。此时服务端应该采取防御措施:撤销该用户的所有令牌,强制用户重新登录。

// Refresh Token Rotation
async function refreshTokens(refreshToken) {
  try {
    const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);

    // 检查 Refresh Token 是否在黑名单中(已被使用或撤销)
    const isRevoked = await redis.get(`revoked:${refreshToken}`);
    if (isRevoked) {
      // Refresh Token 被重用 → 可能被盗,撤销所有 Token
      await revokeAllUserTokens(payload.sub);
      throw new Error('Token reuse detected');
    }

    // 将旧 Refresh Token 加入黑名单
    await redis.set(`revoked:${refreshToken}`, '1', 'EX', 7 * 86400);

    // 签发新的 Token 对
    const user = await getUserById(payload.sub);
    return issueTokens(user);
  } catch (error) {
    throw new Error('Invalid refresh token');
  }
}

4. OAuth 2.0

OAuth 2.0是一种授权框架,而非认证协议。它解决的问题是:如何让第三方应用获取用户授权,访问用户在资源服务器上的数据,而不需要用户提供密码

最典型的场景是"使用Google账号登录"。用户点击"使用Google登录"按钮后,被重定向到Google的授权页面。用户同意后,Google返回一个授权码给原网站。原网站用授权码换取访问令牌(Access Token),然后用访问令牌获取用户的Google Profile信息。这就是OAuth 2.0的Authorization Code Flow。

Client          Auth Server        Resource Server
  │                  │                    │
  ├── 重定向到授权页面 →│                    │
  │                  │                    │
  │←── 授权码(code) ──┤                    │
  │                  │                    │
  ├── code + secret →│                    │
  │                  │                    │
  │←── access_token ─┤                    │
  │                  │                    │
  ├── 携带 token 请求资源 ──────────────────→│
  │                  │                    │
  │←── 资源数据 ─────────────────────────────┤

OAuth 2.0有多种授权流程,适用于不同场景:

  • Authorization Code Flow:适用于有后端服务器的Web应用,授权码在服务端交换令牌,安全性最高。
  • Implicit Flow(已废弃):适用于纯前端SPA,但存在令牌泄露风险。
  • Client Credentials Flow:适用于机器间的认证,不涉及用户。
  • Device Code Flow:适用于CLI工具或智能电视等输入受限设备。

5. OAuth 2.0 PKCE

PKCE(Proof Key for Code Exchange)是OAuth 2.0的扩展,最初设计用于保护移动应用和SPAs中的授权代码,防止授权码被截获或替换。

在没有PKCE的情况下,授权码通过浏览器重定向传递,可能被恶意扩展或代理截获。攻击者拿到授权码后,可以立即用它换取访问令牌。PKCE通过在授权请求中附加一个只有原始客户端才知道的code_verifier,解决这个问题。

PKCE 工作流程:

1. 客户端生成随机字符串 code_verifier (128字符)
2. 计算 code_verifier 的 SHA256 哈希,取Base64url编码,得到 code_challenge
3. 授权请求中携带 code_challenge 和 code_challenge_method='S256'
4. 用户授权后,授权服务器保存 code_challenge
5. 客户端用 code_verifier 换取令牌
6. 授权服务器重新计算,比较是否与保存的 code_challenge 匹配

PKCE的精妙之处在于:即使攻击者截获了授权码,他们不知道code_verifier,无法完成令牌交换。整个过程无需预共享密钥,却实现了双向认证——服务器确认请求来自合法的客户端应用,客户端确认响应来自合法的授权服务器。

// PKCE(Proof Key for Code Exchange)
// 用于公共客户端(SPA、移动应用),无需 client_secret

// 1. 生成 code_verifier 和 code_challenge
function generatePKCE() {
  const verifier = generateRandomString(128);
  const challenge = base64URLEncode(
    await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
  );
  return { verifier, challenge };
}

// 2. 发起授权请求
function startAuth() {
  const { verifier, challenge } = generatePKCE();
  sessionStorage.setItem('pkce_verifier', verifier);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid profile email',
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state: generateRandomString(32),
  });

  window.location.href = `${AUTH_SERVER}/authorize?${params}`;
}

// 3. 处理回调,用 code 换 token
async function handleCallback(code) {
  const verifier = sessionStorage.getItem('pkce_verifier');

  const response = await fetch(`${AUTH_SERVER}/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier,
    }),
  });

  const tokens = await response.json();
  return tokens; // { access_token, refresh_token, id_token }
}

6. 方案选择决策

选择认证方案不是非此即彼的技术选型,而应该基于具体业务场景。以下决策树可以帮助你做出合理选择:

认证方案选择:
┌───────────────────────────────┐
│ 是传统 Web 应用(SSR)吗?      │
└───┬───────────────────────────┘
  Yes → Session-based(推荐)
  No ↓
┌───────────────────────────────┐
│ 是 SPA + 同域 API 吗?         │
└───┬───────────────────────────┘
  Yes → Session 或 JWT(HttpOnly Cookie)
  No ↓
┌───────────────────────────────┐
│ 需要跨域/微服务/移动端?         │
└───┬───────────────────────────┘
  Yes → JWT(短期 Access + 长期 Refresh)
  No ↓
┌───────────────────────────────┐
│ 需要第三方登录?                │
└───┬───────────────────────────┘
  Yes → OAuth 2.0 + PKCE

关键考量因素包括:

用户类型:内部系统用户更适合Session,外部终端用户更适合JWT或OAuth。

安全要求:高安全要求场景(如金融系统)需要能够即时撤销会话的能力,Session或带黑名单的JWT更合适。

系统架构:微服务架构天然适合JWT,因为各服务可以独立验证;传统单体或SSR应用更适合Session。

用户体验:涉及"记住登录"、"第三方登录"、"社交分享"等场景时,JWT和OAuth更灵活。

7. Token 存储方案对比

无论选择哪种认证方案,Token的存储安全都至关重要。不同的存储位置有不同的安全特性,需要根据场景权衡。

存储位置 XSS 安全 CSRF 安全 适用场景
HttpOnly Cookie 需 SameSite SSR / 同域 SPA
localStorage 不推荐存敏感 Token
sessionStorage 临时 Token
内存(变量) SPA,刷新后丢失
Service Worker 高安全场景

HttpOnly Cookie是Session认证的标准存储方式。HttpOnly标志防止JavaScript读取Cookie(XSS无法获取),配合SameSite属性可以防止CSRF攻击。但这种方式仅限于同源Cookie,跨域场景需要额外配置。

localStorage曾是SPA认证的主流选择,但它存在XSS风险——攻击者通过XSS漏洞可以读取localStorage中的Token。现代安全指南更推荐将Token存储在内存中,配合刷新机制。虽然页面刷新后需要重新获取Token,但显著降低了Token泄露风险。

实战练习

练习 1:Session vs JWT 对比

分别使用Session和JWT实现同一个登录功能,对比开发体验和安全特性。思考:在你的业务场景中,哪种方案的运维成本更低?哪种方案的安全边界更清晰?

练习 2:Token 刷新机制

实现完整的Access Token + Refresh Token刷新流程,包括Token Rotation和重用检测。记录刷新过程中可能出现的边界情况,设计异常处理策略。

练习 3:OAuth 2.0 PKCE 集成

实现Google或GitHub OAuth 2.0登录,使用PKCE保护授权码交换流程。对比有无PKCE时的安全性差异,理解为什么现代OAuth最佳实践推荐所有应用都使用PKCE。

延展阅读

关键术语

术语 解释
Authentication 认证,确认用户身份的过程
Authorization 授权,确定用户可以访问哪些资源
Session 服务端存储的用户会话信息,包含用户身份和状态
JWT JSON Web Token,自包含的身份声明令牌
Access Token 短期令牌,用于访问受保护资源
Refresh Token 长期令牌,用于获取新的 Access Token
OAuth 2.0 开放授权标准协议,允许第三方应用访问用户数据
PKCE Proof Key for Code Exchange,OAuth 2.0扩展,防止授权码截获攻击
Algorithm Confusion 算法混淆攻击,篡改JWT算法声明的安全漏洞
Token Rotation 令牌轮换,每次刷新时颁发新令牌并使旧令牌失效