Node.js JWT 鉴权机制实现
1. 问题解析
JWT(JSON Web Token)是目前最流行的跨域认证解决方案。理解 JWT 的结构、签名验证机制以及在 Node.js 中的实现方式,对于构建安全的 Web 应用至关重要。JWT 的无状态特性使其特别适合分布式系统和微服务架构。
2. 核心概念
2.1 JWT 结构
JWT 由三部分组成,用点号(.)分隔:
xxxxx.yyyyy.zzzzz
↑ ↑ ↑
Header Payload Signature
| 部分 | 说明 | 示例 |
|---|---|---|
| Header | 声明类型和签名算法 | {"alg":"HS256","typ":"JWT"} |
| Payload | 声明(claims),包含用户数据 | {"userId":1,"exp":1234567890} |
| Signature | 签名,防止篡改 | HMACSHA256(base64Url(header) + "." + base64Url(payload), secret) |
2.2 JWT Claims 类型
| Claim | 含义 | 示例 |
|---|---|---|
| iss (Issuer) | 签发者 | iss: "my-app" |
| sub (Subject) | 主题(用户标识) | sub: "user123" |
| aud (Audience) | 接收方 | aud: "my-api" |
| exp (Expiration) | 过期时间(时间戳) | exp: 1234567890 |
| nbf (Not Before) | 生效时间 | nbf: 1234567800 |
| iat (Issued At) | 签发时间 | iat: 1234567000 |
| jti (JWT ID) | 唯一标识 | jti: "uuid-123" |
2.3 JWT 工作流程
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │ ──(1) 登录请求────> │ Server │ ──(2) 验证凭证───> │Database │
│ │ │ │ <─(3) 返回用户信息── │ │
│ │ <─(4) 返回 JWT──── │ │ └─────────┘
│ │ │ │
│ │ ──(5) 请求 API ───> │ │
│ │ Authorization: │ │
│ │ Bearer xxx.yyy │ │
│ │ │ │
│ │ <─(6) 返回数据──── │ │
└─────────┘ └─────────┘
3. 详细解答
3.1 使用 jsonwebtoken 库
const jwt = require('jsonwebtoken');
// 密钥配置
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = '7d'; // 7天过期
/**
* 生成 JWT Token
* @param {Object} payload - 要编码的数据
* @param {Object} options - 额外选项
*/
function generateToken(payload, options = {}) {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: options.expiresIn || JWT_EXPIRES_IN,
issuer: options.issuer || 'my-app',
audience: options.audience || 'my-api',
...options
});
}
/**
* 验证 JWT Token
* @param {string} token - JWT 字符串
*/
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new Error('Token 已过期');
} else if (err.name === 'JsonWebTokenError') {
throw new Error('Token 无效');
}
throw err;
}
}
/**
* 解码 Token(不验证)
* @param {string} token - JWT 字符串
*/
function decodeToken(token) {
return jwt.decode(token, { complete: true });
}
// 使用示例
const user = { userId: 1, username: 'john', role: 'admin' };
// 生成 Token
const token = generateToken(user, { expiresIn: '2h' });
console.log('Generated Token:', token);
// 验证 Token
try {
const decoded = verifyToken(token);
console.log('Decoded:', decoded);
} catch (err) {
console.error('Verification failed:', err.message);
}
3.2 Koa JWT 鉴权中间件
const Koa = require('koa');
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
/**
* JWT 认证中间件
* @param {Object} options - 配置选项
*/
function jwtMiddleware(options = {}) {
const {
secret = JWT_SECRET,
exclude = [], // 排除校验的路径
extractToken = (ctx) => {
const authHeader = ctx.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
return null;
}
} = options;
return async function auth(ctx, next) {
// 检查是否在排除列表
if (exclude.some(path => ctx.path.startsWith(path))) {
return await next();
}
// 提取 Token
const token = extractToken(ctx);
if (!token) {
ctx.status = 401;
ctx.body = {
error: 'Unauthorized',
message: '缺少访问令牌'
};
return;
}
try {
// 验证 Token
const decoded = jwt.verify(token, secret);
// 将用户信息挂载到 ctx.state
ctx.state.user = decoded;
await next();
} catch (err) {
ctx.status = 401;
ctx.body = {
error: 'Unauthorized',
message: err.name === 'TokenExpiredError'
? 'Token 已过期'
: '无效的访问令牌'
};
}
};
}
// 角色权限中间件
function requireRole(...allowedRoles) {
return async function roleCheck(ctx, next) {
const user = ctx.state.user;
if (!user) {
ctx.status = 401;
ctx.body = { error: '未认证' };
return;
}
if (!allowedRoles.includes(user.role)) {
ctx.status = 403;
ctx.body = { error: 'Forbidden', message: '权限不足' };
return;
}
await next();
};
}
// 使用示例
const app = new Koa();
const Router = require('@koa/router');
const router = new Router();
// 应用 JWT 中间件
app.use(jwtMiddleware({
secret: JWT_SECRET,
exclude: ['/login', '/register', '/health']
}));
// 登录接口
router.post('/login', async (ctx) => {
const { username, password } = ctx.request.body;
// 验证用户凭证(实际应从数据库验证)
if (username === 'admin' && password === '123456') {
const user = { userId: 1, username, role: 'admin' };
const token = jwt.sign(user, JWT_SECRET, { expiresIn: '2h' });
ctx.body = {
success: true,
token,
user
};
} else {
ctx.status = 401;
ctx.body = { error: '用户名或密码错误' };
}
});
// 需要登录的接口
router.get('/profile', async (ctx) => {
// ctx.state.user 由 JWT 中间件设置
ctx.body = {
user: ctx.state.user
};
});
// 需要管理员权限的接口
router.delete('/users/:id',
requireRole('admin'),
async (ctx) => {
ctx.body = { message: '用户已删除' };
}
);
app.use(router.routes());
app.listen(3000);
3.3 使用 koa-jwt 中间件
const Koa = require('koa');
const koajwt = require('koa-jwt');
const jwt = require('jsonwebtoken');
const Router = require('@koa/router');
const app = new Koa();
const router = new Router();
const JWT_SECRET = 'your-secret-key';
// 使用 koa-jwt 中间件
app.use(koajwt({
secret: JWT_SECRET,
algorithms: ['HS256']
}).unless({
// 排除不需要认证的路径
path: [/^\/login/, /^\/register/, /^\/health/]
}));
// 错误处理
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
if (err.status === 401) {
ctx.status = 401;
ctx.body = {
error: 'Unauthorized',
message: '无效的访问令牌'
};
} else {
throw err;
}
}
});
// 登录
router.post('/login', async (ctx) => {
const { username, password } = ctx.request.body;
// 验证用户
if (username === 'user' && password === 'pass') {
const token = jwt.sign(
{ userId: 1, username, role: 'user' },
JWT_SECRET,
{ expiresIn: '1h' }
);
ctx.body = { token };
} else {
ctx.status = 401;
ctx.body = { error: '认证失败' };
}
});
// 受保护的路由
router.get('/api/data', async (ctx) => {
// ctx.state.user 包含解码后的 JWT payload
ctx.body = {
data: 'secret data',
user: ctx.state.user
};
});
app.use(router.routes());
app.listen(3000);
3.4 Refresh Token 机制
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
// 双 Token 策略:Access Token + Refresh Token
const JWT_SECRET = 'access-secret';
const REFRESH_SECRET = 'refresh-secret';
// Refresh Token 存储(生产环境使用 Redis)
const refreshTokenStore = new Map();
/**
* 生成 Access Token
* 有效期短(如 15 分钟),用于 API 访问
*/
function generateAccessToken(payload) {
return jwt.sign(payload, JWT_SECRET, {
expiresIn: '15m',
issuer: 'my-app'
});
}
/**
* 生成 Refresh Token
* 有效期长(如 7 天),用于获取新的 Access Token
*/
function generateRefreshToken(userId) {
const token = crypto.randomBytes(40).toString('hex');
const expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7天
refreshTokenStore.set(token, {
userId,
expiresAt,
createdAt: Date.now()
});
return token;
}
/**
* 验证 Refresh Token
*/
function verifyRefreshToken(token) {
const data = refreshTokenStore.get(token);
if (!data) {
throw new Error('Refresh Token 不存在');
}
if (Date.now() > data.expiresAt) {
refreshTokenStore.delete(token);
throw new Error('Refresh Token 已过期');
}
return data;
}
/**
* 刷新 Access Token
*/
function refreshAccessToken(refreshToken, userData) {
const data = verifyRefreshToken(refreshToken);
// 生成新的 Access Token
const newAccessToken = generateAccessToken({
userId: data.userId,
...userData
});
return newAccessToken;
}
/**
* 撤销 Refresh Token(登出)
*/
function revokeRefreshToken(token) {
refreshTokenStore.delete(token);
}
// API 实现
const Koa = require('koa');
const Router = require('@koa/router');
const app = new Koa();
const router = new Router();
// 登录 - 返回双 Token
router.post('/auth/login', async (ctx) => {
const { username, password } = ctx.request.body;
// 验证用户(示例)
const user = await validateUser(username, password);
if (!user) {
ctx.status = 401;
ctx.body = { error: '认证失败' };
return;
}
const accessToken = generateAccessToken({
userId: user.id,
username: user.username,
role: user.role
});
const refreshToken = generateRefreshToken(user.id);
ctx.body = {
accessToken,
refreshToken,
expiresIn: 900 // 15分钟(秒)
};
});
// 刷新 Token
router.post('/auth/refresh', async (ctx) => {
const { refreshToken } = ctx.request.body;
try {
const data = verifyRefreshToken(refreshToken);
const user = await getUserById(data.userId);
const newAccessToken = generateAccessToken({
userId: user.id,
username: user.username,
role: user.role
});
ctx.body = {
accessToken: newAccessToken,
expiresIn: 900
};
} catch (err) {
ctx.status = 401;
ctx.body = { error: err.message };
}
});
// 登出 - 撤销 Refresh Token
router.post('/auth/logout', async (ctx) => {
const { refreshToken } = ctx.request.body;
revokeRefreshToken(refreshToken);
ctx.body = { message: '登出成功' };
});
app.use(router.routes());
4. 深入理解
4.1 JWT 安全性考虑
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
/**
* JWT 安全最佳实践
*/
class JWTSecurity {
constructor() {
// 使用强密钥
this.secret = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex');
this.refreshSecret = process.env.JWT_REFRESH_SECRET || crypto.randomBytes(64).toString('hex');
// Token 黑名单(使用 Redis 更好)
this.blacklist = new Set();
}
/**
* 生成安全的 JWT
*/
generateSecureToken(payload, options = {}) {
const jti = crypto.randomUUID(); // JWT ID,用于撤销
return jwt.sign(
{ ...payload, jti },
this.secret,
{
algorithm: 'HS256', // 避免使用不安全的 'none'
expiresIn: options.expiresIn || '15m',
issuer: 'my-app',
audience: 'my-api',
notBefore: '0s' // 立即生效
}
);
}
/**
* 验证 JWT 并检查黑名单
*/
verifySecureToken(token) {
const decoded = jwt.verify(token, this.secret, {
algorithms: ['HS256'], // 明确指定允许的算法
issuer: 'my-app',
audience: 'my-api'
});
// 检查黑名单
if (this.blacklist.has(decoded.jti)) {
throw new Error('Token 已被撤销');
}
return decoded;
}
/**
* 撤销 Token
*/
revokeToken(jti, exp) {
this.blacklist.add(jti);
// 设置过期时间自动清理(实际使用 Redis TTL)
const ttl = exp * 1000 - Date.now();
setTimeout(() => this.blacklist.delete(jti), ttl);
}
/**
* 密钥轮换
*/
rotateSecret() {
// 保存旧密钥用于验证现有 Token
this.oldSecret = this.secret;
// 生成新密钥
this.secret = crypto.randomBytes(64).toString('hex');
// 一段时间后移除旧密钥
setTimeout(() => {
this.oldSecret = null;
}, 24 * 60 * 60 * 1000); // 24小时
}
}
// 防止 JWT 泄露的措施
function setupSecurityHeaders(ctx) {
// 防止 XSS 窃取 Token
ctx.set('X-Content-Type-Options', 'nosniff');
ctx.set('X-Frame-Options', 'DENY');
ctx.set('Content-Security-Policy', "default-src 'self'");
// 使用 HttpOnly Cookie 存储 Refresh Token
ctx.cookies.set('refreshToken', token, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict', // CSRF 防护
maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
});
}
4.2 非对称加密(RS256)
const jwt = require('jsonwebtoken');
const fs = require('fs');
/**
* 使用 RSA 密钥对(推荐生产环境)
* 私钥签名,公钥验证
*/
// 生成密钥对(命令行)
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
/**
* 使用私钥签名生成 JWT
*/
function generateTokenWithRSA(payload) {
return jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '1h'
});
}
/**
* 使用公钥验证 JWT
*/
function verifyTokenWithRSA(token) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256']
});
}
// 优势:
// 1. 私钥只保存在认证服务器
// 2. 其他服务只需要公钥即可验证
// 3. 适合微服务架构
4.3 多设备登录管理
const Redis = require('ioredis');
const redis = new Redis();
/**
* 多设备 Token 管理
*/
class MultiDeviceAuth {
/**
* 用户登录
*/
async login(userId, deviceInfo) {
const deviceId = crypto.randomUUID();
const accessToken = jwt.sign(
{ userId, deviceId },
JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = crypto.randomBytes(40).toString('hex');
// 存储设备信息
await redis.hset(`user:${userId}:devices`, deviceId, JSON.stringify({
deviceId,
deviceInfo,
refreshToken,
loginAt: Date.now()
}));
// 设置 Refresh Token 过期
await redis.setex(`refresh:${refreshToken}`, 7 * 24 * 60 * 60, userId);
return { accessToken, refreshToken, deviceId };
}
/**
* 获取用户所有设备
*/
async getUserDevices(userId) {
const devices = await redis.hgetall(`user:${userId}:devices`);
return Object.values(devices).map(d => JSON.parse(d));
}
/**
* 踢出指定设备
*/
async logoutDevice(userId, deviceId) {
const deviceData = await redis.hget(`user:${userId}:devices`, deviceId);
if (deviceData) {
const { refreshToken } = JSON.parse(deviceData);
await redis.del(`refresh:${refreshToken}`);
await redis.hdel(`user:${userId}:devices`, deviceId);
}
}
/**
* 踢出所有其他设备
*/
async logoutOtherDevices(userId, currentDeviceId) {
const devices = await redis.hgetall(`user:${userId}:devices`);
for (const [deviceId, deviceData] of Object.entries(devices)) {
if (deviceId !== currentDeviceId) {
const { refreshToken } = JSON.parse(deviceData);
await redis.del(`refresh:${refreshToken}`);
await redis.hdel(`user:${userId}:devices`, deviceId);
}
}
}
}
5. 最佳实践
5.1 完整的认证流程
const Koa = require('koa');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const Router = require('@koa/router');
const app = new Koa();
const router = new Router();
const JWT_CONFIG = {
secret: process.env.JWT_SECRET,
accessExpiresIn: '15m',
refreshExpiresIn: '7d'
};
// 密码哈希
async function hashPassword(password) {
return bcrypt.hash(password, 10);
}
async function verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
// 登录
router.post('/auth/login', async (ctx) => {
const { username, password, deviceId } = ctx.request.body;
// 1. 验证用户
const user = await db.users.findOne({ username });
if (!user || !(await verifyPassword(password, user.passwordHash))) {
ctx.status = 401;
ctx.body = { error: '用户名或密码错误' };
return;
}
// 2. 检查账户状态
if (user.isLocked) {
ctx.status = 403;
ctx.body = { error: '账户已被锁定' };
return;
}
// 3. 生成 Token
const accessToken = jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role,
deviceId
},
JWT_CONFIG.secret,
{ expiresIn: JWT_CONFIG.accessExpiresIn }
);
const refreshToken = await generateRefreshToken(user.id, deviceId);
// 4. 记录登录日志
await db.loginLogs.create({
userId: user.id,
deviceId,
ip: ctx.ip,
userAgent: ctx.headers['user-agent'],
loginAt: new Date()
});
ctx.body = {
accessToken,
refreshToken,
expiresIn: 900,
user: {
id: user.id,
username: user.username,
role: user.role
}
};
});
// 刷新 Token
router.post('/auth/refresh', async (ctx) => {
const { refreshToken } = ctx.request.body;
const tokenData = await validateRefreshToken(refreshToken);
if (!tokenData) {
ctx.status = 401;
ctx.body = { error: 'Refresh Token 无效' };
return;
}
const user = await db.users.findById(tokenData.userId);
if (!user || user.isLocked) {
ctx.status = 403;
ctx.body = { error: '账户异常' };
return;
}
// 生成新的 Access Token
const newAccessToken = jwt.sign(
{
userId: user.id,
username: user.username,
role: user.role,
deviceId: tokenData.deviceId
},
JWT_CONFIG.secret,
{ expiresIn: JWT_CONFIG.accessExpiresIn }
);
ctx.body = {
accessToken: newAccessToken,
expiresIn: 900
};
});
// 登出
router.post('/auth/logout', async (ctx) => {
const { refreshToken } = ctx.request.body;
// 撤销 Refresh Token
await revokeRefreshToken(refreshToken);
// 如果有 Access Token,加入黑名单
const authHeader = ctx.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const decoded = jwt.decode(token);
if (decoded?.jti) {
await addToBlacklist(decoded.jti, decoded.exp);
}
}
ctx.body = { message: '登出成功' };
});
// 修改密码
router.post('/auth/change-password',
jwtMiddleware(),
async (ctx) => {
const { oldPassword, newPassword } = ctx.request.body;
const userId = ctx.state.user.userId;
const user = await db.users.findById(userId);
if (!(await verifyPassword(oldPassword, user.passwordHash))) {
ctx.status = 400;
ctx.body = { error: '原密码错误' };
return;
}
// 更新密码
user.passwordHash = await hashPassword(newPassword);
await user.save();
// 撤销所有 Refresh Token,强制重新登录
await revokeAllUserTokens(userId);
ctx.body = { message: '密码修改成功,请重新登录' };
}
);
5.2 前端 Token 管理
// api.js - 前端 API 客户端
class APIClient {
constructor() {
this.baseURL = 'http://api.example.com';
this.accessToken = localStorage.getItem('accessToken');
this.refreshToken = localStorage.getItem('refreshToken');
this.refreshPromise = null;
}
async request(url, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (this.accessToken) {
headers.Authorization = `Bearer ${this.accessToken}`;
}
try {
const response = await fetch(`${this.baseURL}${url}`, {
...options,
headers
});
// Token 过期,尝试刷新
if (response.status === 401 && this.refreshToken) {
const newToken = await this.refreshAccessToken();
if (newToken) {
headers.Authorization = `Bearer ${newToken}`;
return fetch(`${this.baseURL}${url}`, {
...options,
headers
});
}
}
return response;
} catch (error) {
throw error;
}
}
async refreshAccessToken() {
// 防止重复刷新
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = fetch(`${this.baseURL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: this.refreshToken })
}).then(async (response) => {
if (response.ok) {
const data = await response.json();
this.accessToken = data.accessToken;
localStorage.setItem('accessToken', data.accessToken);
return data.accessToken;
} else {
// 刷新失败,清除 Token 并跳转登录
this.clearTokens();
window.location.href = '/login';
return null;
}
}).finally(() => {
this.refreshPromise = null;
});
return this.refreshPromise;
}
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
}
export const api = new APIClient();
6. 面试要点
-
JWT 的三部分组成
- Header:算法和类型声明
- Payload:用户数据和声明(claims)
- Signature:签名,确保 Token 未被篡改
-
JWT 与传统 Session 的区别
- JWT:无状态,服务端不存储,可扩展性好
- Session:有状态,服务端存储,需要共享 Session 存储
- JWT 适合分布式系统,Session 适合需要服务端控制会话的场景
-
JWT 的安全注意事项
- 使用强密钥(至少 256 位)
- 设置合理的过期时间
- 使用 HTTPS 传输
- 不要在 Payload 中存放敏感信息(只是 base64 编码)
- 实现 Token 黑名单机制用于撤销
- 使用 RS256 等非对称算法时保护好私钥
-
Refresh Token 机制的作用
- Access Token 有效期短,减少泄露风险
- Refresh Token 有效期长,用于获取新的 Access Token
- 实现无感刷新,提升用户体验
- Refresh Token 通常存储在 HttpOnly Cookie 中
-
JWT 验证失败的处理
- TokenExpiredError:提示 Token 过期,引导刷新
- JsonWebTokenError:Token 格式错误或被篡改
- NotBeforeError:Token 尚未生效
-
实际应用中的考虑
- 多设备登录管理
- Token 撤销(黑名单)
- 密钥轮换
- 权限控制(RBAC)
- 登录日志和异常检测