跳到主要内容

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 对比

对比项JWTSession
存储位置客户端服务端
状态无状态有状态
扩展性天然支持分布式需要共享存储
服务器压力
注销难度困难简单
安全性中(需要刷新机制)

Node.js 实现

安装依赖

npm install jsonwebtoken
npm install @types/jsonwebtoken -D

生成和验证 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

流程

  1. 登录获取两个 Token
  2. 正常请求使用 Access Token
  3. Access Token 过期,用 Refresh Token 换新
  4. Refresh Token 过期,重新登录

Q4: JWT 和 Session 如何选择?

答案

场景推荐方案
单体应用Session
分布式/微服务JWT
需要即时注销Session
移动端应用JWT
高安全要求Session + Redis
第三方 APIJWT

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
});

相关链接