Vue 基础体系 · 第 44/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 登录与权限:路由、按钮、Token 刷新、403 和状态恢复

在一个真实的 Vue 应用中,“登录后能看到页面”只是认证与授权问题的起点。完整的权限系统至少要处理这些问题:

  • 用户是否已经登录;
  • 当前用户能否进入某个路由;
  • 当前用户能否看到某个按钮;
  • 后端返回 401 Unauthorized 时如何刷新 Token;
  • 后端返回 403 Forbidden 时如何提示或降级;
  • 浏览器刷新、关闭后重新打开时,前端如何恢复登录状态;
  • 多个请求同时失效时,如何避免并发刷新 Token;
  • 前端权限控制被绕过后,后端如何继续保护数据。

本文使用 Vue 3、Composition API、TypeScript、Vue Router 4、Pinia 和现代 Vite 工具链。示例假设后端提供以下接口:

POST /api/auth/login
POST /api/auth/refresh
POST /api/auth/logout
GET  /api/me
GET  /api/projects

其中:

  • POST /api/auth/login 返回短期 accessToken
  • POST /api/auth/refresh 使用刷新凭证换取新的 accessToken
  • GET /api/me 返回当前用户及其权限;
  • 业务接口在访问令牌无效时返回 401
  • 令牌有效但权限不足时返回 403

一、先区分认证与授权

1. 认证回答“你是谁”

认证(Authentication,简称 AuthN)是确认请求者身份的过程。

用户提交用户名和密码后,服务器验证成功,可能返回:

{
  "accessToken": "eyJ...",
  "user": {
    "id": "u_1001",
    "name": "Alice"
  }
}

之后,前端请求业务接口时携带访问令牌:

Authorization: Bearer eyJ...

服务器根据令牌判断:

这个请求来自用户 u_1001

认证不等于允许访问资源。一个已经登录的用户,仍然可能没有删除项目的权限。

2. 授权回答“你能做什么”

授权(Authorization,简称 AuthZ)是判断已经确认身份的主体能否执行某项操作的过程。

例如:

用户 Alice 已登录
Alice 可以查看项目
Alice 不可以删除项目

因此,权限判断可以抽象为:

allow(subject,action,resource,context){true,false}allow(subject, action, resource, context) \in \{true, false\}

其中:

  • subject:主体,例如当前用户;
  • action:动作,例如 readcreatedelete
  • resource:资源,例如项目 project-1
  • context:上下文,例如用户所属组织、项目归属关系;
  • allow:授权结果。

路由权限通常只判断较粗粒度的条件:

用户是否拥有 project:read 权限

而后端还可能判断资源级条件:

用户是否拥有 project:delete 权限,并且该项目属于用户所在组织

这也是为什么前端权限控制只能改善用户体验,不能代替后端授权。

二、HTTP 状态码:401 和 403 不是一回事

1. 401 Unauthorized 表示认证凭证不可用

在实际接口中,401 通常表示:

  • 没有携带访问令牌;
  • 访问令牌过期;
  • 访问令牌签名无效;
  • 访问令牌被撤销;
  • 访问令牌格式错误。

严格地说,HTTP 语义中的 401 与身份验证凭证有关。它不应被用来表达“你登录了,但没有这个业务权限”。

前端收到 401 后,常见流程是:

业务请求失败
    ↓
检查是否可以刷新 Token
    ↓
调用 refresh 接口
    ↓
刷新成功:重试原请求
刷新失败:清除状态并跳转登录页

2. 403 Forbidden 表示身份已确认但权限不足

403 通常表示服务器已经知道请求者是谁,但拒绝执行该操作。

例如:

DELETE /api/projects/project-1
Authorization: Bearer valid-token

服务器判断用户没有 project:delete 权限,于是返回:

HTTP/1.1 403 Forbidden

前端不应因为 403 自动刷新 Token,因为刷新 Token 不会改变用户的业务权限。正确处理通常是:

  • 页面访问被拒绝:跳转到 /403
  • 按钮操作被拒绝:显示“无权执行此操作”;
  • 若权限可能刚刚变化:重新获取用户信息或权限列表,但不能把刷新认证凭证当作授权修复。

可以用下面的决策表区分两种错误:

响应 含义 是否刷新 Token 常见处理
401 认证凭证缺失或无效 是,通常一次 刷新成功重试,失败退出登录
403 身份有效但权限不足 显示无权限或跳转 403
404 资源不存在,或服务端故意隐藏资源 显示不存在
429 请求过多 限流提示或退避重试
5xx 服务端故障 否,除非接口另有约定 错误页、重试或上报

某些后端为了避免泄露资源是否存在,会对无权访问的资源返回 404 而不是 403。前端应以接口契约为准,不能仅靠状态码猜测完整的安全策略。

