成为全栈·React 管理后台篇·前端鉴权闭环:内存令牌、刷新旋转与路由守卫
登录页拿到 token 只是鉴权的起点。刷新页面、多个请求同时过期、账号被禁用和主动登出之后,界面仍能给出一致结果,才算形成闭环。

前言
前端鉴权最简单的实现,大概是登录后把 token 存进 localStorage,每次请求带上它,接口返回 401 就跳转登录页。这个方案很快,而且在演示环境里通常运行良好。
真正使用时,它会连续撞上几个问题:accessToken 过期本来可以刷新,却把正在编辑文章的用户直接踢走;首屏六个请求一起返回 401,刷新接口被调用六次;刷新令牌旋转后,后五次还拿旧令牌请求,最终整个会话失败;账号被禁用时又不断尝试刷新;页面重载以后,路由守卫在恢复完成前先跳到登录页。
这些问题分别散落在登录页、store、请求层和路由里,却属于同一条会话生命周期。本篇把它们重新接成闭环。
本文要解决什么
- accessToken 为什么只存内存,刷新页面后怎样恢复。
- 刷新令牌旋转为什么要求并发请求复用同一个 Promise。
- 401 怎样区分可恢复过期和必须退出的情况。
- 路由守卫为什么需要等待 boot 状态落定。
- 登出失败时,前端为什么仍要清理本地会话。
前置阅读:认证与授权:JWT、刷新令牌与权限模型、请求层封装:统一信封、业务错误与并发 401
先定义会话的完整生命周期
当前后台的会话大致经过这些状态:
text
首次打开
→ booting:尝试用 HttpOnly Cookie 静默刷新
→ ready + 未登录:显示登录页
→ 登录成功:内存中保存 accessToken、refreshToken、user
→ accessToken 过期:刷新一次并重放原请求
→ 刷新失败 / 账号禁用:清空会话并回登录页
→ 主动登出:服务端尽力作废,本地无条件清空
这里有三个相互独立的问题:认证回答"你是谁",授权回答"你能做什么",会话恢复回答"页面重载后怎样重新确认你是谁"。前端路由守卫可以改善访问体验,却不能替代后端鉴权;隐藏按钮也不会让越权接口变安全。

accessToken 放内存,是安全与体验之间的一次取舍
把 token 放 localStorage 的优点很直接:刷新页面还在,实现简单。但只要页面发生 XSS,脚本就能读取并带走长期保存的令牌。内存状态至少把暴露时间限制在当前页面会话,而且页面刷新会清空它。
当前认证 store 因此不使用 persist:
ts
interface AuthState {
accessToken: string | null
refreshToken: string | null
user: User | null
bootStatus: 'idle' | 'booting' | 'ready'
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
refreshToken: null,
user: null,
bootStatus: 'idle',
setSession: (auth) => set((prev) => ({
accessToken: auth.accessToken,
refreshToken: auth.refreshToken ?? prev.refreshToken,
user: auth.user,
bootStatus: 'ready',
})),
clear: () => set({
accessToken: null,
refreshToken: null,
user: null,
bootStatus: 'ready',
}),
}))
代价也必须承认:刷新页面时 accessToken 必然丢失。项目用后端写入的 HttpOnly Cookie 作为恢复载体,启动时空请求体调用 /auth/refresh。浏览器可以自动携带 Cookie,JavaScript 却读不到它。
这要求前后端部署满足 Cookie 的 SameSite、Secure、Domain、Path 和跨域凭据条件。开发环境通过 Vite 同源代理降低了差异;如果生产环境把 API 放到另一个站点,就必须重新核对 Cookie 策略,不能只复制前端代码。
启动恢复期间,守卫不能抢跑
若认证状态只有"有 token / 没 token",页面刚启动时一定是没 token。路由守卫会立即跳 /login,随后静默刷新成功,又跳回后台,用户看到明显闪烁,原始地址也可能丢失。
因此 store 增加三态 bootStatus:
ts
export const bootstrapSession = async (): Promise<boolean> => {
const store = useAuthStore.getState()
if (store.bootStatus !== 'idle') return Boolean(store.accessToken)
store.setBootStatus('booting')
try {
await refreshOnce()
return true
} catch {
useAuthStore.getState().clear()
return false
}
}
idle 表示还没尝试,booting 表示结论未定,ready 才表示可以让守卫判断。恢复失败对于首次访问或 Cookie 过期是正常路径,不应弹出红色错误;只需明确落到未登录状态。
守卫则在结论出来前显示整页 loading:
tsx
export const RequireAuth = ({ children }: Props) => {
const location = useLocation()
const bootStatus = useAuthStore((s) => s.bootStatus)
const authed = useAuthStore((s) => Boolean(s.accessToken && s.user))
if (bootStatus !== 'ready') {
return <FullPageLoading label="正在恢复登录状态" />
}
if (!authed) {
return (
<Navigate
to="/login"
replace
state={{ from: location.pathname + location.search }}
/>
)
}
return <>{children}</>
}
回跳地址放在 router state 中,而不是公开 query 参数;登录成功后可以回到原来的文章编辑地址,又不会把内部路径复制到分享链接中。
刷新令牌旋转,让并发 401 变成竞态条件
后端每次刷新都会签发新 refreshToken,并让旧 token 失效。这能减少刷新令牌被重复利用,却给前端带来一个必须处理的并发窗口。
假设首页同时请求文章、评论、统计和通知,四个请求拿着过期 accessToken 一起返回 401。如果每个请求都单独刷新:
text
请求 A 刷新成功:refresh-v1 → refresh-v2
请求 B 继续使用 refresh-v1:失败
请求 C 继续使用 refresh-v1:失败
请求 D 继续使用 refresh-v1:失败
解决方法不是加锁库,而是让所有请求共享同一个正在执行的 Promise:
ts
let refreshInFlight: Promise<string> | null = null
const refreshOnce = (): Promise<string> => {
if (!refreshInFlight) {
refreshInFlight = performRefresh().finally(() => {
refreshInFlight = null
})
}
return refreshInFlight
}
第一个 401 创建刷新任务,后续 401 都等待它。成功后新会话一次性写回 store,各自重放原请求;finally 清空引用,让未来真正需要刷新时可以再创建新任务。
原请求还必须标记只重放一次。否则后端持续返回 401 时,会形成"刷新---重放---再刷新"的死循环。当前请求内核用 retried 和 isRefreshCall 同时防止业务请求无限重放、刷新接口刷新自己。

