Token 无感刷新与 Logout:前端安全会话管理实战
为什么需要无感刷新?
想象一个场景:你正在编辑一篇长文章,突然页面弹出一个"登录已过期"的提示,刚才写的内容全都丢了------这种体验无疑是一场灾难。
传统的做法是把 access_token 有效期设得很长(比如 30 天),但这等于把家门钥匙挂在门外------一旦 token 被窃取,攻击者可以长时间冒充你的身份。安全专家会告诉你:token 有效期越短越安全。但用户体验又要求"一次登录,长期有效"。
无感刷新就是在这两个矛盾的需求之间找到的平衡点。它的核心思想是:
- access_token:枪械的撞针,短时效(15分钟),频率极高,暴露面大
- refresh_token:保险柜里的备用钥匙,长时效(7天),只用一次(刷新时),暴露面极小
当 access_token 过期时,前端用 refresh_token 悄悄去换一个新的 access_token,整个过程用户完全无感知------就像手机自动连上 Wi-Fi 一样自然。
一次请求的完整旅程
让我们跟随一个普通的 API 请求,看看无感刷新的完整链路:
scss
用户点击「发送消息」
│
▼
┌──────────────────────────────────────┐
│ Chat.tsx │
│ handleSubmit(e) │
│ → triggerRequest(chatRequest) │
│ → callChatApi → fetch('/api/chat') │
└─────────────────┬────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ config.ts - axios 实例 │
│ baseURL: 'http://localhost:5173/api'│
└─────────────────┬────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 请求拦截器 │
│ 自动附加 Authorization 头 │
│ config.headers.Authorization = │
│ `Bearer ${accessToken}` │
└─────────────────┬────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 后端 @UseGuards(AuthGuard) │
│ 验证 access_token │
│ ┌─ 有效 → 正常返回数据 │
│ └─ 过期/无效 → 返回 401 │
└─────────────────┬────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 响应拦截器 │
│ ┌─ 200 → 返回 res.data │
│ └─ 401 → 触发刷新流程 │
└──────────────────────────────────────┘
核心代码解读
1. 双重 Token 的生成
后端在登录时同时签发两个 token,短效和长效形成互补:
typescript
// backend/posts/src/auth/auth.service.ts
private async generateTokens(id: string, name: string) {
const payload = { sub: id, name };
// Promise.all 并行签发,互不阻塞
const [at, rt] = await Promise.all([
this.jwtService.signAsync(payload, {
expiresIn: '15m', // access_token:短时效,安全优先
secret: process.env.TOKEN_SECRET
}),
this.jwtService.signAsync(payload, {
expiresIn: '7d', // refresh_token:长时效,体验优先
secret: process.env.TOKEN_SECRET
}),
]);
return { access_token: at, refresh_token: rt };
}
这里有一个优雅的设计:两个 token 使用相同的 payload 和 secret,只有过期时间不同。这意味着当 refresh_token 到达时,服务器可以直接验证并提取用户信息,无需查询数据库,就为它换发新的 token 对。
2. 请求队列:并发控制的精髓
这是整个无感刷新机制中最精妙的部分。考虑一个常见场景:首页加载时,同时发起了 3 个 API 请求(文章列表、用户信息、推荐内容),它们全部收到了 401。
如果每个请求都独立去刷新 token,就会产生惊群效应------3 个刷新请求同时打到服务器,造成资源浪费,更严重的是后面的请求可能拿到过期的 token。
解决方案是 请求队列 + 刷新锁:
typescript
// frontend/notes/src/api/config.ts
let isRefreshing = false; // 刷新锁
let requestsQueue: any[] = []; // 请求队列
instance.interceptors.response.use(
// 成功回调:直接剥离 res.data,让调用方少写一层
res => res.data,
// 错误回调:核心逻辑
async (err) => {
const { config, response } = err;
if (response?.status === 401 && !config._retry) {
// 场景 A:刷新正在进行中
// 后来的请求不需要自己刷新,只需排队等待
if (isRefreshing) {
return new Promise((resolve) => {
requestsQueue.push((token: string) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(instance(config)); // 用新 token 重试
});
});
}
// 场景 B:第一个遇到 401 的请求
// 它负责刷新 token,其他请求排队
config._retry = true;
isRefreshing = true;
try {
const { refreshToken } = useUserStore.getState();
if (refreshToken) {
const { access_token, refresh_token } =
await instance.post('/auth/refresh', {
refresh_token: refreshToken
});
// 更新 store(持久化到 localStorage)
useUserStore.setState({
accessToken: access_token,
refreshToken: refresh_token,
isLogin: true
});
// 关键:唤醒所有排队请求,逐个用新 token 重试
requestsQueue.forEach((callback) =>
callback(access_token)
);
requestsQueue = [];
// 当前请求自己也要重试
config.headers.Authorization =
`Bearer ${access_token}`;
return instance(config);
}
} catch (err) {
// refresh_token 也过期了 → 真正登出
useUserStore.getState().logout();
window.location.href = '/login';
return Promise.reject(err);
} finally {
isRefreshing = false; // 释放锁
}
}
return Promise.reject(err);
}
);
这段代码的巧妙之处体现在几个细节上:
config._retry 标记:防止刷新后重试的请求再次收到 401 时陷入死循环。如果刷新后的新 token 依然被拒,说明是权限问题而非过期问题,直接 reject。
请求队列用 Promise 挂起 :return new Promise((resolve) => {...}) 让后来请求"暂停"在微任务队列中,等刷新完成后通过 callback(token) 唤醒,实现了优雅的并发控制。
zustand persist 持久化 :token 存储在 localStorage 中,即使页面刷新也不丢失。useUserStore.getState() 是 zustand 提供的非 Hook 方式读取状态,在拦截器这种非组件上下文中非常实用。
3. 刷新端点:极简的后端验证
typescript
// backend/posts/src/auth/auth.controller.ts
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body('refresh_token') refresh_token: string) {
return this.authService.refreshToken(refresh_token);
}
typescript
// backend/posts/src/auth/auth.service.ts
async refreshToken(rt: string) {
try {
const payload = await this.jwtService.verifyAsync(rt, {
secret: process.env.TOKEN_SECRET
});
if (payload) {
// 验证通过 → 签发全新的 token 对
return this.generateTokens(payload.sub, payload.name);
}
} catch (e) {
throw new UnauthorizedException(
'Refresh Token 已失效,请重新登录'
);
}
}
刷新逻辑极其简洁------验证 → 签发 → 返回。没有数据库查询,没有额外的权限校验,因为 refresh_token 本身就是信任凭证。
4. Logout:彻底的会话清理
当 refresh_token 也过期时,才是真正需要用户重新登录的时刻:
typescript
// frontend/notes/src/store/useUserStore.ts
logout: () => {
set({
user: null,
isLogin: false,
accessToken: null,
refreshToken: null,
})
}
zustand 的 persist 中间件会自动把状态同步到 localStorage。调用 logout() 后,所有持久化的认证信息被清除,配合 window.location.href = '/login' 完成页面跳转。
为什么登出不调用后端接口? 因为 JWT 是无状态的------服务器不存储 token,token 的"失效"完全依赖过期时间。登出操作只需要在前端清除 token 即可。如果需要服务端主动失效(比如用户修改密码后强制所有设备下线),那就需要引入 Redis 黑名单机制,这是另一个话题了。
总结:设计决策一览
| 决策 | 做法 | 原因 |
|---|---|---|
| 双 token 策略 | access_token 15min + refresh_token 7d | 安全与体验的平衡 |
| 拦截器位置 | 响应拦截器而非请求拦截器 | 只有在收到 401 时才知道 token 过期 |
| 并发 401 处理 | 请求队列 + 刷新锁 | 避免多请求同时刷新产生竞态 |
| 重试标记 | config._retry |
防止刷新后仍然 401 时死循环 |
| 状态持久化 | zustand persist → localStorage | 页面刷新不丢登录态 |
| 登出逻辑 | 纯前端清除 token | JWT 无状态特性,无需服务端配合 |
写代码如同修桥------既要考虑承重(安全性),也要考虑通行体验(用户体验)。无感刷新的本质,就是在桥的两端各设一个收费站:短效 token 在"安全端"设卡,长效 refresh_token 在"体验端"放行。两者配合,让用户几乎感觉不到"关卡"的存在。