08|权限与敏感状态
第五章概览 / 08。目标:前端状态改善体验,服务端决定授权;任何到达浏览器的值都不能再被当作秘密。
两道边界承担不同责任
前端 canEdit ──> 隐藏/禁用按钮、减少误操作
服务端 authorize ──> 对每个请求核验身份、资源与动作2
用户可以修改 Pinia state、直接调用接口、伪造 URL,也能绕过前端路由守卫。因此 v-if="canEdit" 不是安全控制。真正的权限判断必须在服务端再次执行,并按当前资源和动作决定结果。
auth store 只保存驱动界面的最小会话视图
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 }
})2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
初始状态用 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,不让每个消费者互相取消;第六章守卫实验会把“取消导航”和“结束外部恢复任务”分开验证。
组件只做体验层判断
<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>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
即使按钮可见,服务端仍可能因权限刚被撤销返回 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,并把登出/切换账号的取消与清理设计成一个完整流程。