Node.js 认证与授权完全指南:JWT 实战

小飞兽 Node.js 52 次阅读 2026-06-12

JWT 认证原理

JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在各方之间安全传输信息。JWT 由三部分组成:Header.Payload.Signature,全部是 Base64 编码的字符串。

工作流程:
1. 用户登录 → 服务器验证密码 → 返回 JWT
2. 客户端在后续请求的 Authorization: Bearer <token> 头中携带 JWT
3. 服务器验证 JWT 签名,确认用户身份后处理请求

密码加密存储

// utils/password.js
const bcrypt = require('bcrypt');

// 盐值轮数:10-12 是安全和性能的平衡点
const SALT_ROUNDS = 12;

// 加密密码
async function hashPassword(plainPassword) {
  return await bcrypt.hash(plainPassword, SALT_ROUNDS);
}

// 验证密码
async function verifyPassword(plainPassword, hashedPassword) {
  return await bcrypt.compare(plainPassword, hashedPassword);
}

module.exports = { hashPassword, verifyPassword };

JWT 工具函数

// utils/jwt.js
const jwt = require('jsonwebtoken');
const { promisify } = require('util');

const signAsync = promisify(jwt.sign);
const verifyAsync = promisify(jwt.verify);

// 配置
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const JWT_EXPIRES_IN = '7d';       // Access Token 过期时间
const JWT_REFRESH_EXPIRES_IN = '30d'; // Refresh Token 过期时间

// 创建 Access Token
async function generateAccessToken(userId, role = 'user') {
  return await signAsync(
    { sub: userId, type: 'access', role },
    JWT_SECRET,
    { expiresIn: JWT_EXPIRES_IN }
  );
}

// 创建 Refresh Token(不包含敏感信息,过期时间更长)
async function generateRefreshToken(userId) {
  return await signAsync(
    { sub: userId, type: 'refresh' },
    JWT_SECRET,
    { expiresIn: JWT_REFRESH_EXPIRES_IN }
  );
}

// 验证 Token
async function verifyToken(token) {
  try {
    return await verifyAsync(token, JWT_SECRET);
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      throw new Error('TOKEN_EXPIRED');
    }
    if (err.name === 'JsonWebTokenError') {
      throw new Error('TOKEN_INVALID');
    }
    throw err;
  }
}

// 解码 Token(不解签名,用于调试)
function decodeToken(token) {
  return jwt.decode(token);
}

module.exports = {
  generateAccessToken,
  generateRefreshToken,
  verifyToken,
  decodeToken,
  JWT_SECRET
};

认证中间件

// middlewares/auth.js
const { verifyToken } = require('../utils/jwt');

// 主认证中间件:验证 Access Token
async function authenticate(req, res, next) {
  try {
    const authHeader = req.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res.status(401).json({ error: '未提供认证令牌' });
    }

    const token = authHeader.split(' ')[1];

    // 验证 token(会检查签名和过期时间)
    const decoded = await verifyToken(token);

    // 确保是 access token(不是 refresh token)
    if (decoded.type !== 'access') {
      return res.status(401).json({ error: '令牌类型无效' });
    }

    // 将用户信息挂载到请求对象
    req.user = {
      id: decoded.sub,
      role: decoded.role
    };

    next();
  } catch (err) {
    if (err.message === 'TOKEN_EXPIRED') {
      return res.status(401).json({ error: '令牌已过期,请刷新' });
    }
    return res.status(401).json({ error: '令牌无效' });
  }
}

// 角色授权中间件
function authorize(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: '请先登录' });
    }
    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({ error: '权限不足' });
    }
    next();
  };
}

// 速率限制中间件(登录限流,防止暴力破解)
const loginAttempts = new Map(); // 生产环境建议用 Redis

function loginRateLimit(req, res, next) {
  const ip = req.ip;
  const now = Date.now();
  const windowMs = 15 * 60 * 1000; // 15 分钟窗口

  if (!loginAttempts.has(ip)) {
    loginAttempts.set(ip, { count: 1, firstAttempt: now });
    return next();
  }

  const record = loginAttempts.get(ip);
  if (now - record.firstAttempt > windowMs) {
    // 窗口过期,重置
    loginAttempts.set(ip, { count: 1, firstAttempt: now });
    return next();
  }

  if (record.count >= 5) {
    return res.status(429).json({
      error: '登录尝试过于频繁,请 15 分钟后再试'
    });
  }

  record.count++;
  next();
}

module.exports = { authenticate, authorize, loginRateLimit };

完整认证路由

// routes/auth.js
const express = require('express');
const router = express.Router();
const { body, validationResult } = require('express-validator');
const { authenticate } = require('../middlewares/auth');
const { hashPassword, verifyPassword } = require('../utils/password');
const {
  generateAccessToken,
  generateRefreshToken,
  verifyToken
} = require('../utils/jwt');
const User = require('../models/User');