三、Token 的生命周期与存储方式

1. Access Token 和 Refresh Token 的职责不同

Access Token 用于访问业务接口,通常有效期较短。

Refresh Token 用于换取新的 Access Token,通常有效期较长,且权限不应直接拿来访问业务资源。

典型生命周期如下:

sequenceDiagram
    participant B as 浏览器
    participant A as 认证服务
    participant R as 业务服务

    B->>A: 提交账号密码
    A-->>B: accessToken + refresh凭证
    B->>R: Authorization: Bearer accessToken
    R-->>B: 业务数据
    R-->>B: 401 accessToken过期
    B->>A: refresh凭证
    A-->>B: 新 accessToken
    B->>R: 重试原业务请求
    R-->>B: 业务数据

常见的安全设计是:

  • accessToken 只保存在内存中的 Pinia 状态;
  • refreshToken 由服务端通过 HttpOnlySecure、适当 SameSite 属性的 Cookie 保存;
  • 刷新请求通过 credentials: 'include' 携带 Cookie。

这样做的主要原因是:JavaScript 无法读取 HttpOnly Cookie,XSS 脚本不能直接把刷新凭证读出来。

这并不意味着 HttpOnly Cookie 能消除 XSS 或 CSRF 风险:

  • XSS 仍可能代替用户发起请求;
  • Cookie 认证需要配置 CSRF 防护;
  • 仍需正确设置 CSP、输入输出编码和依赖安全策略;
  • Secure 需要 HTTPS 才能生效。

把 Token 放进 localStorage 便于刷新后恢复,但任何能执行页面 JavaScript 的 XSS 都可能读取它。是否持久化应根据威胁模型和产品需求决定,而不能把“方便恢复”直接等同于“安全”。

2. Access Token 过期不等于用户注销

Access Token 过期时,用户身份可能仍然有效,因为 Refresh Token 尚未过期。前端应先尝试刷新,而不是看到一次 401 就立即清除用户状态。

但刷新失败时,必须清除本地认证状态。否则会出现:

前端认为用户已登录
所有请求都返回 401
路由守卫不断尝试恢复
页面陷入循环

四、建立明确的认证状态机

登录状态不应只用一个模糊的 isLoggedIn 布尔值表示。至少需要区分以下状态:

export type AuthStatus =
  | 'unknown'       // 应用启动后,尚未恢复状态
  | 'anonymous'     // 确认未登录
  | 'authenticated' // 已登录
  | 'refreshing';   // 正在刷新访问令牌

状态转换可以表示为:

stateDiagram-v2
    [*] --> unknown
    unknown --> authenticated: refresh成功或登录成功
    unknown --> anonymous: refresh失败或无凭证
    authenticated --> refreshing: 收到401
    refreshing --> authenticated: refresh成功
    refreshing --> anonymous: refresh失败
    authenticated --> anonymous: 主动退出
    anonymous --> authenticated: 登录成功

这里的 unknown 很重要。应用刚加载时,前端尚不知道:

浏览器中是否存在有效的刷新 Cookie?

如果直接把初始值设成 false,路由守卫可能在恢复请求完成前把用户误判为未登录并跳转到登录页。

五、使用 Pinia 管理认证状态

下面的 Store 使用 Setup Store 写法。它假设 Refresh Token 在 HttpOnly Cookie 中,因此 Store 不保存 Refresh Token 字符串。

// src/stores/auth.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export type Permission =
  | 'project:read'
  | 'project:create'
  | 'project:update'
  | 'project:delete'
  | 'user:manage'

export interface User {
  id: string
  name: string
  permissions: Permission[]
}

interface LoginResponse {
  accessToken: string
  user: User
}

interface MeResponse {
  user: User
}

