引言
在现代前后端分离架构中,用户认证与权限控制是系统安全的第一道防线。单 Token 方案虽然简单,但难以兼顾安全性与用户体验:短有效期的 Token 会频繁中断用户操作,而长有效期的 Token 则存在被盗用后长期有效的风险。
双 Token 机制(access_token + refresh_token) 正是为解决这一矛盾而生——它通过两个职责分明、生命周期不同的 Token,在保障接口安全的同时,实现“无感续期”的丝滑体验。
本文将聚焦于 双 Token 登录模块的核心逻辑实现,基于 NestJS(后端)与 React(前端)技术栈,深入剖析从登录生成 Token、请求携带 Token、到过期自动刷新的完整链路。所有代码均来自真实项目,逻辑严谨、可直接复用,助你构建高安全、高可用的认证体系。
一、双Token机制:为什么是“最佳实践”?
1.1 核心角色分工
-
access_token(访问令牌)- 作用:用于访问受保护的 API 接口(如发布文章、修改资料)。
- 特点:短时效(通常 10~30 分钟),一旦泄露,攻击窗口极小。
- 传输方式:放在 HTTP 请求头
Authorization: Bearer <token>中。
-
refresh_token(刷新令牌)- 作用:仅用于调用
/auth/refresh接口,换取新的access_token(和新的refresh_token)。 - 特点:长时效(如 7 天),但绝不用于业务接口,极大降低暴露风险。
- 存储建议:前端应存于
HttpOnly Cookie或安全的持久化存储(如加密 localStorage)。
- 作用:仅用于调用
✅ 安全优势:即使
access_token被窃取,攻击者也无法长期使用;若refresh_token被盗,因其不参与业务请求,检测和撤销也更容易。
1.2 完整流程
用户登录时,前端发送账号密码至后端 /auth/login 接口。后端验证通过后,签发一对 Token:短时效(15分钟)的 access_token 用于访问业务接口,长时效(7天)的 refresh_token 仅用于刷新。前端将双 Token 持久化存储,并在后续请求头中自动携带 access_token。
当 access_token 过期,后端返回 401。前端拦截该错误,若存在有效 refresh_token,则调用 /auth/refresh 获取新 Token 对(实现 Token 轮换,旧 refresh_token 立即失效)。刷新期间的并发请求被暂存队列,待新 access_token 返回后统一重发,全程用户无感。
若 refresh_token 也过期,则清空本地状态并跳转登录页。该机制兼顾安全(短 Token + 轮换)与体验(自动续期),是前后端分离架构下的认证最佳实践。
二、后端实现:NestJS 如何生成与验证双Token?
⚠️ 以下代码基于已搭建好的 NestJS 项目,仅聚焦认证模块核心逻辑。
2.1 登录接口:生成双Token对
登录成功后,后端需同时生成 access_token 和 refresh_token:
// src/auth/auth.service.ts
private async generateTokens(id: string, name: string) {
const payload = { sub: id, name }; // sub 是 JWT 规范字段,代表主体(用户ID)
const [at, rt] = await Promise.all([
this.jwtService.signAsync(payload, {
expiresIn: '15m',
secret: process.env.TOKEN_SECRET
}),
this.jwtService.signAsync(payload, {
expiresIn: '7d',
secret: process.env.TOKEN_SECRET
})
]);
return { access_token: at, refresh_token: rt };
}
关键设计点:
Promise.all并发生成:JWT 签名涉及加密运算,串行执行会增加响应时间,并发可提升性能。- 统一 Payload 结构:两个 Token 使用相同的载荷(用户 ID 和用户名),便于后续解析一致性。
sub字段规范:sub 是 JWT 规范字段,代表主体(用户ID)
2.2 刷新接口:Token 轮换(Token Rotation)
当 access_token 过期,前端调用 /auth/refresh 获取新 Token:
async refreshToken(rt: string) {
try {
const payload = await this.jwtService.verifyAsync(rt, {
secret: process.env.TOKEN_SECRET
});
return this.generateTokens(payload.sub, payload.name);
} catch (e) {
throw new UnauthorizedException('Refresh Token 已失效,请重新登录');
}
}
为何要“轮换”?
- 安全性增强:每次刷新都生成全新的
refresh_token,旧的立即失效。即使旧refresh_token被盗,也无法再次使用。 - 防止 Token 无限续期:避免用户“永远不登出”,符合最小权限原则。
🛡️ 进阶建议:生产环境可将
refresh_token存入 Redis,并设置 TTL(如 7 天),同时维护一个“黑名单”用于强制下线。
2.3 鉴权守卫:保护敏感接口
NestJS 通过 Guard + Strategy 实现声明式鉴权:
// jwt.strategy.ts
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.TOKEN_SECRET
});
}
validate(payload) {
return { id: payload.sub, name: payload.name }; // 挂载到 req.user
}
}
在控制器中使用:
@Post()
@UseGuards(JwtAuthGuard)
createPost(@Req() req) {
const userId = req.user.id; // 自动获取当前用户ID
// ...
}
在 NestJS 中,Strategy(策略) 负责具体的认证逻辑,比如从请求头提取 JWT 并验证其有效性,验证通过后返回用户信息;它不控制请求流程,只做身份解析。而 Guard(守卫) 是 NestJS 的执行钩子,运行在控制器之前,用于决定是否放行请求。Guard 会调用对应的 Strategy:若认证成功,将用户信息挂载到 req.user 并允许进入路由;若失败(如 Token 无效或过期),则直接抛出 401 错误,中断后续操作。通常,JwtStrategy 实现 JWT 验证,JwtAuthGuard 封装该策略并作为守卫使用。二者分工明确:Strategy 是“验票员”,Guard 是“守门人”,共同实现安全、可复用的鉴权机制。
守卫工作流程:
- 请求到达时,守卫拦截;
- 从
Authorization头提取Bearer <token>; - 验证签名、检查是否过期;
- 若有效,调用
validate(),结果挂载到req.user; - 控制器方法中直接使用
req.user,无需手动解析 Token。
❗ 常见坑:若报错
Unknown authentication strategy "jwt",请确保JwtStrategy已在AuthModule的providers中注册。
三、前端实现:React 如何管理与自动刷新 Token?
前端的核心挑战是:如何在 Token 过期时,自动刷新并重试失败请求,且不造成重复刷新或死循环?
3.1 Token 持久化:Zustand + persist
使用 Zustand 的 persist 中间件,将 Token 存入 localStorage:
// useUserStore.ts
export const useUserStore = create<UserState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
user: null,
isLogin: false,
login: async (credentials) => {
const res = await doLogin(credentials);
set({ ...res, isLogin: true }); // 存储 access_token, refresh_token, user
},
logout: () => { /* 清空状态 */ }
}),
{
name: 'user-store',
partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
user: state.user,
isLogin: state.isLogin
})
}
)
);
✅ 优势:页面刷新后状态不丢失,用户无需重新登录。
3.2 Axios 拦截器:自动携带与智能刷新
这是前端最核心的逻辑——请求拦截器 + 响应拦截器协同工作。
请求拦截器:自动注入 Token
instance.interceptors.request.use((config) => {
const token = useUserStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
响应拦截器:处理 401 与 Token 刷新
let isRefreshing = false;
let requestsQueue: ((token: string) => void)[] = [];
instance.interceptors.response.use(
(res) => res.data,
async (err) => {
const { config, response } = err;
if (response?.status === 401 && !config._retry) {
config._retry = true;
if (isRefreshing) {
// 若正在刷新,将请求加入队列,等待新Token
return new Promise((resolve) => {
requestsQueue.push((token) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(instance(config));
});
});
}
isRefreshing = true;
try {
const { refreshToken } = useUserStore.getState();
if (!refreshToken) throw new Error('No refresh token');
const { access_token, refresh_token } = await instance.post('/auth/refresh', {
refresh_token: refreshToken
});
// 更新本地Token
useUserStore.setState({ accessToken: access_token, refreshToken: refresh_token });
// 重发队列中所有请求
requestsQueue.forEach(cb => cb(access_token));
requestsQueue = [];
// 重发当前请求
config.headers.Authorization = `Bearer ${access_token}`;
return instance(config);
} catch (e) {
// 刷新失败:跳转登录页
useUserStore.getState().logout();
window.location.href = '/login';
return Promise.reject(e);
} finally {
isRefreshing = false;
}
}
return Promise.reject(err);
}
);
这段代码实现了 Token 过期时的自动刷新与请求重试机制。当接口返回 401 且请求未被重试过(!config._retry)时,拦截器会尝试用 refresh_token 刷新获取新 Token。若此时已有刷新操作正在进行(isRefreshing = true),则将当前请求加入队列 requestsQueue,等待新 Token 返回后再执行。若无刷新任务,则发起 /auth/refresh 请求,成功后更新本地 Token,并遍历队列批量重发所有挂起的请求,最后重试当前失败请求。若刷新失败(如 refresh_token 过期),则清空登录状态并跳转至登录页。通过 isRefreshing 锁和请求队列,有效避免了重复刷新和请求丢失,确保用户体验无缝。
关键机制解析:
| 机制 | 作用 |
|---|---|
config._retry | 防止 401 错误无限循环触发刷新 |
isRefreshing | 全局锁,避免多个 401 请求同时发起刷新 |
requestsQueue | 缓存刷新期间的所有请求,待新 Token 到手后统一重发 |
💡 用户体验优化:整个过程用户无感知,页面不会跳转,操作不中断。
四、避坑指南:那些年踩过的“雷”
后端篇
-
JwtStrategy 未注册到模块 providers
- 现象:调用受保护接口时报错
Unknown authentication strategy "jwt"。 - 原因:
JwtStrategy是 Guard 的底层策略,必须在AuthModule的providers中显式注册。 - 解决:在
AuthModule的providers数组中加入JwtStrategy。
- 现象:调用受保护接口时报错
-
密码验证失败
- 原因:注册时用
bcrypt.hash(password, 10),登录时却未用bcrypt.compare。 - 正确做法:永远不要比对明文密码,必须用
compare验证哈希值。
- 原因:注册时用
-
JWT 密钥不一致或未加载
- 现象:Token 签发成功,但验证时报
invalid signature。 - 原因:
JwtModule.register()与jwtService.verifyAsync()使用的密钥不同,或.env未正确加载。 - 解决:确保统一使用
process.env.TOKEN_SECRET,并在main.ts中调用config()加载环境变量(如使用@nestjs/config)。
- 现象:Token 签发成功,但验证时报
前端篇
-
Token 持久化失效
- 检查
partialize是否包含accessToken和refreshToken。
- 检查
-
刷新死循环
- 确保
config._retry = true生效,且刷新失败后清空队列并跳转登录。
- 确保
-
请求头格式错误
- 必须为
Authorization: Bearer <token>,Bearer后有空格,否则后端无法解析。
- 必须为
五、总结:双Token 的价值与扩展
双 Token 机制之所以成为行业标准,是因为它精准平衡了安全与体验:
- 安全性高:短时效 access_token 限制泄露后的攻击窗口,refresh_token 不参与业务请求,降低风险。
- 体验流畅:access_token 过期后可自动无感刷新,用户无需频繁重新登录,操作不中断。
- 灵活可控:支持 Token 轮换、强制下线、有效期独立配置,便于实现精细化安全策略。
🌟 结语:认证模块看似简单,实则暗藏玄机。一个健壮的双 Token 体系,不仅能抵御常见攻击,更能为用户提供“无感却安全”的体验。本文所分享的代码与逻辑,已在多个线上项目稳定运行,欢迎直接借鉴、优化、落地。