Skip to content

08|权限与敏感状态 ​

第五章概览 / 08。目标:前端状态改善体验,服务端决定授权;任何到达浏览器的值都不能再被当作秘密。

两道边界承担不同责任 ​

text
前端 canEdit ──> 隐藏/禁用按钮、减少误操作
服务端 authorize ──> 对每个请求核验身份、资源与动作

用户可以修改 Pinia state、直接调用接口、伪造 URL,也能绕过前端路由守卫。因此 v-if="canEdit" 不是安全控制。真正的权限判断必须在服务端再次执行,并按当前资源和动作决定结果。

auth store 只保存驱动界面的最小会话视图 ​

ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

interface SessionUser {
  id: string
  displayName: string
  permissions: string[]
}

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

  const isAuthenticated = computed(() => status.value === 'authenticated')
  const can = (permission: string) => user.value?.permissions.includes(permission) ?? false

  let generation = 0
  let controller: AbortController | undefined

  async function restore() {
    const run = ++generation
    controller?.abort()
    const current = new AbortController()
    controller = current
    user.value = null
    status.value = 'unknown'
    const isCurrent = () => run === generation && !current.signal.aborted
    try {
      const response = await fetch('/api/session', {
        credentials: 'include', signal: current.signal,
      })
      if (!isCurrent()) return false
      if (response.status === 401) {
        clearSession()
        return false
      }
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      const restored = await response.json() as SessionUser
      if (!isCurrent()) return false
      user.value = restored
      status.value = 'authenticated'
      return true
    } catch (cause) {
      if (!isCurrent()) return false
      // 网络失败仍为 unknown,由调用方显示重试,不能伪装成已登出。
      throw cause
    } finally {
      if (run === generation) controller = undefined
    }
  }

  function clearSession() {
    ++generation
    controller?.abort()
    controller = undefined
    user.value = null
    status.value = 'anonymous'
  }

  return { user, status, isAuthenticated, can, restore, clearSession }
})

初始状态用 unknown,否则首屏会把“尚未恢复”误判成“已登出”。restore() 从服务端 session 端点重建最小用户视图。若会话使用 HttpOnly cookie,前端无需也无法读取凭证;服务端还要设置恰当的 Secure、SameSite,并根据认证方案处理 CSRF。

恢复请求也属于会话生命周期:清会话先递增轮次,再取消请求。即便请求客户端忽略 signal,晚到的成功、401 或异常都失去写入资格;JSON 解析本身也是异步边界,因此解析后再检查一次。验证时暂停恢复,在 clearSession() 后才返回旧用户,结果必须仍是 anonymous,can() 必须为 false;反过来让新恢复先成功,旧恢复再返回401,也不能清掉新用户。

沿着会话状态推演一次断网 ​

首次进入页面时是 unknown,表示尚未获得认证事实,界面可以显示恢复中。服务端返回用户后才成为 authenticated;服务端明确返回 401 时进入 anonymous。若中途断网,前端只知道“没有拿到答案”,所以仍为 unknown 并把异常交给调用方。把网络失败也改成 anonymous,会产生不必要的登录跳转,还可能掩盖整个认证服务不可用。

恢复中的两次请求也要分别判断:A开始(轮次1)→B开始(轮次2,取消A)→B返回新会话→A晚到401。A的401只描述旧尝试,不具有清除B会话的资格;因此状态码分支之前也要判断轮次,而不只是成功写user前判断。另一条路径是A开始→主动登出(轮次递增、anonymous)→A成功,结果仍为anonymous。这个状态图解释了为何 Abort、状态码和会话轮次各自承担不同责任。

当前 restore() 每调用一次都会启动新轮次,适合显式“重试并取代”。若多个路由守卫或组件同时希望等待同一次恢复,应由应用级协调者共享正在运行的Promise,不让每个消费者互相取消;第六章守卫实验会把“取消导航”和“结束外部恢复任务”分开验证。

组件只做体验层判断 ​

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

const auth = useAuthStore()

async function deletePost(id: string) {
  const response = await fetch(`/api/posts/${encodeURIComponent(id)}`, {
    method: 'DELETE',
    credentials: 'include',
  })
  if (response.status === 401) auth.clearSession()
  if (!response.ok) throw new Error(`删除失败:HTTP ${response.status}`)
}
</script>

<template>
  <button v-if="auth.can('post:delete')" @click="deletePost('42')">删除</button>
</template>

即使按钮可见,服务端仍可能因权限刚被撤销返回 403;前端要展示结果并刷新权限。即使按钮不可见,也不能假定接口安全。

哪些值不要进入 store 或持久化 ​

  • 密码、支付信息、长期密钥、第三方私密 API key。
  • 本可由 HttpOnly cookie 持有的 session token。
  • 完整服务端错误、响应头或个人资料快照,只为显示一句文案却长期保存。
  • 权限之外不需要的用户字段。

构建时注入前端包的变量最终会被用户下载,不是秘密。localStorage 对同源脚本可读,XSS 会扩大风险;Pinia DevTools、日志、错误上报和持久化插件也都可能复制 state。

401、403、登出与切换账号 ​

事件客户端动作服务端兜底
401 未认证/会话过期清 auth、取消用户请求、跳登录拒绝请求并使 session 无效
403 无权限保留身份,隐藏/禁用动作,刷新权限按资源与动作拒绝
主动登出即使网络失败也清本地敏感状态撤销 session/token,支持重试
切换账号清上一用户缓存和草稿后再恢复每次请求按新身份授权

清理顺序要避免旧请求回写:先阻止新受保护操作,取消 in-flight 请求,清用户范围缓存与持久化,再进入匿名状态。服务端登出失败时本地仍应清理,并记录“需要重试撤销”,不能为了重试把旧 token 重新展示给 UI。

SSR 隔离 ​

每个 SSR 请求创建独立 Pinia 和请求客户端。若把用户 A 的 auth store 放在模块单例,后来的用户 B 可能收到 A 的预渲染数据。注入 HTML 的状态必须最小化并安全转义;hydrate 后仍要允许服务端用 401/403 推翻旧权限快照。

练习:安全的删除流程 ​

实现 deleteDocument(id) 与最小 auth store。契约:UI 只有 document:delete 时显示按钮;接口仍总是请求服务端授权;401 清会话与用户缓存,403 保留登录状态并返回“无权限”,500 返回可重试错误;任何分支都不能把 cookie、token 或原始响应写进日志/storage。

变化题:管理员权限在页面打开后被撤销。说明下一次点击和后台权限刷新如何收敛,且不能依赖刷新页面。

参考答案:第 08 题。

本页结论 ​

前端权限状态是可变、可伪造的 UI 提示。最小化数据、让服务端逐请求授权、区分 401/403,并把登出/切换账号的取消与清理设计成一个完整流程。

用心记录每一步成长