export const useAuthStore = defineStore('auth', () => {
  const accessToken = ref<string | null>(null)
  const user = ref<User | null>(null)
  const status = ref<'unknown' | 'anonymous' | 'authenticated' | 'refreshing'>(
    'unknown',
  )

  const isAuthenticated = computed(
    () => status.value === 'authenticated' && !!user.value,
  )

  let restorePromise: Promise<boolean> | null = null
  let refreshPromise: Promise<boolean> | null = null

  async function login(username: string, password: string) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      credentials: 'include',
      body: JSON.stringify({ username, password }),
    })

    if (!response.ok) {
      throw new Error(`登录失败:HTTP ${response.status}`)
    }

    const data = (await response.json()) as LoginResponse

    accessToken.value = data.accessToken
    user.value = data.user
    status.value = 'authenticated'
  }

  async function restore(): Promise<boolean> {
    if (status.value === 'authenticated') {
      return true
    }

    if (restorePromise) {
      return restorePromise
    }

    restorePromise = refresh().finally(() => {
      restorePromise = null
    })

    return restorePromise
  }

  async function refresh(): Promise<boolean> {
    if (refreshPromise) {
      return refreshPromise
    }

    status.value = 'refreshing'

    refreshPromise = (async () => {
      try {
        const response = await fetch('/api/auth/refresh', {
          method: 'POST',
          credentials: 'include',
        })

        if (!response.ok) {
          clearAuth()
          return false
        }

        const data = (await response.json()) as LoginResponse
        accessToken.value = data.accessToken
        user.value = data.user
        status.value = 'authenticated'
        return true
      } catch {
        clearAuth()
        return false
      } finally {
        refreshPromise = null
      }
    })()

    return refreshPromise
  }

  async function fetchMe(): Promise<User> {
    const response = await fetch('/api/me', {
      headers: withAuthHeaders(),
      credentials: 'include',
    })

    if (!response.ok) {
      throw new Error(`获取用户信息失败:HTTP ${response.status}`)
    }

    const data = (await response.json()) as MeResponse
    user.value = data.user
    return data.user
  }

  async function logout() {
    try {
      await fetch('/api/auth/logout', {
        method: 'POST',
        credentials: 'include',
      })
    } finally {
      clearAuth()
    }
  }

  function clearAuth() {
    accessToken.value = null
    user.value = null
    status.value = 'anonymous'
  }

  function hasPermission(permission: Permission): boolean {
    return user.value?.permissions.includes(permission) ?? false
  }

  function withAuthHeaders(): HeadersInit {
    return accessToken.value
      ? { Authorization: `Bearer ${accessToken.value}` }
      : {}
  }

  return {
    accessToken,
    user,
    status,
    isAuthenticated,
    login,
    restore,
    refresh,
    fetchMe,
    logout,
    clearAuth,
    hasPermission,
    withAuthHeaders,
  }
})

1. 为什么要合并 restore() 请求

应用启动时可能同时发生多个需要登录的动作:

  • 路由守卫调用 restore()
  • 顶层布局加载用户信息;
  • 某个组件发起第一个业务请求。

如果每个调用都单独请求 /api/auth/refresh,就会产生并发刷新。对于采用刷新令牌轮换的后端,旧 Refresh Token 可能在第一次刷新后立即失效,第二次刷新会失败,最终把正常用户错误地退出。

restorePromiserefreshPromise 的作用是:

第一次调用:创建刷新 Promise
后续调用:复用同一个 Promise
刷新完成:清空 Promise,允许下一轮刷新

这不是为了减少几次网络请求,而是为了让同一时刻的认证状态转换只有一个执行者。

2. fetchMe() 是否必须单独调用

有两种常见接口设计:

登录和刷新直接返回用户信息

{
  "accessToken": "...",
  "user": {
    "id": "u_1",
    "permissions": ["project:read"]
  }
}

这样登录和刷新后可以立即建立完整状态。

登录和刷新只返回 Token

{
  "accessToken": "..."
}

这时还需要调用 /api/me

await auth.refresh()
await auth.fetchMe()

第二种设计可以让用户资料独立于 Token 返回,但会增加一次请求。无论采用哪种方式,都应明确:前端权限列表是缓存和界面依据,真正的授权仍由后端执行。

六、路由权限:导航前检查页面访问资格

路由权限解决的是:

用户能不能进入这个页面?

它不解决:

用户能不能执行页面中的每一种操作?

可以使用路由的 meta 字段声明页面所需权限。

// src/router/types.d.ts
import 'vue-router'
import type { Permission } from '@/stores/auth'

declare module 'vue-router' {
  interface RouteMeta {
    requiresAuth?: boolean
    permissions?: Permission[]
  }
}

配置路由:

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/LoginView.vue'),
    },
    {
      path: '/403',
      name: 'forbidden',
      component: () => import('@/views/ForbiddenView.vue'),
    },
    {
      path: '/projects',
      name: 'projects',
      component: () => import('@/views/ProjectsView.vue'),
      meta: {
        requiresAuth: true,
        permissions: ['project:read'],
      },
    },
    {
      path: '/admin/users',
      name: 'admin-users',
      component: () => import('@/views/AdminUsersView.vue'),
      meta: {
        requiresAuth: true,
        permissions: ['user:manage'],
      },
    },
  ],
})

