Node.js 认证与授权完全指南:JWT 实战
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>