成为全栈·React 管理后台篇·前端鉴权闭环:内存令牌、刷新旋转与路由守卫

成为全栈·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 时,会形成"刷新---重放---再刷新"的死循环。当前请求内核用 retriedisRefreshCall 同时防止业务请求无限重放、刷新接口刷新自己。

这条路径已经有专门测试:并发三个业务请求首次都返回 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,却绝不能触发静默刷新或"再次跳往登录页",因此显式设置 skipRefreshskipAuthRedirect。启动探测失败也不该弹出"登录已过期"。这些 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

相关推荐
余槐i20 小时前
从useState到Agent状态:React状态管理为何在AI工作流中失灵?
react·状态管理·ai agent·langgraph·tool calling
liangshanbo12151 天前
React useTransition 和 useDeferredValue 面试题整理
react·usetransition
梦想的颜色1 天前
【AI科普】AI 时代,纯 H5+CSS PK React & Vue:前端技术孰优孰劣深入剖析
ai·前端框架·大模型·vue·react·html5·vibecoding
liangshanbo12152 天前
面试题:React 中 useReducer 和 useState 有什么区别?什么时候应该使用 useReducer?
react·usereducer
FungLeo2 天前
成为全栈·React 管理后台篇·后台骨架:布局、数据路由与分层守卫
react·管理后台·权限控制·react router·前端路由·成为全栈
秋秋小事2 天前
React hooks总览
react
FungLeo3 天前
成为全栈·Node 后端篇·点赞系统:幂等点赞与计数原子增减
node.js·成为全栈·点赞系统·幂等点赞·计数原子增减
FungLeo4 天前
成为全栈·Node 后端篇·辅助接口:相邻、相关、目录、统计与搜索
node.js·成为全栈
FungLeo4 天前
成为全栈·Node 后端篇·通知系统:事件消费与已读态管理
node.js·成为全栈·通知系统·事件消费·已读状态管理