router.beforeEach(async (to) => {
  const auth = useAuthStore()

  if (auth.status === 'unknown') {
    await auth.restore()
  }

  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return {
      name: 'login',
      query: {
        redirect: to.fullPath,
      },
    }
  }

  const requiredPermissions = to.meta.permissions ?? []
  const canAccess = requiredPermissions.every((permission) =>
    auth.hasPermission(permission),
  )

  if (requiredPermissions.length > 0 && !canAccess) {
    return { name: 'forbidden' }
  }

  if (to.name === 'login' && auth.isAuthenticated) {
    return { name: 'projects' }
  }

  return true
})

export default router

1. 守卫的执行顺序

访问 /admin/users 时,过程如下:

1. Router 匹配目标路由
2. beforeEach 开始执行
3. status 是 unknown,调用 restore()
4. refresh 成功,得到用户和权限
5. 判断 requiresAuth
6. 判断 user:manage
7. 权限足够,返回 true
8. 组件开始加载和渲染

如果刷新失败:

1. restore() 返回 false
2. auth.isAuthenticated 为 false
3. 返回 login 路由
4. 原始地址放入 query.redirect

登录成功后可以恢复原始地址:

// src/views/LoginView.vue
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const route = useRoute()
const router = useRouter()
const auth = useAuthStore()

async function submit(username: string, password: string) {
  await auth.login(username, password)

  const redirect =
    typeof route.query.redirect === 'string'
      ? route.query.redirect
      : '/projects'

  // 只允许站内路径,避免把 redirect 当作开放重定向入口
  const target = redirect.startsWith('/') ? redirect : '/projects'

  await router.replace(target)
}

redirect 不能无条件交给 window.locationrouter.push。如果服务端或外部输入可以构造:

/login?redirect=https://evil.example

登录后就可能发生开放重定向。上面的示例只接受以 / 开头的站内路径,但生产代码还应根据路由规则进一步限制协议相对 URL、双斜杠路径等边界情况。

2. 为什么路由守卫不能代替后端鉴权

用户可以:

  • 直接输入隐藏页面 URL;
  • 在 DevTools 中修改 Pinia 状态;
  • 删除前端权限判断;
  • 手工调用 API;
  • 修改前端 JavaScript。

因此,下面这段逻辑只能决定界面行为:

if (auth.hasPermission('project:delete')) {
  // 显示删除按钮
}

后端仍必须在 DELETE /api/projects/:id 中检查:

当前 Token 对应的用户是否可以删除这个具体项目

如果后端只依赖前端传来的:

{
  "role": "admin"
}

就属于安全漏洞。角色、权限和资源归属都必须由可信服务端数据决定。

七、按钮权限:控制可见性,不伪造安全边界

按钮权限解决的是:

用户能不能在当前界面看到或使用某个操作入口?

可以直接在模板中使用 Store:

<!-- src/views/ProjectsView.vue -->
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'

const auth = useAuthStore()

async function deleteProject(projectId: string) {
  if (!auth.hasPermission('project:delete')) {
    return
  }

  // 这里仍可能收到 403,必须处理
  // await api.delete(`/projects/${projectId}`)
}
</script>

<template>
  <section>
    <h1>项目</h1>

    <button
      v-if="auth.hasPermission('project:create')"
      type="button"
    >
      新建项目
    </button>

    <button
      v-if="auth.hasPermission('project:delete')"
      type="button"
      @click="deleteProject('project-1')"
    >
      删除项目
    </button>
  </section>
</template>

v-ifv-show 的差异在权限场景中有实际意义:

  • v-if 不创建没有权限的元素;
  • v-show 仍会创建元素,只是通过 CSS 隐藏;
  • 对敏感操作,通常不应仅依赖 v-show 隐藏;
  • 无论使用哪一种,浏览器中的元素都不能作为安全边界。

如果项目中按钮权限很多,可以封装成指令:

// src/directives/permission.ts
import type { Directive } from 'vue'
import type { Permission } from '@/stores/auth'
import { useAuthStore } from '@/stores/auth'

export const permission: Directive<HTMLElement, Permission | Permission[]> = {
  mounted(el, binding) {
    const auth = useAuthStore()
    const required = Array.isArray(binding.value)
      ? binding.value
      : [binding.value]

    const allowed = required.every((item) => auth.hasPermission(item))

    if (!allowed) {
      el.remove()
    }
  },
}

注册:

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { permission } from './directives/permission'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.directive('permission', permission)
app.mount('#app')

使用:

<button v-permission="'project:delete'">
  删除项目
</button>

这个简单指令有一个边界:它只在 mounted 时检查一次。如果用户权限在当前页面中发生变化,元素不会自动重新计算。更稳妥的方式是使用 v-if 配合响应式 Store,或者实现基于组件渲染更新的权限组件:

<script setup lang="ts">
import type { Permission } from '@/stores/auth'
import { useAuthStore } from '@/stores/auth'

const props = defineProps<{
  permission: Permission
}>()

const auth = useAuthStore()
</script>