这条路径已经有专门测试:并发三个业务请求首次都返回 401,刷新接口人为延迟以撑开竞态窗口,最终断言只调用一次 /auth/refresh,三个请求均在刷新后成功。普通页面测试很难稳定撞中这个窗口,针对并发性质的测试才有意义。
不是所有 401 都应该刷新
HTTP 状态只能说明未授权,业务错误码才能说明原因。accessToken 过期可以尝试刷新;刷新令牌失效、账号被禁用或明确的登录失败,则不应该进入同一流程。
请求层先按错误码分流:
ts
if (response.status === 401) {
if (shouldForceLogout(code)) {
if (!skipAuthRedirect) {
forceLogout(code === ErrCode.ACCOUNT_DISABLED ? 'disabled' : 'expired')
}
throw apiError
}
const canRetry =
isRefreshable(code) &&
!skipRefresh &&
!flags.isRefreshCall &&
!flags.retried
if (canRetry) {
await refreshOnce()
return rawRequest(path, options, { ...flags, retried: true })
}
}
登录接口本身会在密码错误时返回 401,却绝不能触发静默刷新或"再次跳往登录页",因此显式设置 skipRefresh 和 skipAuthRedirect。启动探测失败也不该弹出"登录已过期"。这些 skip 开关看起来是特殊情况,实际是在定义请求所处的会话语境。
请求层不应该直接 import 路由器
请求核心需要在任何地方使用,路由器又依赖页面和 store。如果请求层直接调用 navigate,依赖很容易绕成环。
项目采用注册回调的方式隔开两层:
ts
let unauthorizedHandler: UnauthorizedHandler | null = null
export const setUnauthorizedHandler = (handler: UnauthorizedHandler | null) => {
unauthorizedHandler = handler
}
export const forceLogout = (reason: 'expired' | 'disabled') => {
useAuthStore.getState().clear()
unauthorizedHandler?.(reason)
}
根组件注册具体的 toast 和导航行为。请求层只发出"会话已经无法恢复"的信号,不认识 React Router。这既避免循环依赖,也让请求逻辑可以在测试环境中独立运行。
主动登出要以本地结果为准
用户点登出时,服务端作废刷新令牌当然应该尝试。但网络断开、令牌已经过期时,请求可能失败。若前端因此拒绝清空本地状态,用户明明点了退出,界面却还保持登录,这比服务端调用失败更糟糕。
ts
export const logout = async (): Promise<void> => {
const { refreshToken } = useAuthStore.getState()
try {
await http.post('/auth/logout', refreshToken ? { refreshToken } : {}, {
skipAuthRedirect: true,
skipRefresh: true,
})
} catch {
// 服务端尽力作废,不阻塞本地退出
} finally {
useAuthStore.getState().clear()
}
}
这里的边界很明确:前端能保证本机不再以登录态工作,不能在断网时保证服务端立刻收到作废请求。后端仍需依靠 refreshToken 过期、旋转和吊销机制控制风险。
路由和按钮权限只是体验层
会话恢复完成后,路由还要继续区分三层:未登录由 RequireAuth 送往登录页;普通 member 由 RequireConsole 拦在管理后台之外;editor/admin 再由 RequireCan 判断具体页面能力。
这种分层能给出更准确的失败去向,也能隐藏无权使用的菜单和按钮。但攻击者可以绕过 React 页面直接请求 API,因此所有真正的授权判断仍必须由后端执行。前端权限的职责,是减少误操作并解释结果。
适用边界
内存 token 加 HttpOnly Cookie 不是唯一方案。严格同源的 Web 应用可以完全依赖服务端 Session Cookie;跨设备客户端可能需要安全存储 refreshToken;高风险系统还会增加 CSRF 防护、设备管理和主动吊销。
本项目方案成立的前提,是后端已经支持刷新令牌旋转和 Cookie 恢复。若后端只返回一个长期 JWT,前端单方面把它从 localStorage 移到内存,只会让用户不断重新登录,并没有补齐闭环。
小结
一个完整的前端鉴权流程,包括登录写入、请求携带、启动恢复、过期刷新、并发去重、单次重放、不可恢复错误退出、路由等待以及本地无条件登出。任何一环缺失,都可能在网络抖动或令牌过期时表现成"偶尔被踢下线"。
各位看官,登录功能是否可靠,不能只看登录按钮能不能跳转。请刷新一次页面,同时触发几个请求,让 accessToken 过期,再禁用账号并断网登出。系统在这些边界里仍能给出一致结果,鉴权才算真正闭环。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
