刷新页面后,登录状态不是 localStorage 里还有 Token
用户关掉页面 20 分钟后再打开,localStorage 里仍有 Refresh Token,却被强制重新登录。
原因不是 Token 没存上,而是恢复流程拿已经过期的 Access Token 去请求当前用户,失败后把仍然有效的 Refresh Token 一起清掉了。
本文只讨论页面刷新后前端如何用双令牌恢复登录,以及当前 AuthContext 为什么会误删会话。双令牌签发、Redis 白名单和 Rotation 见 知识分享平台项目复习第一天:登录模块中的双令牌与刷新安全-CSDN博客;Token 放在 localStorage 的 XSS 风险见 知识分享平台项目复习第二天:登录模块中的双令牌与刷新安全(2)-CSDN博客。
1. 前端到底保存了什么
AuthContext 往 localStorage 里写了两份数据:
| Key | 内容 |
|---|---|
|
| Access Token、Refresh Token、Access Token 的 |
|
| 当前用户信息 |
对应结构在 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 请求 | Access Token 过期或即将过期时先刷新,再用新 Token 请求 |
| 定时刷新 |
| 按 Access Token 的 |
|
|
| 仅认证失败(401)才尝试刷新;网络错误或 5xx 保留凭证,不把用户打成未登录 |
| 业务请求 401 |
| 请求层对 401 刷新一次再重试原请求;刷新失败才登出。尚未落地 |
前三行都在修同一件事:不要把仍然有效的 Refresh Token 当登出清掉。请求层 401 拦截补的是页面已打开后的热路径,补不齐冷启动误删;冷启动修好之前,拦截器接不上。
fetchingRef 只包了 fetchUser,refresh() 没有单飞。恢复流程若先刷新,多标签并发会碰到 第一天 写过的 Rotation 竞态,应和冷启动刷新一起做成单飞,本文不展开 Redis 侧方案。
7. 可执行判断
-
页面恢复:Access Token 过期时先刷新;Refresh Token 也失败再清状态。
-
定时刷新:按过期点调度,不要用 60 秒去撞 5 秒窗口。
-
错误处理:认证失败才当作可能登出;网络错误保留本地凭证。
-
本地
expiresAt只减少无效请求,最终以服务端 401 为准。 -
当前仓库这四项都还没做;介绍项目时不要把「刷新后自动恢复登录」说成已落地。

2795

被折叠的 条评论
为什么被折叠?