<template>
  <slot v-if="auth.hasPermission(props.permission)" />
</template>

八、统一请求层:添加 Access Token,并处理 401

直接在每个组件里手写 fetch 会导致:

每个请求都重复拼接 Authorization
每个请求都重复处理 401
每个请求都可能实现出不同的登出逻辑

因此,业务请求应通过统一客户端发送。下面使用 Axios,先安装:

npm install axios

创建实例:

// src/lib/http.ts
import axios, {
  type AxiosError,
  type InternalAxiosRequestConfig,
} from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'

type RetryableConfig = InternalAxiosRequestConfig & {
  _authRetry?: boolean
}

export const http = axios.create({
  baseURL: '/api',
  withCredentials: true,
})

http.interceptors.request.use((config) => {
  const auth = useAuthStore()

  if (auth.accessToken) {
    config.headers.Authorization = `Bearer ${auth.accessToken}`
  }

  return config
})

let refreshPromise: Promise<boolean> | null = null

function refreshOnce(): Promise<boolean> {
  const auth = useAuthStore()

  if (!refreshPromise) {
    refreshPromise = auth.refresh().finally(() => {
      refreshPromise = null
    })
  }

  return refreshPromise
}

http.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const status = error.response?.status
    const original = error.config as RetryableConfig | undefined

    if (!original) {
      return Promise.reject(error)
    }

    const isRefreshRequest = original.url?.includes('/auth/refresh')

    if (
      status === 401 &&
      !original._authRetry &&
      !isRefreshRequest
    ) {
      original._authRetry = true

      const refreshed = await refreshOnce()

      if (refreshed) {
        return http(original)
      }

      const auth = useAuthStore()
      auth.clearAuth()

      if (router.currentRoute.value.name !== 'login') {
        await router.replace({
          name: 'login',
          query: {
            redirect: router.currentRoute.value.fullPath,
          },
        })
      }
    }

    if (status === 403) {
      // 可在这里上报权限拒绝,或交给调用方展示业务提示
      // 不要在这里刷新 Token
    }

    return Promise.reject(error)
  },
)

1. 为什么必须限制“只重试一次”

假设请求 A 收到 401

A 第一次失败
→ 刷新 Token
→ A 重试

如果重试后的 A 仍然返回 401,就不能再次进入刷新流程。否则可能发生:

A 401
→ refresh
→ A retry 401
→ refresh
→ A retry 401
→ 无限循环

_authRetry 就是这个请求级别的上限标记。它必须附加在原请求配置上,而不是使用一个全局布尔值,因为不同请求的重试状态彼此独立。

2. 为什么需要全局 refreshPromise

假设 ABC 几乎同时收到 401

A 401 ─┐
B 401 ─┼→ 共同等待一个 refreshPromise
C 401 ─┘
            ↓
       只发送一次 refresh
            ↓
      A、B、C 分别重试

如果没有这个 Promise 合并机制,三个请求都执行刷新:

refresh-1 成功并轮换刷新凭证
refresh-2 使用旧凭证失败
refresh-3 使用旧凭证失败

结果可能是正常用户被误判为未登录。

需要注意的是,示例同时在 Store 和 HTTP 层做了并发保护:

  • Store 的 refreshPromise 保护所有直接调用 auth.refresh() 的代码;
  • HTTP 层的 refreshPromise 保护拦截器的刷新入口;
  • 二者即使功能重叠,也能防止不同调用路径重复刷新。

实际项目中可以选择只在一个层统一实现,但必须保证所有刷新入口都经过同一把“锁”。

3. 不能刷新哪些请求

以下请求通常不应自动刷新后重试:

  • /auth/refresh 本身;
  • /auth/login
  • /auth/logout
  • 明确标记为不可重试的写请求;
  • 非幂等请求在无法确认服务端是否已执行时。

幂等表示同一个请求执行一次或多次,最终资源状态相同。GET 通常设计为幂等,POST 创建资源通常不是。

例如:

POST /orders

客户端已经发出请求,服务器可能创建成功,但响应在网络中丢失。客户端如果盲目重试,可能创建第二个订单。Token 刷新重试与业务请求重试是两个问题,不能因为可以刷新 Token,就认为所有请求都可以安全重放。

对重要写操作,后端可以支持幂等键:

Idempotency-Key: order-create-8e8c...

这样即使请求因认证恢复而重试,服务端也能识别同一业务操作。

九、正确处理 403:页面拒绝与动作拒绝

1. 页面级 403

路由守卫可以在进入页面前判断静态权限:

meta: {
  requiresAuth: true,
  permissions: ['user:manage'],
}

没有权限时进入 /403。这个流程适合:

