Token 无感刷新与 Logout:前端安全会话管理实战

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 使用相同的 payloadsecret,只有过期时间不同。这意味着当 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 在"体验端"放行。两者配合,让用户几乎感觉不到"关卡"的存在。

相关推荐
不会敲代码11 小时前
我写了一个 HTML 文件,把 JS 事件循环彻底搞懂了
前端·javascript·面试
写不来代码的草莓熊1 小时前
SVG 图标插件误读 PNG 图片 + Vite 重启缓存失效重新生成 + 浏览器严格渲染
前端
燐妤1 小时前
前端HTML编程3:初识CSS
前端·html5
UXbot1 小时前
独立设计师UI设计工具推荐(2026):支持AI原型生成与代码导出的5款工具全面评价
前端·人工智能·低代码·ui·交互·产品经理·web app
anOnion2 小时前
构建无障碍组件之Table Pattern
前端·html·交互设计
mfxcyh2 小时前
如何把对象数据转化为数组
java·服务器·前端
编程技术手记2 小时前
Vite 开发环境前后端端口隔离:解决 index.html 冲突问题
前端·html
舒一笑3 小时前
零后端、零数据库——我做了一个让 10000+ 人成功告白的开源工具
后端·产品·设计师
光影少年3 小时前
react16-react19类组件完整生命周期(挂载/更新/卸载)
前端·javascript·react.js