// 注册
router.post('/register',
  [
    body('email').isEmail().normalizeEmail(),
    body('password').isLength({ min: 8 }).withMessage('密码至少 8 位'),
    body('name').trim().isLength({ min: 2, max: 50 }),
  ],
  async (req, res, next) => {
    try {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
      }

      const { email, password, name } = req.body;

      // 检查邮箱是否已注册
      const existingUser = await User.findOne({ email });
      if (existingUser) {
        return res.status(409).json({ error: '该邮箱已被注册' });
      }

      // 加密密码
      const passwordHash = await hashPassword(password);

      // 创建用户
      const user = await User.create({
        email,
        passwordHash,
        name,
        role: 'user'
      });

      // 生成 Token
      const [accessToken, refreshToken] = await Promise.all([
        generateAccessToken(user._id.toString()),
        generateRefreshToken(user._id.toString())
      ]);

      // 保存 Refresh Token(支持 Token 轮换)
      user.refreshTokens.push(refreshToken);
      await user.save();

      res.status(201).json({
        message: '注册成功',
        user: { id: user._id, email: user.email, name: user.name },
        accessToken,
        refreshToken
      });
    } catch (err) {
      next(err);
    }
  }
);

// 登录
router.post('/login',
  [
    body('email').isEmail().normalizeEmail(),
    body('password').isLength({ min: 1 }),
  ],
  async (req, res, next) => {
    try {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).json({ errors: errors.array() });
      }

      const { email, password } = req.body;

      // 查找用户
      const user = await User.findOne({ email });
      if (!user) {
        return res.status(401).json({ error: '邮箱或密码错误' });
      }

      // 验证密码
      const isValid = await verifyPassword(password, user.passwordHash);
      if (!isValid) {
        return res.status(401).json({ error: '邮箱或密码错误' });
      }

      // 生成 Token
      const [accessToken, refreshToken] = await Promise.all([
        generateAccessToken(user._id.toString(), user.role),
        generateRefreshToken(user._id.toString())
      ]);

      // 保存 Refresh Token
      user.refreshTokens.push(refreshToken);
      await user.save();

      res.json({
        message: '登录成功',
        user: { id: user._id, email: user.email, name: user.name, role: user.role },
        accessToken,
        refreshToken
      });
    } catch (err) {
      next(err);
    }
  }
);

// 刷新 Token
router.post('/refresh', async (req, res, next) => {
  try {
    const { refreshToken } = req.body;
    if (!refreshToken) {
      return res.status(400).json({ error: '未提供 refresh token' });
    }

    // 验证 Refresh Token
    const decoded = await verifyToken(refreshToken);
    if (decoded.type !== 'refresh') {
      return res.status(401).json({ error: '无效的 refresh token' });
    }

    // 查找用户并确认 token 在列表中
    const user = await User.findById(decoded.sub);
    if (!user || !user.refreshTokens.includes(refreshToken)) {
      return res.status(401).json({ error: 'Refresh token 无效或已撤销' });
    }

    // 移除旧 Token,颁发新 Token(支持 Token 轮换)
    user.refreshTokens = user.refreshTokens.filter(t => t !== refreshToken);
    const [newAccessToken, newRefreshToken] = await Promise.all([
      generateAccessToken(user._id.toString(), user.role),
      generateRefreshToken(user._id.toString())
    ]);
    user.refreshTokens.push(newRefreshToken);
    await user.save();

    res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
  } catch (err) {
    if (err.message === 'TOKEN_EXPIRED') {
      return res.status(401).json({ error: 'Refresh token 已过期,请重新登录' });
    }
    next(err);
  }
});

// 登出(撤销 Refresh Token)
router.post('/logout', authenticate, async (req, res, next) => {
  try {
    const { refreshToken } = req.body;
    const user = await User.findById(req.user.id);
    if (user && refreshToken) {
      user.refreshTokens = user.refreshTokens.filter(t => t !== refreshToken);
      await user.save();
    }
    res.json({ message: '登出成功' });
  } catch (err) {
    next(err);
  }
});

// 获取当前用户信息
router.get('/me', authenticate, async (req, res, next) => {
  try {
    const user = await User.findById(req.user.id).select('-passwordHash -refreshTokens');
    if (!user) {
      return res.status(404).json({ error: '用户不存在' });
    }
    res.json({ user });
  } catch (err) {
    next(err);
  }
});

module.exports = router;

常见问题

Q1: JWT Token 存在哪里最安全?

推荐存在内存(变量)中,通过 Authorization: Bearer 头发送。存在 localStorage 易受 XSS 攻击;存在 httpOnly Cookie 可防 XSS 但易受 CSRF 攻击。最佳实践:Access Token 存内存,Refresh Token 存 httpOnly Cookie。

Q2: Access Token 过期了但 Refresh Token 还有效,应该怎么做?

客户端检测到 401 响应后,使用 Refresh Token 调用 /refresh 接口获取新的 Access Token。如果 Refresh Token 也过期(通常 30 天),则要求用户重新登录。注意实现 Token 轮换(Rotation):每次刷新时同时颁发新的 Refresh Token,旧 Token 立即作废,防止 Refresh Token 被盗用。

Q3: 如何防止 JWT 被盗用?

1. 使用 HTTPS 传输(防止中间人攻击)
2. Access Token 有效期不要太长(建议 15 分钟到 7 天,根据安全性要求)
3. Refresh Token 只颁发一次,用过即废(Token 轮换)
4. Refresh Token 存储在 httpOnly Cookie 中
5. 服务器端维护 Refresh Token 黑名单(支持登出即失效)
6. 敏感操作(如修改密码)要求重新验证

延伸阅读

  • <a href="https://jwt.io/">JWT 官方文档</a>
  • <a href="https://owasp.org/">OWASP Web 安全指南</a>
  • <a href="https://danielmiessler.com/study/bcrypt/">bcrypt 密码哈希原理</a>