用户明确访问一个存在的页面
但角色没有该页面所需权限

页面可以提供返回上一页或回到首页的操作:

<script setup lang="ts">
import { useRouter } from 'vue-router'

const router = useRouter()
</script>

<template>
  <main>
    <h1>403</h1>
    <p>你已登录,但没有访问此页面的权限。</p>
    <button type="button" @click="router.back()">返回</button>
  </main>
</template>

2. API 操作级 403

即使按钮已经根据权限隐藏,操作仍可能收到 403

  • 管理员撤销了用户权限;
  • 用户权限缓存尚未更新;
  • 资源属于另一个组织;
  • 项目状态不允许执行该动作;
  • 后端还检查了前端没有展示的上下文条件。

调用方应捕获这个错误:

import { http } from '@/lib/http'
import axios from 'axios'

async function removeProject(id: string) {
  try {
    await http.delete(`/projects/${encodeURIComponent(id)}`)
  } catch (error) {
    if (axios.isAxiosError(error) && error.response?.status === 403) {
      window.alert('当前账号没有删除该项目的权限')
      return
    }

    window.alert('删除失败,请稍后重试')
  }
}

不要把所有错误都转换成“登录已过期”。否则用户实际遇到的是权限不足,却被引导重复登录,既不能解决问题,也会掩盖后端授权缺陷。

十、状态恢复:浏览器刷新后如何知道用户仍然登录

浏览器刷新会重新创建:

  • Vue 应用;
  • Pinia Store;
  • 路由实例;
  • 所有内存中的 Access Token。

如果 Access Token 只保存在内存里,刷新后它会丢失。但只要 Refresh Token 仍在 HttpOnly Cookie 中,就可以通过启动时调用 /auth/refresh 恢复。

1. 在路由守卫中恢复

前面的 beforeEach 已经提供了一种恢复方式:

if (auth.status === 'unknown') {
  await auth.restore()
}

优点是:只有访问路由时才执行恢复。

缺点是:应用的其他初始化逻辑可能早于路由守卫启动,例如:

顶层布局先发起 /api/notifications
此时 accessToken 仍为空
请求先得到 401

2. 在应用挂载前恢复

也可以在 main.ts 中先恢复认证状态,再挂载应用:

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'

async function bootstrap() {
  const app = createApp(App)
  const pinia = createPinia()

  app.use(pinia)
  app.use(router)

  const auth = useAuthStore(pinia)
  await auth.restore()

  await router.isReady()
  app.mount('#app')
}

bootstrap().catch((error) => {
  console.error('应用启动失败', error)
})

这个写法的因果关系是:

先创建 Pinia
→ 从同一个 Pinia 容器获取 auth Store
→ 恢复认证状态
→ 等待路由准备完成
→ 挂载页面

useAuthStore(pinia) 显式传入 Pinia 实例,避免在应用尚未安装 Pinia 时调用 Store。

3. 恢复失败不一定是系统错误

/auth/refresh 返回 401403 时,可能表示:

  • 用户从未登录;
  • Refresh Token 已过期;
  • 用户主动退出;
  • 服务端撤销了会话;
  • Cookie 没有被发送;
  • 跨域配置错误。

对于未登录用户,恢复失败是正常的匿名结果,不一定需要显示错误弹窗。对于网络错误,则应区分:

认证凭证无效 → anonymous,进入登录页
网络暂时不可用 → 可以显示启动失败或重试

前面的简化 Store 将两者都归为匿名。生产代码可根据 response.status 与网络异常进一步细分,以免把“服务器暂时不可用”误判成“用户已退出”。

十一、权限恢复与用户信息更新

权限并非永远不变。管理员可能在用户已经打开页面后修改权限。

有三种常见策略:

1. 只在登录和刷新时获得权限

优点是请求少,逻辑简单。

缺点是权限变更不会立即反映到当前页面。直到下一次刷新 Token 或重新登录,前端才得到新权限。

2. 定期调用 /api/me

setInterval(async () => {
  const auth = useAuthStore()

  if (auth.isAuthenticated) {
    try {
      await auth.fetchMe()
    } catch (error) {
      console.error('刷新用户信息失败', error)
    }
  }
}, 5 * 60 * 1000)

这里的时间间隔只是示例,不是框架或协议规定的固定值。生产系统应根据权限变更时效、接口成本和业务风险决定。

3. 接收服务端事件后重新获取

如果系统已经有 WebSocket 或 SSE,可以在服务端通知“权限已变更”后调用 fetchMe()。但通知本身不应直接成为可信权限数据,前端仍应从可信 API 获取新的权限集合。

无论采用哪种策略,正在执行的 API 请求仍可能在权限变更的瞬间收到 403。因此,按钮隐藏和服务端 403 处理必须同时存在。

