关掉标签再打开,Refresh Token 还在却被强制重新登录

刷新页面后,登录状态不是 localStorage 里还有 Token

用户关掉页面 20 分钟后再打开,localStorage 里仍有 Refresh Token,却被强制重新登录。

原因不是 Token 没存上,而是恢复流程拿已经过期的 Access Token 去请求当前用户,失败后把仍然有效的 Refresh Token 一起清掉了。

本文只讨论页面刷新后前端如何用双令牌恢复登录,以及当前 AuthContext 为什么会误删会话。双令牌签发、Redis 白名单和 Rotation 见 知识分享平台项目复习第一天:登录模块中的双令牌与刷新安全-CSDN博客;Token 放在 localStorage 的 XSS 风险见 知识分享平台项目复习第二天:登录模块中的双令牌与刷新安全(2)-CSDN博客


1. 前端到底保存了什么

AuthContextlocalStorage 里写了两份数据:

Key

内容

zhiguang_auth_tokens

Access Token、Refresh Token、Access Token 的 expiresAt

zhiguang_current_user

当前用户信息

对应结构在 zhiguang_fe-main/src/context/AuthContext.tsx

type AuthTokens = {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
};

后端 TokenResponse 里其实有 refreshTokenExpiresAt,但 toTokens() 转换时只取了 accessTokenExpiresAt。前端因此无法在本地判断 Refresh Token 是否过期,只能把这枚 Token 交给刷新接口,由后端验签和 Redis 白名单决定。

保存 Token 也不是为了让前端验签。前端不验证 JWT 签名,只在后续请求里继续持有凭证:Access Token 访问业务接口,Refresh Token 续期。验签仍由后端完成。


2. 缓存用户不是登录证明

页面重新加载时,AuthContext 会立刻从 localStorage 读出 Token 和用户:

const [tokens, setTokens] = useState<AuthTokens | null>(() => readStoredTokens());
const [user, setUser] = useState<AuthenticatedUser | null>(() => readStoredUser());
const [isLoading, setIsLoading] = useState<boolean>(!!tokens);

user 的初始值来自本地缓存。AuthStatus 会等 isLoading 结束,期间显示「加载中...」;但任何没判断 isLoading 的调用方,都可以立刻读到这份缓存身份。这仍然不是登录依据:浏览器端能改 localStorage,服务端的资料、账号状态和权限也可能已经变了。

真正的确认发生在 GET /api/v1/auth/me(叙述里简称 /auth/me)。authService.fetchCurrentUser() 走这条接口;isLoading 的初始值是 !!tokens,有本地 Token 时会一直等到这次请求结束,才把导航区从「加载中」切到已登录或「登录」按钮。

/auth/me 同时做两件事:确认当前 Access Token 仍能通过后端认证,并返回服务端此刻的用户数据。缓存可以加速展示,最终登录状态仍由这次请求决定。

当前初始化并不区分 Access Token 是否过期,本地只要有 Token,就直接用它请求 /auth/me

读取本地 Token
→ 携带 Access Token 请求 GET /api/v1/auth/me
→ 成功:更新用户缓存
→ 失败:清除 Access Token、Refresh Token 和用户缓存

3. 两条会把合法会话清掉的路径

Access Token 15 分钟、Refresh Token 7 天。Access Token 先过期、Refresh Token 仍有效,是双令牌的正常状态,不是异常。Refresh Token 存在,就是为了在这种时候换新的 Access Token。当前实现却在冷启动和热路径上,把这个正常状态当成「已经登出」。

3.1 冷启动:过期 Access Token 直接打 /auth/me

挂载时的 useEffect 只要 tokens 存在,就调用 fetchUser(tokens.accessToken),没有先刷新。关掉标签再打开也走同一条路径:JavaScript 定时器只在页面活着时运行,关页 20 分钟后定时器帮不上忙,重开就是一次冷启动。

fetchUser() 的失败分支不看状态码:

} catch (error) {
  console.error("获取用户信息失败", error);
  setUser(null);
  setTokens(null);
  persistTokens(null);
  persistUser(null);
}

Access Token 已过期时,/auth/me 认证失败。随后 Access Token、Refresh Token 和用户缓存被一起删除。此时 Refresh Token 可能仍在 7 天有效期内,只是还没被用来刷新。

3.2 热路径:60 秒周期撞不上 5 秒窗口

页面保持打开时,refresh() 每 60 秒跑一次,但只有距离 Access Token 过期不足 5 秒才真正请求刷新:

if (Date.now() < tokens.expiresAt - 5_000) {
  return;
}

根因不是「有定时器」,而是检查周期 60 秒大于刷新窗口 5 秒。15 分钟的 Access Token 在绝大多数 tick 上会直接 return。若某次检查落在过期前 30 秒,这次不刷新;下一次已是 60 秒后,Access Token 已经过期。

改法有两种,当前都没做:按 expiresAt - 5s 设一次性 timeout;或把刷新窗口改到不小于一个 interval,例如提前 90 秒。


4. 失败处理把认证失败和断网当成同一件事

apiFetch!response.ok 时抛 ApiError没有 401 拦截器,也不会自动刷新。因此「后端返回 401 时再刷新一次」只是建议,不是当前行为。

fetchUser()catch 覆盖所有异常,不只 401。临时断网或服务端短暂 5xx,也会删掉仍然有效的 Refresh Token,用户只能重新登录。

更稳妥的是分开三条出口:

/auth/me 成功
→ 更新用户缓存,保持双令牌

认证失败(401)
→ 尝试用 Refresh Token 刷新
→ 刷新成功后再请求 /auth/me
→ 刷新也失败,才清除登录状态

网络错误或 5xx
→ 保留本地双令牌
→ 不把用户打成未登录

本地 expiresAt 只能用来减少无效请求,不能当最终安全判断。它相对的是浏览器时钟,不是服务器时钟。即使前端认为 Access Token 尚未过期,后端仍可能返回 401;那时同样应该走刷新,而不是清会话。

恢复流程若改为先刷新,多标签并发刷新的单飞要和冷启动一起做,见第 6 节。


5. 更合理的恢复流程

当前坏流程与建议流程应对着看:

当前:
读取本地双令牌
→ 直接用 Access Token 请求 /auth/me
→ 任意失败:清除全部登录状态

建议:
读取本地双令牌
→ Access Token 已过期或即将过期:先用 Refresh Token 刷新
→ 保存新的双令牌
→ 用新 Access Token 请求 /auth/me
→ 仅当 Refresh Token 也失效时,才清除登录状态
→ 网络错误:保留本地凭证,下次再试

业务请求同样带 Authorization: Bearer <Access Token>。后端在 Controller 之前用 JwtDecoder 验签;/auth/me 通过 @AuthenticationPrincipal Jwt 取的是已经认证过的 principal,不是 Controller 自己解析请求头。对前端恢复流程而言,这里只需要记住一件事:/auth/me 失败首先表示认证没过,应尝试刷新,而不是把本地会话清掉。401 与 403、permitAll() 的完整含义见 第二天


6. 当前实现与演进方案

主题

当前实现

可演进方向

页面恢复

本地有 Token 就用 Access Token 请求 /auth/me;关页再开也走这条路,失败则连 Refresh Token 一起删

Access Token 过期或即将过期时先刷新,再用新 Token 请求 /auth/me;Refresh Token 也失败才清状态

定时刷新

setInterval 每 60 秒检查一次,只在过期前 5 秒才真正刷新

按 Access Token 的 expiresAt 设一次性超时,或把刷新窗口拉到不小于检查周期

/auth/me 失败

fetchUser() 任意异常都清除内存和本地双令牌

仅认证失败(401)才尝试刷新;网络错误或 5xx 保留凭证,不把用户打成未登录

业务请求 401

apiFetch 收到非 2xx 立刻抛错,不刷新、不重试原请求

请求层对 401 刷新一次再重试原请求;刷新失败才登出。尚未落地

前三行都在修同一件事:不要把仍然有效的 Refresh Token 当登出清掉。请求层 401 拦截补的是页面已打开后的热路径,补不齐冷启动误删;冷启动修好之前,拦截器接不上。

fetchingRef 只包了 fetchUserrefresh() 没有单飞。恢复流程若先刷新,多标签并发会碰到 第一天 写过的 Rotation 竞态,应和冷启动刷新一起做成单飞,本文不展开 Redis 侧方案。

7. 可执行判断

  1. 页面恢复:Access Token 过期时先刷新;Refresh Token 也失败再清状态。

  2. 定时刷新:按过期点调度,不要用 60 秒去撞 5 秒窗口。

  3. 错误处理:认证失败才当作可能登出;网络错误保留本地凭证。

  4. 本地 expiresAt 只减少无效请求,最终以服务端 401 为准。

  5. 当前仓库这四项都还没做;介绍项目时不要把「刷新后自动恢复登录」说成已落地。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值