用户登录态全局管理实操方法(前端双 token 续期)

在企业级 AI 网关项目中,前端登录态管理不能只做”存 token、带 token”两件事,必须构建一个覆盖存储、注入、失效处理三层能力的闭环:Pinia 负责内存态、localStorage 负责持久化、axios 拦截器负责统一注入与 401 兜底,再叠加双 token 静默续期,才能让十几个页面共享同一份登录状态而不各自维护。我们做 AI 爆款文章创作器时踩过的坑是:SSE 流式请求里的 401 和普通请求不一样,处理逻辑必须单独写。下文给出这套方案的完整落地过程。

一、登录态全局管理的三层结构

全局管理的核心诉求是”一处登录,处处可用”。我们把登录态拆成三个层次,每层职责单一:

层次 职责 落地载体
存储层 登录态在内存与磁盘间同步 Pinia + localStorage
注入层 每次请求自动携带凭证 axios 请求拦截器
失效层 401 统一处理、静默续期、踢回登录页 axios 响应拦截器 + 路由守卫

1.1 存储层:Pinia 管内存,localStorage 管持久化

刷新页面时 Pinia 会被清空,所以 token 必须落盘到 localStorage;但所有业务代码只读写 Pinia,不直接碰 localStorage,避免散落。登录成功后一次性写入两处:

// stores/auth.ts
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    accessToken: localStorage.getItem('access_token') || '',
    refreshToken: localStorage.getItem('refresh_token') || '',
    user: null as UserInfo | null,
  }),
  getters: {
    isLoggedIn: (s) => !!s.accessToken,
  },
  actions: {
    setLogin({ accessToken, refreshToken, user }) {
      this.accessToken = accessToken
      this.refreshToken = refreshToken
      this.user = user
      localStorage.setItem('access_token', accessToken)
      localStorage.setItem('refresh_token', refreshToken)
    },
    logout() {
      this.accessToken = ''
      this.refreshToken = ''
      this.user = null
      localStorage.removeItem('access_token')
      localStorage.removeItem('refresh_token')
    },
  },
})

1.2 注入层:请求拦截器统一加认证头

网关后端按 Bearer 协议校验,所有请求(含 SSE)都走同一个 axios 实例。请求拦截器在发出前读取 Pinia,把 token 写进 Authorization 头:

import axios from 'axios'
import { useAuthStore } from '@/stores/auth'

const http = axios.create({ baseURL: '/api', timeout: 30000 })

http.interceptors.request.use((config) => {
  const auth = useAuthStore()
  if (auth.accessToken) {
    config.headers.Authorization = `Bearer ${auth.accessToken}`
  }
  return config
})

这段代码的好处是:新增一个页面、新增一个接口,登录态自动跟上,不用在业务层重复取 token。

二、失效层:401 拦截与静默续期

access token 生命周期短(我们网关设为 30 分钟),到期后所有请求会返回 401。响应拦截器拦截 401,用 refresh token 换新凭证,再重放原请求,用户全程无感知。

2.1 双 token 机制

网关对短时凭证与长时凭证做了区分:

凭证 有效期 用途
access_token 30 分钟 每次请求携带,后端校验身份
refresh_token 7 天 仅在续期接口使用,换取新的双 token

refresh token 只允许换一次,换完立即作废,即便泄露也只能用一次。这是企业级项目里通行做法,我们照搬到了网关前端。

2.2 并发刷新保护

多个接口同时 401 时,如果每个都去调刷新接口,后端会被打出一堆无效请求。用 isRefreshing 标志加一个失败队列,保证同一时间只有一次刷新,其余请求排队等新 token:

let isRefreshing = false
let pendingQueue: Array<{ resolve: (value: unknown) => void }> = []

http.interceptors.response.use(
  (res) => res,
  async (error) => {
    const { response, config } = error
    if (!response || response.status !== 401) return Promise.reject(error)
    if (config.url === '/auth/refresh' || config._retried) {
      forceLogout()
      return Promise.reject(error)
    }

    const auth = useAuthStore()
    if (!auth.refreshToken) {
      forceLogout()
      return Promise.reject(error)
    }

    if (isRefreshing) {
      // 已有刷新在途,挂到队列等新 token
      return new Promise((resolve) => pendingQueue.push({ resolve }))
    }

    isRefreshing = true
    config._retried = true
    try {
      const { data } = await http.post('/auth/refresh', {
        refreshToken: auth.refreshToken,
      })
      auth.setLogin(data)
      pendingQueue.forEach(({ resolve }) => resolve(data.accessToken))
      pendingQueue = []
      config.headers.Authorization = `Bearer ${data.accessToken}`
      return http(config)
    } catch (e) {
      forceLogout()
      pendingQueue = []
      return Promise.reject(e)
    } finally {
      isRefreshing = false
    }
  },
)

刷新失败说明 refresh token 也失效了,走 forceLogout():清空 Pinia 与 localStorage,跳登录页并携带 redirect 参数,登录成功后跳回原页面。

三、SSE 流式请求的 401 是例外场景

这是我们在 AI 网关项目里单独处理的一环。网关的对话接口走 SSE 长连接,一次流式响应可能持续几十秒。如果 token 恰好在这期间过期,后端会中途断开并返回 401 事件,此时响应头阶段已经结束,拦截器里那套”刷新后重放”的逻辑对长连接无效。

我们的做法是:SSE 请求不走 axios 响应拦截器的自动重试,而是在流式读取循环里识别 401 事件,提示”登录已过期”,引导用户重新登录;同时把对话上下文暂存到本地,登录后恢复现场。对普通请求保留静默续期,对流式请求降级为显式提示,两种策略分开写,谁都不影响谁。

四、登录态管理的完整落地步骤

按下面顺序接入,可以避免”能登录但一刷新就掉线”这类问题:

  1. 先写 Pinia 的 auth store,包含 setLogin / logout / 双 token 字段,并在 state 初始化时从 localStorage 恢复;
  2. 封装 axios 实例,加请求拦截器注入 Authorization,加响应拦截器处理 401 与并发刷新;
  3. 路由守卫里加白名单(login 等免登录页),无 token 访问受限页时跳登录;
  4. SSE 流式请求单独封装,用 fetch 而非 axios,读流时识别 401 事件走显式提示逻辑;
  5. 全项目统一通过 useAuthStore() 读登录态,禁止业务代码直接操作 localStorage。

五、三个常见的坑

存储方案选错是头号问题:token 放 sessionStorage,刷新标签页就丢;放 cookie 会被每个请求自动带上,且 4KB 上限受限。localStorage 没有这些限制,但 XSS 风险要靠”不落地存储明文敏感信息 + 请求侧 CSP”兜底,风险点在网关后端校验环节控制。

刷新 token 时如果没做并发保护,高峰期会打出几十个无效刷新请求。isRefreshing 加队列这段必须写,否则后端日志里全是刷新接口的重复调用。

跳登录页的逻辑别散落在各个组件里。统一收敛在拦截器的 forceLogout() 与路由守卫两处,出问题排查时只需找这两个点。

常见问题(FAQ)

Q1:token 为什么必须存 localStorage?

刷新页面会清空内存,localStorage 能持久化;再配合 Pinia 读取,两者互补。

Q2:refresh token 泄露了怎么办?

一次性使用、用完即作废,再叠加有效期限制,泄露窗口被压缩到极小。

Q3:SSE 长连接里 token 过期怎么处理?

无法重放请求,只能识别 401 事件提示重登,并暂存对话上下文恢复现场。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部