十二、完整数据流与故障路径

一个正常的页面访问流程如下:

flowchart TD
    A[浏览器加载应用] --> B[创建 Pinia 和 Router]
    B --> C[auth.status = unknown]
    C --> D[调用 /auth/refresh]
    D -->|成功| E[保存 accessToken 和 user]
    D -->|无有效刷新凭证| F[status = anonymous]
    E --> G[访问受保护路由]
    F --> H[跳转 /login]
    G --> I{路由权限足够?}
    I -->|否| J[跳转 /403]
    I -->|是| K[加载页面]
    K --> L[调用业务 API]
    L -->|200| M[显示数据]
    L -->|401| N[并发合并 refresh]
    L -->|403| O[显示无权限]
    N -->|成功| P[原请求最多重试一次]
    N -->|失败| Q[清除状态并跳转 /login]

关键路径的区别是:

路由守卫判断“是否允许开始导航”
HTTP 层处理“请求发送后服务器如何回应”
按钮权限控制“用户是否看到操作入口”
后端授权决定“操作最终是否真的允许”

它们处于不同层次,不能互相替代。

十三、常见错误实现及其失败表现

错误一:只在前端判断权限

if (user.role === 'admin') {
  await http.delete('/projects/1')
}

失败原因是攻击者可以跳过这段代码,直接调用接口。正确做法是:

前端隐藏或禁用入口
后端验证 Token、权限、资源归属和业务状态

错误二:所有 401 都直接跳转登录

失败表现:

Access Token 短期过期
→ 业务请求返回 401
→ 用户突然回到登录页

如果 Refresh Token 仍有效,这会造成不必要的登录中断。应先刷新,再决定是否退出。

错误三:收到 403 就刷新 Token

失败原因是:

认证凭证有效
权限本身不足

刷新 Token 不会自动获得 project:delete 权限。这样做只会增加请求,还可能掩盖真正的授权错误。

错误四:多个请求分别刷新

失败表现取决于后端实现:

  • 服务器允许并行刷新:产生多余请求;
  • 服务器轮换 Refresh Token:部分刷新失败;
  • 前端后写入旧 Token:后续请求使用错误令牌。

应使用共享 Promise 或队列,确保同一时间只有一个刷新流程。

错误五:刷新请求也被拦截器重试

如果 /auth/refresh 返回 401,拦截器再次调用 /auth/refresh,就会形成递归。必须显式排除刷新接口。

错误六:原请求没有重试上限

没有 _authRetry 之类的标记时,可能出现无限循环。重试机制必须有明确上限,且失败后要把错误交给调用方或统一错误页。

错误七:把完整用户对象永久写入 localStorage

风险包括:

  • XSS 读取用户资料;
  • 旧权限长期残留;
  • 多标签页状态不一致;
  • 用户退出后残留数据;
  • 结构升级导致解析失败。

如果必须持久化,应只保存必要、非敏感、可重新验证的数据,并处理版本、过期和清理。更安全的方案是保存 HttpOnly Refresh Cookie,让应用重新从服务端恢复状态。

十四、诊断问题时应观察哪些证据

1. 页面刷新后总是回到登录页

按顺序检查:

1. 浏览器是否保存了 Refresh Cookie
2. Cookie 的 Domain、Path、Secure、SameSite 是否正确
3. refresh 请求是否设置 credentials: include
4. 跨域时服务端是否允许凭证
5. refresh 请求是否真的返回 200
6. 应用是否在挂载前调用 restore
7. restore 失败后是否错误地清除了有效状态

在 Network 面板中重点查看:

POST /api/auth/refresh
Cookie: ...

如果请求没有 Cookie,问题通常不在 Pinia,而在 Cookie 或跨域配置。

2. 业务接口循环 401

检查:

1. 请求拦截器是否使用了最新 accessToken
2. refresh 成功后是否更新 Store
3. 原请求是否使用了 _authRetry
4. refresh 接口是否被排除
5. 重试时是否复用了旧 Authorization 头

在 Axios 中,重新执行 http(original) 时,请求拦截器会再次运行并读取新的 Store Token。如果项目另行手动写死了旧的 Authorization,就可能覆盖新 Token。

3. 明明有权限却收到 403

需要分别验证:

1. 前端 Store 中权限是否是最新的
2. 请求中携带的 Token 是否属于预期用户
3. 服务端是否按角色、组织、资源归属继续判断
4. 目标资源是否处于允许操作的状态
5. 是否存在权限缓存延迟

前端显示了 project:delete,只能证明前端缓存里有这个字符串,不代表当前 Token 的服务端权限和具体资源条件一定满足。

4. 退出登录后仍能访问接口

