从“刷新就掉线”到稳定保持登录,这个困扰我很久的登录状态问题终于解决了。前前后后折腾了两三个星期,踩了无数坑,最后发现问题的根源不是一个点,而是一整套机制的缺失。想把这段排查和改造的经历完整记录下来,给正在被“明明登录了,一刷新就变游客”“请求时不时401”折磨的朋友一个可直接参考的方案。
先说下背景:某个内部中后台管理系统,前端采用 Vue 技术栈,接口层统一走了 axios 请求封装;后端是 Node.js 提供接口服务,早期登录态用的是 Cookie + Session 的经典方案。听起来没什么毛病,但系统部署到正式环境后,登录状态极不稳定,问题集中出现在几个场景:登录成功后刷新页面,用户直接被登出;在系统里操作十几分钟,下一次请求就返回 401;有时候请求状态是正常的,但前端拿到的用户信息是一片空白。整个团队被这个“薛定谔的登录态”折磨得够呛。
这次改造的核心思路,是彻底放弃“纯服务端 Session + 浏览器 Cookie”的组合,转向“双 Token + 自动续期”的认证模型。整套方案落地后,刷新不再掉线、长时间挂机也不会被突然踢出,跨域的认证问题也一并解决了。下面把这套方案的思考过程、核心代码、配置细节和踩坑记录完整展开。
1. 问题复现与根因定位:先弄清楚登录状态为什么丢
1.1 症状清单:这些现象你都遇到过吗
在动手改代码之前,我先把团队反馈的问题整理成了症状清单,因为只有把现象描述精确到“哪个环节、哪种时机、什么表现”,排查才不会变成无头苍蝇。整理下来,主要症状是四类:
- 登录成功后跳转首页,第一次刷新页面就变成未登录状态,需要重新输入账号密码。
- 登录后处于正常操作状态,但每隔 15 到 20 分钟左右,接口开始批量返回 401,前端没有统一处理提示,用户只能自己刷新页面碰运气。
- 用户信息接口(/me、/user/info 之类的接口)的返回结果不稳定,有时候能拿到完整资料,有时候只返回一个空对象。
- 访问某个业务功能页,点击操作按钮时报 403,需要二次登录后才能继续使用。
这四条看着是不同的问题,但把它们放到一块儿看,规律其实非常明显:都和“时间”有关,也都和“凭证失效后的处理方式”有关。15 到 20 分钟这个时间点尤其敏感,因为它恰好和服务端的 Session 超时时间设置吻合——团队里有人在早期配置 Session 过期时间时,随手写了一个非常短的值。
1.2 排查过程记录:从 Network 面板开始一层层剥
排查的第一步是打开浏览器开发者工具的 Network 面板,观察 401 发生时,请求头里到底带了什么。结果发现一个很诡异的现象:某些请求的 Cookie 字段里,会话标识还在,但服务端仍然返回 401。这就说明问题不一定在“凭证丢失”,更可能在“凭证有效性校验”上。
于是我把排查方向分成三条线并行推进:
- 第一条线:确认浏览器的 Cookie 是否成功写入、是否按预期随请求发送。
- 第二条线:确认服务端读到 Cookie 后,能否从 Session 存储中找回对应的会话数据。
- 第三条线:确认 Session 存储与接口服务之间的连接是否稳定。
先说第一条线。打开 Application 面板看 Cookie 时,发现会话标识确实写入了,不过没有写入过期时间——这本身意味着它是一个“会话级 Cookie”。但问题在于,逆代部署时前端域名和接口域名不完全一致,部分浏览器环境对跨域 Cookie 的策略收紧了。实际表现是:接口请求时 Cookie 有时候带、有时候不带,带的情况读出来的值也经常不完整。这类“玄学掉线”大多源于此。
再说第二条线。服务端日志显示,收到 Cookie 后去查找会话实体的操作偶尔超时。进一步排查时发现,团队之前图省事把 Session 直接存在了本地内存里。对于单实例开发环境来说,这种方案完全够用。但是正式环境有多个服务实例,如果一次请求落到没有该 Session 的实例上,那自然是查不到有效会话的。这种问题极其隐蔽,因为你不是每次都失败,而是“取决于请求被分配到哪个实例”。
第三条线的结论就更直接了:没有任何服务端错误日志,纯粹就是客户端根本拿不到合法的身份凭证去请求接口。于是整个问题链就清晰了——服务端 Session 机制本身没问题,问题出在“客户端凭证不可靠”和“服务端会话存储不共享”这两件同时存在的事情上。
1.3 根因结论:不是某一行代码的问题,是一整套机制的缺失
经过上面的排查,我对根因做了一个整体判断:现有方案既依赖浏览器 Cookie 的跨站点行为,又依赖服务端实例间共享会话存储,这两条链路恰好都存在薄弱点。再加上一个附加隐患——原方案中用户信息是服务端在登录成功后塞进 Session 里的,而运维配置的 Session 有效期非常短,导致用户信息频繁失效。
所以在正式动手改造前,我定了一个原则:这次的方案必须满足以下四个条件,缺一不可:
- 凭证不能依赖第三方容器存储,客户端自身要能完整携带身份信息。
- 服务端不能保存会话状态,也就是要做成无状态接口,这样多实例部署时天然不慌。
- 凭证必须有过期机制,同时系统要有“无感续期”能力,不能让用户干到一半被踢下线。
- 前端需要统一拦截所有 401 响应,并具备自动恢复的能力。
2. 方案选型与设计思路:双 Token 模型到底解决了什么
2.1 为什么放弃 Session + Cookie:它本身没错,但场景不匹配
Session + Cookie 不是错方案,在传统同域部署、单实例运行的场景下,它依然简单可靠。但放回我们现在的环境里,它存在几个无法回避的硬伤。
第一,跨域 + Cookie 的组合越来越难用。如今的浏览器对第三方 Cookie 的管控越来越严格,只要前端页面和后端接口不在同一个站点下,Cookie 的写入和携带就会遇到各种策略层面的限制。你要么做复杂的同站配置,要么就要接受部分浏览器环境下“凭证随机丢失”的事实。
第二,多实例部署时,Session 共享是个绕不开的基建问题。你可以引入集中式会话存储(比如把 Session 放到 Redis 里),这确实能解决跨实例的会话共享,但它给架构引入了额外组件,也带来了更高的运维成本。如果只是一个小型管理系统,为了一个登录态去引入一套缓存中间件,多少有些兴师动众。
第三,Session 方案天然会把“用户状态”和“身份凭证”耦合在一起。用户资料、权限列表、角色信息全都塞在 Session 里,一旦需要调整数据结构,所有已登录用户都可能受影响。而且 Session 是“服务端记住了你”,接口本身不关心调用方是谁,这在接口需要被多个端复用时就不够灵活了。
2.2 JWT 的选型:无状态与自包含带来的取舍
JWT 的全称是 JSON Web Token,它本质上是一个经过签名的、结构化的字符串。相比 Session 方案,它最大的变化是:服务端不再记录“这个用户是否登录”,而是把用户身份信息、过期时间等直接编码进 Token 里,请求到达时,服务端只需要验证签名和有效期,就能确信“这就是那个已登录用户”。
JWT 的优点是自包含、无状态、天然适合多实例部署。它的代价也很明确:没有服务端记录,就意味着你无法主动让一个 Token 失效——除非引入黑名单机制,否则用户修改密码后旧 Token 依然有效,直到它自然过期。所以,在实际工程中,极少有人直接用 JWT 单令牌打天下,更常见的做法是给 JWT 设置一个极短的有效期(比如 15 分钟),同时配一个可撤销的长期凭证(Refresh Token)来实现续期。
2.3 双 Token 模型的工作流:短令牌访问 + 长令牌续期
最终方案确定为“双 Token 模型”。它由两种凭证组成:
- 访问令牌(Access Token):生命周期短,我这里设置为 30 分钟。所有业务接口请求都携带它,服务端只校验它的签名和过期时间。
- 刷新令牌(Refresh Token):生命周期长,我这里设置为 14 天。它只服务于一个特殊接口——刷新接口。客户端拿它去换取新的访问令牌。
整套流程走下来是这样的:用户登录成功后,服务端一次性返回访问令牌和刷新令牌;前端把访问令牌保存在内存里,把刷新令牌写入 HttpOnly Cookie;后续所有请求统一在请求头里携带访问令牌;当访问令牌过期、接口返回 401 时,前端拦截器自动调用刷新接口,携带刷新令牌换取新的访问令牌;如果刷新接口也失败,则跳转登录页。
这个模型的关键价值,是让“短期凭证”和“长期凭证”各司其职。访问令牌期限短,即使泄露,攻击窗口也有限;刷新令牌期限长,但只用于刷新,使用频率低,且放在 HttpOnly Cookie 里,避免脚本直接读取。双令牌在安全性和体验之间取得了很好的平衡。
3. 核心改造落地实操:后端的令牌签发与校验
3.1 登录接口改造:一次签发,返回两种凭证
后端实现我以 Node.js 生态为例子,使用 jsonwebtoken 这个库来做签名与验签。登录接口在验证用户名密码通过后,不再是往 Session 里写数据,而是生成两个令牌返回给前端。
const jwt = require('jsonwebtoken'); const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET; function generateTokens(user) { const accessToken = jwt.sign( { userId: user.id, username: user.username, role: user.role }, ACCESS_TOKEN_SECRET, { expiresIn: '30m' } ); const refreshToken = jwt.sign( { userId: user.id, tokenVersion: user.tokenVersion }, REFRESH_TOKEN_SECRET, { expiresIn: '14d' } ); return { accessToken, refreshToken }; } // 路由:POST /api/auth/login app.post('/api/auth/login', (req, res) => { const { username, password } = req.body; const user = verifyUser(username, password); if (!user) { return res.status(401).json({ message: '用户名或密码错误' }); } const { accessToken, refreshToken } = generateTokens(user); // 刷新令牌放入 HttpOnly Cookie res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', // 视跨域情况调整,见踩坑部分 maxAge: 14 * 24 * 60 * 60 * 1000, path: '/api/auth/refresh' }); return res.json({ accessToken, user: { id: user.id, username: user.username, role: user.role } }); });这里有两个容易被忽略的细节。首先是访问令牌里不要塞太多东西,如非必要,不要把手机号、邮箱这些隐私字段放进 JWT 的 Payload,因为 JWT 的 Payload 只是 Base64 编码,不是加密,任何拿到令牌的人都能解码看到内容。其次是刷新令牌的 Payload 里我加入了一个 tokenVersion 字段,这是为后续实现“强制下线”预留的——如果用户修改密码或管理员禁用账号,只需在数据库里递增这个版本号,旧刷新令牌就会因为验签通过但版本号不匹配而失效。
注意:这里签发令牌用到的两个密钥,千万不能写死在代码里。正确做法是通过环境变量注入,并保证开发、测试、生产环境使用不同密钥。生产环境的密钥强度务必达到至少 256 位随机数。
3.2 认证中间件:无状态校验的核心实现
服务端不再需要去查 Session 来确认用户身份,这一切工作都压缩在一个认证中间件里。它的职责很简单:从请求头里取访问令牌,验证签名,取出用户信息,挂载到请求对象上。
const authenticate = (req, res, next) => { const header = req.headers.authorization; if (!header || !header.startsWith('Bearer ')) { return res.status(401).json({ message: '未提供访问令牌' }); } const token = header.slice(7); try { const payload = jwt.verify(token, ACCESS_TOKEN_SECRET); req.user = { userId: payload.userId, username: payload.username, role: payload.role }; next(); } catch (err) { // 区分令牌过期和非法令牌 if (err.name === 'TokenExpiredError') { return res.status(401).json({ code: 'ACCESS_TOKEN_EXPIRED', message: '访问令牌已过期' }); } return res.status(401).json({ message: '无效的访问令牌' }); } };注意,这里不再使用 req.session 去读用户信息,所有依赖登录态的接口上直接挂这个中间件即可。由于中间件是无状态的,多少个服务实例跑起来都一样,完全不存在“会话不同步”的问题。
3.3 刷新接口:新令牌的签发与旧令牌的失效
刷新接口是整个双 Token 模型里最关键的接口。它的实现逻辑是:从 HttpOnly Cookie 里取出刷新令牌,验签确认有效,然后从数据库里校验用户状态和 tokenVersion,最后签发一组新的令牌。
// 路由:POST /api/auth/refresh app.post('/api/auth/refresh', (req, res) => { const refreshToken = req.cookies.refresh_token; if (!refreshToken) { return res.status(401).json({ message: '未找到刷新令牌' }); } let payload; try { payload = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET); } catch (err) { return res.status(401).json({ message: '刷新令牌无效或已过期' }); } // 从数据库检查用户状态和 tokenVersion const user = getUserById(payload.userId); if (!user || user.tokenVersion !== payload.tokenVersion) { return res.status(401).json({ message: '登录状态已失效' }); } const { accessToken, refreshToken: newRefreshToken } = generateTokens(user); res.cookie('refresh_token', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 14 * 24 * 60 * 60 * 1000, path: '/api/auth/refresh' }); return res.json({ accessToken }); });这里有一个容易踩的坑:刷新接口必须使用独立的 Cookie 路径。如果刷新令牌的 Cookie 没有限制 path,那么它在所有请求中都会被携带——这既扩大了令牌的暴露面,也增加了无意义的请求体积。我在设计时把它的 path 限制为 /api/auth/refresh,只有刷新请求才会携带它,业务接口则不会。
提示:安全要求严格的场景下,刷新令牌还应做轮换(Refresh Token Rotation),也就是每次刷新后旧刷新令牌立即作废,只允许新令牌继续使用。如果检测到旧令牌被重复使用,可以判定为令牌泄露信号,直接要求重新登录。
4. 前端改造与关键配置:拦截器、存储方式与并发控制
4.1 Token 放在哪里:Cookie、localStorage 还是内存
前端第一个要决定的问题就是访问令牌放哪。网上关于这个问题的讨论很多,我最后的选择是:访问令牌放在 JavaScript 内存变量中,刷新令牌放在 HttpOnly Cookie 中。这个组合有几个明显的好处。
如果把访问令牌放进 localStorage,虽然实现简单,但存在一个问题:任何在你的页面里执行的第三方脚本,都有可能通过脚本访问到它。而放在内存里,页面刷新后变量会丢失,这会带来“刷新即需要重新获取访问令牌”的问题——但这恰好由刷新令牌解决,所以整体链路是自洽的。
至于为什么不把访问令牌也放进 Cookie,是因为 Cookie 会自动随请求发送,引入不必要的 CSRF 风险。用 Authorization 请求头手动携带令牌,则可以规避这套风险。
// 内存中的访问令牌 let accessToken = null; export function setAccessToken(token) { accessToken = token; } export function getAccessToken() { return accessToken; }4.2 axios 拦截器:请求自动携带令牌并统一处理 401
前端的 token 管理需要和 axios 结合起来。请求发出前,拦截器统一在请求头中加上 Authorization 字段;响应回来时,如果发现 401,则自动走刷新逻辑。
import axios from 'axios'; const service = axios.create({ baseURL: '/api', timeout: 15000, withCredentials: true // 允许跨域携带 Cookie,依赖后端 CORS 配置 }); service.interceptors.request.use((config) => { const token = getAccessToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; });开发者经常在这里漏掉一个配置: withCredentials。如果前端页面和服务端接口不是完全同源,axios 默认不会携带 Cookie。之前我们排查出来的“Cookie 有时候带、有时候不带”,有一半原因就在这里——CORS 配置里没开 allowCredentials,浏览器直接拦掉了 Cookie 的携带动作。
4.3 响应拦截器:刷新令牌的自动续期与并发请求的排队
响应拦截器是前端改造的重中之重。401 触发刷新时,如果同时有多个业务请求在途,它们都会收到 401 响应,如果每个请求都各自去刷新一次令牌,一方面浪费请求,另一方面可能因为刷新令牌被轮换导致其他请求的刷新操作失效。正确的做法是:用一个“刷新中的 Promise”来合并并发请求,这样同一时刻只会发起一次刷新。
let refreshPromise = null; service.interceptors.response.use( (response) => response.data, async (error) => { const { config, response } = error; // 避免刷新接口自身再次进入该逻辑 if (!response || response.status !== 401 || config.url.includes('/auth/refresh')) { return Promise.reject(error); } if (!refreshPromise) { refreshPromise = axios .post('/auth/refresh', null, { withCredentials: true }) .then((res) => { setAccessToken(res.data.accessToken); return res.data.accessToken; }) .finally(() => { refreshPromise = null; }); } try { const newToken = await refreshPromise; config.headers.Authorization = `Bearer ${newToken}`; return service(config); } catch (refreshError) { clearAccessToken(); window.location.href = '/login'; return Promise.reject(refreshError); } } );这段代码的核心价值在于 refreshPromise 的复用。第一次 401 到来时,refreshPromise 被赋值为刷新请求的 Promise;后续并发的 401 进入后,检测到 refreshPromise 已存在,不再重新发起刷新,而是等待同一个刷新操作完成,然后用新令牌重放各自的请求。这样整个队列中只会出现一次刷新请求,逻辑清晰,也不容易出现竞态问题。
4.4 登录状态初始化的顺序:先刷新再进业务
还有一个容易被忽略的前端细节:应用启动时的初始化顺序。原先的实现是应用挂载后立刻请求用户信息接口,如果访问令牌刚好过期,页面上就会闪现“加载失败”或“未登录”的错误。新方案需要调整为先检查访问令牌是否存在,如果不存在则自动尝试刷新,刷新成功后再请求用户信息、加载业务数据。
async function bootstrap() { if (!getAccessToken()) { try { const res = await axios.post('/auth/refresh', null, { withCredentials: true }); setAccessToken(res.data.accessToken); } catch (err) { window.location.href = '/login'; return; } } const user = await service.get('/user/info'); // 初始化完成 }如果站在使用者视角,这个“无感续期”的效果就是:无论你怎么刷新页面、挂机多久(不超过刷新令牌有效期),系统都不会要求重新输入密码。整个登录态对用户来说几乎是无感的,体验提升非常明显。
5. 配置细节与安全加固:优化体验的同时守好底线
5.1 跨域配置:好好把关 CORS 的凭证放行
前端和后端不在同源时,CORS 配置是个绕不开的话题。前面提到 axios 设置了 withCredentials=true,对应后端就一定要显式允许凭证。这里有个安全细节:allowCredentials 为 true 时,allowedOrigins 不能使用通配符 *,必须明确列出允许的来源。
// 跨域配置示例 app.use((req, res, next) => { const allowedOrigins = ['https://console.example.com']; const origin = req.headers.origin; if (allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); } if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); });这个配置写过一次之后基本就不用再动了,但千万不要偷懒写成 *。一旦允许了任意来源携带凭证,攻击者就可以在自己的恶意页面上向你的接口发起带凭证的请求,这在设计上是高危漏洞。
5.2 强制下线与账号异常处理:利用 tokenVersion
前面说到了刷新令牌里有一个 tokenVersion 字段,这里展开讲一下它的用法。当用户修改密码、管理员禁用账号、或者检测到异常登录时,需要让用户已有的所有会话失效。此时不需要维护一个分布式黑名单,只需要把数据库里对应用户的 tokenVersion 递增一次。
// 强制下线逻辑 async function forceLogout(userId) { await db.users.update( { id: userId }, { $inc: { tokenVersion: 1 } } ); }用户下一次调用刷新接口时,服务端取数据库里的 tokenVersion 和刷新令牌里的 tokenVersion 做比对,发现不一致,就会返回 401,前端收到后跳转登录页。这个方案相比维护黑名单,代码量少得多,也没有分布式一致性的压力——哪怕多实例并发读取到旧版本号,也只是延迟了下线,不会造成安全缺口。
5.3 令牌过期时间怎么定:30 分钟与 14 天的组合依据
令牌的有效期不是拍脑袋写的,而是根据使用场景推算出来的。访问令牌设为 30 分钟,这个时间足够覆盖大多数用户连续操作的需求,同时又把令牌泄露后的可利用窗口控制在一个小时以内。刷新令牌设为 14 天,对应“记住登录状态”的常规周期,超过两周未活跃用户需要重新登录,符合中后台管理系统的安全预期。
如果业务对安全要求更高,可以把访问令牌缩短到 10 到 15 分钟,把刷新令牌缩短到 7 天;如果追求极致体验,访问令牌也可以放宽到 1 小时,但必须保证刷新令牌的路径隔离和轮换机制都已到位。核心原则是:访问令牌越短越安全,刷新令牌必须有独立的控制手段。
6. 常见问题与排查技巧实录:这次改造中踩过的所有坑
6.1 问题速查表:现象、原因与解决措施
改造过程中我整理了若干份排查记录,这里挑最典型的整理成速查表,方便将来直接对照定位。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 登录成功后刷新页面即掉线 | 刷新令牌 Cookie 未写入或未携带 | 确认 Cookie 的 Domain、Path、SameSite 配置,以及 CORS 是否开启 allowCredentials |
| 操作 20 分钟左右开始大量 401 | 访问令牌到期,刷新链路不工作 | 检查前端响应拦截器是否处理 401,刷新接口是否被拦截器自身逻辑误伤 |
| 刷新接口报 404 或 500 | Cookie Path 限制过死或路由地址写错 | 确认刷新 Cookie 的 path 与刷新接口路径一致,且路由可访问 |
| 调用业务接口报 403 | 用户角色或权限信息缺失 | 检查访问令牌 Payload 中是否包含角色字段,服务端中间件是否解析后挂载到请求对象 |
| 刷新令牌被重复使用导致全部失效 | 多设备/多标签页共用同一刷新令牌 | 在并发刷新时合并请求,避免涉及多线程的竞态条件;考虑刷新令牌轮换 |
| 白屏且跳不回登录页 | 刷新失败后的重定向逻辑被循环触发 | 在刷新接口自身的请求中设置跳过 401 拦截标记 |
更多细节下面逐个展开,因为每一个坑背后都是一段“排查半天,最后发现是个小配置”的故事。
6.2 坑点一:刷新接口被自己的拦截器拦截导致死循环
这个问题非常隐蔽,也极具代表性。我在实现响应拦截器时,最初没有处理“刷新接口自身的 401”,结果出现了这样的情况:访问令牌过期后,业务请求收到 401,触发刷新;刷新接口发出请求时,如果此时刷新令牌也已经过期,后端同样返回 401;这个 401 又进入了响应拦截器,又触发了一次“刷新”——但因为访问令牌根本没变化,刷新请求带着同一个过期刷新令牌反复发起,前端就陷入了无限循环的请求轰炸。
解决办法是在响应拦截器里做一次路径判断,对于包含 /auth/refresh 的请求,401 直接放行,不再进入刷新逻辑。这个判断必须放在所有逻辑的最前面,否则死循环依旧会出现。
注意:所有涉及自动刷新机制的请求,都建议在 config 上增加一个内部标记字段,比如 config._retry,然后在拦截器里检查该标记。这是许多成熟项目中通用的防循环手段。
6.3 坑点二:并发请求全部重新登录,刷新令牌被重复消费
那次问题出现的场景是这样的:用户在一个页面里同时打开了三个接口的请求,访问令牌刚好过期。三个请求几乎同时收到 401,各自独立进入刷新逻辑。因为最初没有做刷新请求的合并,三个请求同时携带同一个刷新令牌去刷新。如果后端做了无缝的令牌轮换,第一个刷新请求成功,后两个刷新请求会携带已被轮换掉的旧令牌而失败;如果后端没做轮换,更惨——三个请求拿到三个不同的新访问令牌,前面的令牌在后端还没有被及时替换,用户在一个极其短暂的时间窗口内,所有请求全部 401。
解决方案就是我前面代码里写的 refreshPromise 单例模式,把并发刷新合并为一次请求。这个改动非常小,但收益极大,几乎是双 Token 方案里必不可少的一环。
6.4 坑点三:多标签页互踢问题与订阅通知
上线后不久,有用户反馈:同时开着两个标签页,在 A 标签页里操作一段时间,切到 B 标签页时 B 已经掉线了。原因也不复杂——每个标签页各自维护一份内存中的访问令牌,A 标签页刷新了令牌,B 标签页里的令牌还是旧的,一旦 B 标签页里的旧令牌过期并尝试刷新,而后端已经按照 A 的刷新结果更新了令牌记录,B 就会因为刷新令牌失配而被迫重新登录。
要彻底解决多标签页同步问题,比较常规的做法是引入浏览器端的跨标签页通信。可以在应用启动时监听 storage 事件,或者用更轻量的方式:在刷新成功后,通过 localStorage 写入一个“令牌时间戳”,其他标签页监听到时间戳变化后主动清除自己内存里的令牌,并在下一次请求时重新走刷新流程。这样所有标签页都能保持相对同步的登录状态。
我这个内部管理系统暂时没有多标签页的强制要求,所以最终只在 B 标签页访问时做了一次兜底刷新,遇到了就刷新,没有做全链路同步。但如果你的业务中多标签页使用场景很频繁,这部分一定要提前规划。
6.5 遗留下来的优化空间:黑名单、审计与监控
方案落地后,整体运行非常稳定,但这套模型并不完美。目前已知的遗留问题是:如果用户的访问令牌在到期前被泄露,攻击者能在剩余的有效期内使用它。要彻底解决这个问题,需要引入令牌黑名单机制,也就是把提前发现的非法令牌加入一个短时缓存,服务端校验时先查黑名单再验签。因为只有访问令牌需要黑名单,而且它的有效期很短,所以黑名单的存储占用和查询压力都很小。
此外,针对频繁尝试刷新令牌的行为,建议在刷新接口加上简易的风控策略:比如同一用户在一分钟内提交超过一定次数的刷新请求时,直接判定为异常并强制该用户的 tokenVersion 递增一次。这个实现成本很低,但能把暴力撞令牌的可能性压到极低。
最后的一些体会
回头来看,这次改造最核心的收获不是某个具体接口的写法,而是建立了一套完整的“认证链路思维”:从凭证怎么生成、怎么存储、怎么携带,到过期后怎么续期、失败后怎么兜底,每个环节都必须考虑清楚。只盯着某一环修修补补,很难彻底解决登录态的问题。
如果看完这篇分享,你也准备对自己的系统做一次类似的改造,我的建议是从小处着手:先把后端双令牌签发做出来,再搭一个能处理 401 的响应拦截器,两步走通,就能覆盖大部分“刷新掉线”场景。之后再根据实际需求逐步添加刷新令牌轮换、多标签页同步、风控策略这些进阶能力。
其实还有一个很小的细节值得单独提一下:在整个联调过程里,浏览器开发者工具里有个“网络节流+离线模拟”的调试模式,对排查登录态丢失的边界情况特别有用。用它可以轻松模拟弱网环境下,刷新请求超时、中断后前端的行为是否符合预期。这个技巧是我调试时偶然发现的,确实省了不少事。希望这篇拆解能给你一些启发,少走我走过的弯路。