正在加载,请稍候…

Node.js 身份验证:JWT 与 Session 完整实现指南

在 Node.js 中实现安全身份验证——无状态 JWT 配合刷新令牌、有状态 Session 配合 Redis、刷新令牌轮换、PKCE 流程及安全最佳实践。

Node.js 身份验证:JWT 与 Session 完整实现指南

JWT 与 Session:何时使用哪种

没有一种方案是普遍更好的——正确的选择取决于你的架构。

JWT Session
存储 客户端(localStorage/cookie) 服务器(Redis/DB)
可扩展性 水平(无状态) 需要共享存储
撤销 困难(短 TTL + 黑名单) 容易(从存储中删除)
载荷 携带数据 仅一个 ID
移动端友好 需要 Cookie

Node.js 身份验证:JWT 与 Session 完整实现指南插图

安全的 JWT 实现

// auth/jwt.js
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

const ACCESS_TOKEN_TTL = '15m';
const REFRESH_TOKEN_TTL = '7d';

export function generateTokenPair(userId, role) {
  const accessToken = jwt.sign(
    { sub: userId, role, type: 'access' },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: ACCESS_TOKEN_TTL, algorithm: 'HS256' }
  );
  
  const refreshToken = jwt.sign(
    { sub: userId, jti: crypto.randomUUID(), type: 'refresh' },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: REFRESH_TOKEN_TTL, algorithm: 'HS256' }
  );
  
  return { accessToken, refreshToken };
}

export function verifyAccessToken(token) {
  return jwt.verify(token, process.env.JWT_ACCESS_SECRET);
}

export function verifyRefreshToken(token) {
  return jwt.verify(token, process.env.JWT_REFRESH_SECRET);
}

刷新令牌轮换

// routes/auth.js
import { redis } from '../lib/redis.js';

app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.cookies;
  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }
  
  try {
    const payload = verifyRefreshToken(refreshToken);
    const { sub: userId, jti } = payload;
    
    // 检查令牌是否已被撤销(使用过或列入黑名单)
    const isRevoked = await redis.get(`revoked:${jti}`);
    if (isRevoked) {
      // 可能令牌被盗——使该用户的所有刷新令牌失效
      await redis.del(`user:${userId}:refresh_tokens`);
      return res.status(401).json({ error: 'Token reuse detected' });
    }
    
    // 撤销已使用的刷新令牌
    await redis.set(
      `revoked:${jti}`,
      '1',
      'EX',
      7 * 24 * 60 * 60 // 7 天
    );
    
    // 颁发新的令牌对
    const { accessToken, refreshToken: newRefreshToken } = generateTokenPair(userId);
    
    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000,
    });
    
    return res.json({ accessToken });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});

Node.js 身份验证:JWT 与 Session 完整实现指南插图

认证中间件

export function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }
  
  const token = authHeader.slice(7);
  try {
    const payload = verifyAccessToken(token);
    req.user = { id: payload.sub, role: payload.role };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// 基于角色的访问控制
export function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user?.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

基于 Session 的认证(使用 Redis)

import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  name: 'sid',
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 24 * 60 * 60 * 1000,
  },
}));

// 登录
app.post('/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await verifyCredentials(email, password);
  
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // 重新生成 session 以防止 session 固定攻击
  req.session.regenerate((err) => {
    if (err) return next(err);
    req.session.userId = user.id;
    req.session.role = user.role;
    res.json({ message: 'Logged in' });
  });
});

// 登出
app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    res.clearCookie('sid');
    res.json({ message: 'Logged out' });
  });
});

Node.js 身份验证:JWT 与 Session 完整实现指南插图

使用 Argon2 进行密码哈希

import argon2 from 'argon2';

// 注册时哈希密码
export async function hashPassword(password) {
  return argon2.hash(password, {
    type: argon2.argon2id,
    memoryCost: 65536, // 64 MB
    timeCost: 3,
    parallelism: 4,
  });
}

// 登录时验证密码
export async function verifyPassword(hash, password) {
  return argon2.verify(hash, password);
}

安全检查清单

  • 使用 httpOnly + secure + sameSite Cookie 存储刷新令牌
  • 每次使用后轮换刷新令牌
  • 检测令牌重用(令牌被盗)
  • 使用 Argon2id 进行密码哈希
  • 对认证端点进行速率限制(5 次尝试 / 15 分钟)
  • 记录所有认证事件以供审计
  • 在 JWT 载荷中存储最少数据
  • 使用短生命周期的访问令牌(15 分钟)
  • 实现登出(令牌黑名单或 session 删除)