这通常说明:

  • 服务端没有真正撤销或失效 Refresh 会话;
  • 前端只清了 Store,没有清 Cookie;
  • 其他标签页仍持有旧 Access Token;
  • 服务端错误地长期接受已撤销 Token。

如果 Access Token 是自包含 JWT,服务端通常无法在不查撤销列表的情况下立即让已签发 Token 失效。产品需要在“短期令牌性能”和“即时撤销能力”之间取舍。

十五、多标签页和状态同步

如果 Access Token 只在内存中保存,多个标签页会各自拥有一份状态:

标签页 A 登录
标签页 B 不一定立即知道
标签页 A 退出
标签页 B 仍可能认为已登录

可以使用 BroadcastChannel 同步非敏感状态变化:

// src/lib/auth-channel.ts
const channel = new BroadcastChannel('auth-events')

export function notifyLogout() {
  channel.postMessage({ type: 'logout' })
}

export function onAuthEvent(handler: (type: string) => void) {
  channel.addEventListener('message', (event) => {
    if (event.data?.type) {
      handler(event.data.type)
    }
  })
}

在 Store 中:

import { onAuthEvent } from '@/lib/auth-channel'

onAuthEvent((type) => {
  if (type === 'logout') {
    clearAuth()
  }
})

如果目标浏览器或项目环境不适合 BroadcastChannel,也可以监听 storage 事件。但不应把 Access Token 放进 localStorage 只是为了实现同步;可以仅使用一个不敏感的事件标记。

此外,多个标签页可能同时触发刷新。若 Refresh Token 轮换严格要求跨标签页互斥,仅靠每个标签页内部的 Promise 不够,还需要跨标签页协调,例如 Web Locks API 或服务端允许并发刷新。Web Locks API 的浏览器兼容性需要根据项目支持范围单独验证,不能假定所有运行环境都支持。

十六、测试这些流程,而不是只测试正常登录

至少应覆盖以下场景:

登录和路由

匿名访问受保护路由 → 跳转 /login
登录成功 → 恢复原始站内地址
已登录访问 /login → 跳转业务首页
已登录但无页面权限 → 跳转 /403

Token

Access Token 有效 → 业务请求成功
Access Token 过期 → refresh 成功 → 原请求成功
Access Token 过期 → refresh 失败 → 清除状态并跳转登录
多个请求同时 401 → 只发送一次 refresh
重试后的请求再次 401 → 不无限刷新
refresh 接口返回 401 → 不递归重试 refresh

授权

没有删除权限 → 删除按钮不显示
按钮显示但服务端权限已撤销 → 收到 403 并显示无权操作
有页面权限但无资源权限 → 业务接口仍正确拒绝

状态恢复

浏览器刷新且 Refresh Cookie 有效 → 恢复登录
浏览器刷新且 Refresh Cookie 过期 → 显示登录页
网络暂时失败 → 不把所有情况错误地伪装成权限不足

测试时应查看请求数量和顺序,而不只看最终页面。并发刷新问题往往页面表面仍然“偶尔能用”,只有在 Network 日志中才能发现同一时间发送了多次 /auth/refresh

十七、规范保证、实现选择与工程建议

需要把三类内容分开:

HTTP 和框架的规范能力

  • 401403 是 HTTP 状态码;
  • Vue Router 提供导航守卫和路由元信息;
  • Pinia 提供响应式 Store;
  • 浏览器 Cookie 的 HttpOnlySecureSameSite 有明确语义。

这些是平台或框架提供的能力,但具体认证流程仍由应用和后端契约决定。

常见实现

  • Access Token 放内存;
  • Refresh Token 放 HttpOnly Cookie;
  • 通过共享 Promise 合并并发刷新;
  • 使用路由 meta 声明页面权限;
  • 使用 Store 或指令控制按钮。

这些方案常见且实用,但并非唯一正确方案。

需要根据系统取舍的部分

  • Access Token 是否持久化;
  • Refresh Token 是否轮换;
  • 退出时是否立即撤销服务端会话;
  • 权限是角色模型、权限字符串模型,还是资源策略模型;
  • 多标签页是否需要即时同步;
  • 是否允许自动重试非幂等请求;
  • 权限变化需要多快反映到页面。

只要始终遵循这条边界,系统就不容易混乱:

路由控制导航体验
按钮控制界面入口
Token 刷新维持认证连续性
401 表示认证需要恢复或结束
403 表示认证存在但授权不足
Pinia 保存和驱动前端状态
后端授权决定请求最终是否被允许

这几个机制共同组成登录与权限系统,但它们不能互相冒充。真正可靠的实现,是让每一层只负责自己的判断,同时让状态恢复、并发刷新和失败路径具有明确的终点。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。