全栈项目中的 JWT 双 Token 无感刷新认证方案
在构建 Web 应用时,用户认证(Authentication)就像是给你的系统装一扇门。如何既保证这扇门足够安全(不让坏人进),又让它足够智能(不让好人每次进出都掏钥匙),是每个开发者都要面对的挑战。
在我们的全栈项目中,我们经历了一个从 Mock 单 Token 到生产级双 Token 的演进过程。本文将带你深入这套机制的每一个齿轮,揭示它是如何平衡“安全性”与“体验感”的。
单 Token 的认证机制
最初写项目时,我们在 mock 中使用了单 Token 的机制,通过 sign 直接签发一个长期的 Token,而拦截器中只要碰到 401,就直接强制登出,返回登录页:
const token = jwt.sign({
user: { // json 对象
id: 1,
name: 'admin',
avatar: "https://p9-passport.byteacctimg.com/img/user-avatar/09aff03aaa33dd9d311511bcbd12535f~50x50.awebp"
}
// 加盐
}, secret, {
expiresIn: 86400 * 7 // 有效时间 7天
});
这样的单 Token 模式却暴露了很多缺陷:
- 安全性问题:长期 Token 存在被截获后被冒用的风险
- 用户体验问题:用户每次操作都需要重新登录,影响用户体验
- 服务端压力问题:每次请求都需要验证 Token,服务端压力增大
进阶:JWT 双 Token 认证机制
在单 Token 时代,我们总是面临一个两难的选择:Token 有效期设短了,用户体验差;设长了,安全风险大。双 Token 机制巧妙地引入了两把不同用途的“钥匙”来解决这个问题。
后端部分核心实现
后端部分负责 Token 的签发与验证。
Token 的签发
每当用户进行登录时,会同时签发一对 Token:
- Access Token:短期 Token,就像是用户的临时密码,用于验证用户身份。所有的操作都只需要验证 Access Token 即可。
- Refresh Token:长期 Token,就像是用户的长期密码,用于刷新 Access Token。
// AuthService: 生成双 Token
private async generateTokens(userId: string, username: string) {
// Payload (载荷):钥匙里包裹的信息
const payload = { sub: userId, name: username };
// 使用 Promise.all 并行执行,效率翻倍
const [at, rt] = await Promise.all([
// 制作手环 (Access Token):15分钟过期
this.jwtService.signAsync(payload, {
expiresIn: '15m',
secret: process.env.TOKEN_SECRET
}),
// 制作凭证 (Refresh Token):7天过期
this.jwtService.signAsync(payload, {
expiresIn: '7d',
secret: process.env.TOKEN_SECRET
})
]);
return { access_token: at, refresh_token: rt };
}
核心代码分析:
jwtService:这是 NestJS 中提供的 JWT 服务,用于签发 TokensignAsync:JWT 服务提供的异步方法,用于签发 TokenexpiresIn:Token 的过期时间。15 分钟用于短期认证,7 天用于长期存储secret:Token 的密钥,用于签名,确保安全性(保存在.env文件中)payload:Token 中包含的信息。sub是标准 JWT Subject 字段,表示用户 IDPromise.all:并行签发两个 Token,提高效率,只有两者都成功才返回前端
短期的 Token 通常以分钟为单位,长期的 Token 通常以天为单位,根据实际情况进行设置。
Token 的刷新
当 Access Token 过期时,用户可以使用 Refresh Token 来重新颁发一对 Token:
async refreshToken(rt: string) {
try {
// decoded
const payload = await this.jwtService.verifyAsync(rt, {
secret: process.env.TOKEN_SECRET
});
if (payload) {
return this.generateTokens(payload.sub, payload.name);
}
} catch (e) {
throw new UnauthorizedException('Refresh token 已失效,请重新登录');
}
}
核心代码解析:
verifyAsync方法:NestJS JWT 服务提供的异步方法,用于验证 Refresh Token 的有效性payload:若能成功解码并获取用户信息,说明 Refresh Token 有效,即可调用generateTokens重新签发
NestJS 鉴权处理:策略与守卫
配备守卫
NestJS 提供了默认的 Guard,用于解析 req.headers.Authorization 中的 Token。我们只需继承该 Guard,并指定使用 JWT 策略:
import { Injectable } from "@nestjs/common";
// NestJS 默认提供的 guard,自动解析 req Authorization
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
只需在需要鉴权的路由上加上装饰器即可:
@UseGuards(JwtAuthGuard) // 路由守卫
createPost(
@Body('title') title: string,
@Body('content') content: string,
@Req() req
) {...}
制定策略
仅有守卫还不够,我们还需要制定一套规则,让系统知道如何鉴权:
import { Injectable } from '@nestjs/common';
// 定义和集成 Passport 身份验证策略的基类
import { PassportStrategy } from '@nestjs/passport';
// 身份验证策略选择 jwt
import { Strategy, ExtractJwt } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.TOKEN_SECRET || ""
});
}
// JWT 用户对象
async validate(payload) {
return {
id: payload.sub,
name: payload.name
};
}
}
核心代码解析:
PassportStrategy(Strategy):NestJS 提供的基类,用于定义 Passport 策略Strategy:来自passport-jwt,表示使用 JWT 策略jwtFromRequest:指定从AuthorizationHeader 中提取 Bearer TokensecretOrKey:用于验证 Token 签名的密钥(来自环境变量)validate:验证成功后返回用户上下文,挂载到req.user上
例如,在业务代码中可直接使用:
@Post()
@UseGuards(JwtAuthGuard)
createPost(
@Body('title') title: string,
@Body('content') content: string,
@Req() req
) {
const { user } = req;
return this.postsService.createPost({
title,
content,
userId: user.id
});
}
前端部分核心实现
前端的挑战在于如何管理这些 Token,并在 Token 过期时“悄无声息”地完成刷新,让用户感觉不到任何中断。
Token 的存储与状态管理
我们使用 Zustand 库进行状态管理,因为它轻量且支持中间件。
关键代码解析 (useUserStore):
export const useUserStore = create(
persist((set) => ({
accessToken: null,
refreshToken: null,
// ...
}), {
name: 'user-store', // LocalStorage 中的 key
partialize: (state) => ({
// 白名单:只持久化这两个 Token,不持久化临时状态
accessToken: state.accessToken,
refreshToken: state.refreshToken,
})
})
);
- 持久化 (Persist) :确保刷新页面后,用户依然是登录状态。Token 自动保存到
localStorage。 - 白名单 (Partialize) :最佳实践。只存 Token,避免将临时状态(如
isLoading)误存,导致状态错乱。
拦截器实现 Token 无感刷新
为什么在拦截器里做?
因为拦截器是所有 HTTP 请求的总关口。在这里处理,可以统一拦截所有 API 的 401 错误,业务代码完全不需要关心 Token 是否过期,做到了逻辑解耦。
import axios from 'axios';
import { useUserStore } from '@/store/useUserStore';
const instance = axios.create({
baseURL: 'http://localhost:5173/api'
});
// 每次请求自动带上 token
instance.interceptors.request.use(config => {
const token = useUserStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
let isRefreshing = false;
let requestsQueue: any[] = [];
// 响应拦截器
instance.interceptors.response.use(res => {
return res.data;
}, async (err) => {
const { config, response } = err;
// _retry 刻意标记是否是重试的请求,避免 retry 死循环
if (response?.status === 401 && !config._retry) {
// 如果在刷新中,把后续请求放到队列中
if (isRefreshing) {
return new Promise((resolve) => {
requestsQueue.push((token: string) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(instance(config));
});
});
}
config._retry = true; // retry 开关
isRefreshing = true;
try {
const { refreshToken } = useUserStore.getState();
if (refreshToken) {
// 无感刷新 token
const { access_token, refresh_token } = await instance.post('auth/refresh', {
refresh_token: refreshToken
});
useUserStore.setState({
accessToken: access_token,
refreshToken: refresh_token,
isLogin: true
});
requestsQueue.forEach(callback => callback(access_token));
requestsQueue = [];
config.headers.Authorization = `Bearer ${access_token}`;
return instance(config);
}
} catch (err) {
useUserStore.getState().logout();
window.location.href = '/login';
return Promise.reject(err);
} finally {
isRefreshing = false;
}
}
return Promise.reject(err);
});
export default instance;
代码拆解讲解
-
请求拦截器
让每次请求都自动带上 Token:instance.interceptors.request.use(config => { const token = useUserStore.getState().accessToken; if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); -
响应拦截器
关键变量:
isRefreshing:是否正在刷新 TokenrequestsQueue:等待刷新完成后的请求队列
为什么需要这个队列?
当多个并发请求因 Token 过期失败时,不能每个都去刷新 Token。应只触发一次刷新,其余请求排队等待新 Token。逻辑步骤:
-
判断是否需要刷新 Token
if (response?.status === 401 && !config._retry) { ... }_retry标记防止死循环:若重试后仍 401,不再重复刷新。 -
处理并发请求
if (isRefreshing) { return new Promise((resolve) => { requestsQueue.push((token: string) => { config.headers.Authorization = `Bearer ${token}`; resolve(instance(config)); }); }); } config._retry = true; isRefreshing = true; -
签发新 Token
const { refreshToken } = useUserStore.getState(); if (refreshToken) { const { access_token, refresh_token } = await instance.post('auth/refresh', { refresh_token: refreshToken }); useUserStore.setState({ accessToken: access_token, refreshToken: refresh_token, isLogin: true }); } -
重放排队请求
requestsQueue.forEach(callback => callback(access_token)); requestsQueue = []; -
重试当前请求
config.headers.Authorization = `Bearer ${access_token}`; return instance(config); -
处理刷新失败
catch (err) { useUserStore.getState().logout(); window.location.href = '/login'; return Promise.reject(err); } finally { isRefreshing = false; } -
拦截器出口
return Promise.reject(err);
总结
JWT 双 Token 无感刷新机制,本质上是一种在安全性和用户体验之间取得精妙平衡的工程实践。通过将认证职责拆分为短期有效的 Access Token 与长期有效的 Refresh Token,我们既避免了长期持有敏感凭证带来的安全风险,又消除了用户频繁登录的体验断层。
后端通过 NestJS 的守卫(Guard)与策略(Strategy)体系,构建了一套清晰、可复用的鉴权基础设施;前端则借助 Axios 拦截器与状态管理库 Zustand,在请求层面实现了“透明”的 Token 刷新逻辑——业务代码无需感知认证细节,所有 401 错误都被拦截器自动处理,真正做到了“无感”。
这套方案的核心价值在于:安全不妥协,体验不打折。Access Token 的短生命周期极大降低了泄露后的危害窗口,而 Refresh Token 的独立存储与严格校验则保障了凭证刷新的安全边界。同时,通过队列机制与重试标记,前端有效解决了并发请求下的 Token 竞态问题,确保系统在高负载下依然稳定可靠。
当然,任何方案都不是银弹。双 Token 机制虽好,仍需配合以下措施,才能构筑起完整的认证安全防线:
- HTTPS 传输:防止 Token 在传输中被窃听
- Refresh Token 黑名单或数据库记录:支持主动吊销
- 合理的过期策略:根据业务敏感度调整时效
- 客户端安全存储:如使用
HttpOnlyCookie 防 XSS(若适用)
但在大多数中大型全栈应用中,这套 双 Token + 无感刷新 的组合,无疑是当前兼顾安全性、可维护性与用户体验的优选方案。