JWT 认证
问题
什么是 JWT?它与 Session 认证有什么区别?如何实现 Token 刷新机制?
答案
JWT(JSON Web Token)是一种开放标准(RFC 7519),用于在各方之间安全地传输信息。它是一种无状态的认证方式。
JWT 结构
JWT 由三部分组成,用 . 分隔:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header(头部)
// 声明类型和签名算法
{
"alg": "HS256", // 签名算法
"typ": "JWT" // Token 类型
}
// Base64Url 编码后:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload(载荷)
// 存放实际数据(声明)
{
// 注册声明(标准字段)
"iss": "auth.example.com", // 签发者
"sub": "1234567890", // 主题(用户ID)
"aud": "app.example.com", // 接收者
"exp": 1516239022, // 过期时间
"nbf": 1516239022, // 生效时间
"iat": 1516239022, // 签发时间
"jti": "unique-id", // 唯一标识
// 公共声明(自定义)
"name": "John Doe",
"role": "admin"
}
注意
Payload 只是 Base64 编码,不是加密!不要存放敏感信息。
Signature(签名)
// 签名生成过程
const signature = HMACSHA256(
base64UrlEncode(header) + '.' + base64UrlEncode(payload),
secret
);
JWT vs Session 对比
| 对比项 | JWT | Session |
|---|---|---|
| 存储位置 | 客户端 | 服务端 |
| 状态 | 无状态 | 有状态 |
| 扩展性 | 天然支持分布式 | 需要共享存储 |
| 服务器压力 | 低 | 高 |
| 注销难度 | 困难 | 简单 |
| 安全性 | 中(需要刷新机制) | 高 |
Node.js 实现
安装依赖
- npm
- Yarn
- pnpm
- Bun
npm install jsonwebtoken
npm install @types/jsonwebtoken -D
yarn add jsonwebtoken
yarn add @types/jsonwebtoken --dev
pnpm add jsonwebtoken
pnpm add @types/jsonwebtoken -D
bun add jsonwebtoken
bun add @types/jsonwebtoken --dev
生成和验证 Token
import jwt from 'jsonwebtoken';
const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key';
const ACCESS_TOKEN_EXPIRES = '15m';
const REFRESH_TOKEN_EXPIRES = '7d';
interface UserPayload {
userId: number;
username: string;
role: string;
}
// 生成 Access Token
function generateAccessToken(user: UserPayload): string {
return jwt.sign(user, SECRET_KEY, {
expiresIn: ACCESS_TOKEN_EXPIRES
});
}
// 生成 Refresh Token
function generateRefreshToken(userId: number): string {
return jwt.sign({ userId }, SECRET_KEY, {
expiresIn: REFRESH_TOKEN_EXPIRES
});
}
// 验证 Token
function verifyToken(token: string): UserPayload | null {
try {
return jwt.verify(token, SECRET_KEY) as UserPayload;
} catch (error) {
return null;
}
}
// 解码 Token(不验证)
function decodeToken(token: string): UserPayload | null {
return jwt.decode(token) as UserPayload;
}
Express 中间件
import { Request, Response, NextFunction } from 'express';
interface AuthRequest extends Request {
user?: UserPayload;
}
// 认证中间件
function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
const user = verifyToken(token);
if (!user) {
return res.status(401).json({ error: 'Invalid token' });
}
req.user = user;
next();
}
// 使用
app.get('/protected', authMiddleware, (req: AuthRequest, res) => {
res.json({ user: req.user });
});
完整登录流程
// 登录
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// 验证用户
const user = await validateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// 生成 Token
const accessToken = generateAccessToken({
userId: user.id,
username: user.username,
role: user.role
});
const refreshToken = generateRefreshToken(user.id);
// 存储 Refresh Token(用于验证和撤销)
await saveRefreshToken(user.id, refreshToken);
res.json({
accessToken,
refreshToken,
expiresIn: 900 // 15 分钟
});
});
// 刷新 Token
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
// 验证 Refresh Token
const payload = verifyToken(refreshToken);
if (!payload) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
// 检查是否在白名单
const isValid = await isRefreshTokenValid(payload.userId, refreshToken);
if (!isValid) {
return res.status(401).json({ error: 'Refresh token revoked' });
}
// 获取用户信息
const user = await getUserById(payload.userId);
// 生成新 Token
const newAccessToken = generateAccessToken({
userId: user.id,
username: user.username,
role: user.role
});
res.json({
accessToken: newAccessToken,
expiresIn: 900
});
});
// 登出
app.post('/logout', authMiddleware, async (req: AuthRequest, res) => {
const { refreshToken } = req.body;
// 删除 Refresh Token
await deleteRefreshToken(req.user!.userId, refreshToken);
res.json({ success: true });
});
前端存储和使用
存储方式对比
| 存储方式 | XSS 风险 | CSRF 风险 | 建议 |
|---|---|---|---|
| localStorage | 高 | 无 | 不推荐 |
| Cookie(httpOnly) | 低 | 有 | 推荐 |
| 内存 | 低 | 无 | 适合 SPA |
最佳实践:双 Token 方案
class AuthService {
private accessToken: string | null = null;
// 存储 Token
setTokens(accessToken: string, refreshToken: string) {
// Access Token 存内存
this.accessToken = accessToken;
// Refresh Token 存 httpOnly Cookie(服务端设置)
// 或安全存储
}
// 获取 Access Token
getAccessToken(): string | null {
return this.accessToken;
}
// 请求拦截器
async request(url: string, options: RequestInit = {}) {
// 添加 Token
const headers = new Headers(options.headers);
if (this.accessToken) {
headers.set('Authorization', `Bearer ${this.accessToken}`);
}
let response = await fetch(url, { ...options, headers });
// Token 过期,尝试刷新
if (response.status === 401) {
const refreshed = await this.refreshToken();
if (refreshed) {
headers.set('Authorization', `Bearer ${this.accessToken}`);
response = await fetch(url, { ...options, headers });
} else {
// 刷新失败,跳转登录
this.logout();
}
}
return response;
}
// 刷新 Token
private async refreshToken(): Promise<boolean> {
try {
const response = await fetch('/api/refresh', {
method: 'POST',
credentials: 'include' // 携带 Cookie
});
if (response.ok) {
const data = await response.json();
this.accessToken = data.accessToken;
return true;
}
return false;
} catch {
return false;
}
}
// 登出
logout() {
this.accessToken = null;
window.location.href = '/login';
}
}
const auth = new AuthService();
Axios 拦截器实现
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
const api = axios.create({
baseURL: '/api'
});
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
const processQueue = (error: Error | null, token: string | null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token!);
}
});
failedQueue = [];
};
// 请求拦截器
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 等待刷新完成
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await api.post('/refresh');
localStorage.setItem('accessToken', data.accessToken);
processQueue(null, data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError as Error, null);
localStorage.removeItem('accessToken');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
安全最佳实践
Token 安全
// 1. 使用强密钥
const SECRET = crypto.randomBytes(64).toString('hex');
// 2. 设置合理过期时间
const accessToken = jwt.sign(payload, SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign(payload, SECRET, { expiresIn: '7d' });
// 3. 使用 HTTPS
// 防止 Token 被截获
// 4. 验证 audience 和 issuer
jwt.verify(token, SECRET, {
audience: 'app.example.com',
issuer: 'auth.example.com'
});
Token 撤销策略
// 方案1:Token 黑名单
const blacklist = new Set<string>();
function isTokenBlacklisted(jti: string): boolean {
return blacklist.has(jti);
}
// 方案2:Token 版本号
// 用户表添加 tokenVersion 字段
// Token 中包含 version,验证时比对
// 方案3:短期 Token + Refresh Token
// Access Token 15 分钟过期
// Refresh Token 存储在数据库,可删除
常见面试问题
Q1: JWT 的优缺点?
答案:
优点:
- 无状态,服务端不存储
- 天然支持分布式/微服务
- 可携带用户信息,减少数据库查询
- 跨域友好
缺点:
- Token 一旦签发,无法主动失效
- Token 较大,增加请求开销
- 敏感信息需要加密处理
- 刷新机制较复杂
Q2: 如何实现 JWT 的注销?
答案:
// 方案1:Token 黑名单(Redis)
async function logout(jti: string, exp: number) {
const ttl = exp - Math.floor(Date.now() / 1000);
await redis.setex(`blacklist:${jti}`, ttl, '1');
}
// 方案2:Token 版本号
async function logout(userId: number) {
await db.user.update({
where: { id: userId },
data: { tokenVersion: { increment: 1 } }
});
}
// 方案3:短期 Token + 删除 Refresh Token
async function logout(userId: number, refreshToken: string) {
await db.refreshToken.delete({
where: { userId, token: refreshToken }
});
}
Q3: Access Token 和 Refresh Token 的作用?
答案:
| Token | 作用 | 有效期 | 存储 |
|---|---|---|---|
| Access Token | 访问资源 | 短(15分钟) | 内存/localStorage |
| Refresh Token | 获取新 Access Token | 长(7天) | httpOnly Cookie |
流程:
- 登录获取两个 Token
- 正常请求使用 Access Token
- Access Token 过期,用 Refresh Token 换新
- Refresh Token 过期,重新登录
Q4: JWT 和 Session 如何选择?
答案:
| 场景 | 推荐方案 |
|---|---|
| 单体应用 | Session |
| 分布式/微服务 | JWT |
| 需要即时注销 | Session |
| 移动端应用 | JWT |
| 高安全要求 | Session + Redis |
| 第三方 API | JWT |
Q5: JWT 存储在哪里最安全?
答案:
// 推荐方案:双 Token + httpOnly Cookie
// Access Token: 存内存(最安全)
// - 防止 XSS 窃取
// - 页面刷新会丢失,需要 Refresh Token 重新获取
// Refresh Token: 存 httpOnly Cookie
// - JavaScript 无法访问
// - 配合 SameSite 防止 CSRF
// 设置 Refresh Token
res.cookie('refreshToken', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000
});