前端鉴权方案设计与实现
凭证放哪、双 token 怎么无感刷新、权限怎么落到路由 / 菜单 / 按钮 / 接口四层,以及动态路由那几个必踩的坑。结论先说:前端鉴权做的是体验,服务端才是边界。
「鉴权」在需求文档里通常只有一行:不同角色看到不同的菜单。真做起来会发现它被切成四段落在完全不同的地方——路由、菜单、按钮、接口,而且每一段出问题的样子都不一样:有的是白屏,有的是刷新后掉 404,有的是明明藏了按钮却还能把数据删掉。
这篇把一套中后台的鉴权从头理一遍:先分清认证和授权,再看凭证怎么存、token 怎么刷,最后是四层权限的落地代码和实测会踩的坑。示例用 Vue 3 + Vue Router 4 + Pinia,末尾给一张 React 的对应关系表。
一、先把两件事分开
| 认证 Authentication | 授权 Authorization | |
|---|---|---|
| 回答什么 | 你是谁 | 你能干什么 |
| 发生时机 | 一次(登录) | 每一次请求、每一次渲染 |
| 前端拿到什么 | 一个凭证 | 一份权限码集合 |
| 失败状态码 | 401 Unauthorized | 403 Forbidden |
| 失败该怎么办 | 跳登录页 | 给提示,不要跳登录页 |
最后一行是最常写错的地方。403 的意思是「你是谁我知道,但这件事你不能干」,把人踢回登录页只会让他重登一次、再撞一次同样的 403。
注意
前端鉴权的全部产出是体验,不是安全。菜单、按钮、路由表全在浏览器里,打开控制台就能改。真正拦得住的只有服务端对每个接口的校验,前端做的事情只是「别让用户看见他做不了的操作」。
二、登录认证
2.1 凭证放在哪
四个位置,各有各的死法:
| 放法 | XSS 能读走 | 会被 CSRF 利用 | 跨站自动携带 | 刷新页面 | 多标签共享 |
|---|---|---|---|---|---|
| HttpOnly Cookie | 否 | 是,要靠 SameSite 或 CSRF token 挡 | 配 SameSite=None; Secure 后可以 | 在 | 是 |
| localStorage | 是 | 否 | 手动加请求头 | 在 | 是 |
| sessionStorage | 是 | 否 | 手动加请求头 | 在 | 否 |
| 内存(Pinia / 闭包) | 基本不会 | 否 | 手动加请求头 | 丢 | 否 |
现在比较稳的组合是把两种凭证拆开:
1、access token 短命(15 分钟上下),放内存或 localStorage,由前端手动塞进 Authorization 头。
2、refresh token 长命(数天),放 HttpOnly + Secure + SameSite=Strict 的 Cookie,并把 Path 限死在刷新接口上——它只在刷新那一次请求里出现,业务代码全程碰不到它。
备注
HttpOnly 挡住的是「把凭证偷走」,挡不住「借着浏览器用凭证」。页面一旦被 XSS,攻击者照样能在你的页面里发一个自动带 Cookie 的请求。它降低的是损失,不是免疫。
2.2 无感刷新与并发风暴
access token 过期时,正确的表现是用户什么都感觉不到:请求 401 → 悄悄刷新 → 原样重发。
麻烦在于「悄悄」这一步会被并发放大。首屏同时发八个请求,token 恰好这时过期,八个 401 各自去刷一次——服务端如果是「刷一次就旋转 refresh token」的实现,后面七个拿着已作废的 refresh 全部失败,用户当场被踢出去。
修法是让刷新单飞,所有 401 复用同一个 promise:
let refreshing = null // 全局只允许一条刷新在跑
function ensureRefreshed() {
refreshing ??= refreshToken().finally(() => { refreshing = null })
return refreshing
}
http.interceptors.response.use(null, async (error) => {
const { response, config } = error
if (response?.status !== 401 || config._retried) throw error
config._retried = true // 一个请求只重放一次,否则 401 会打成死循环
const token = await ensureRefreshed() // 后到的请求排在同一条刷新后面
config.headers.Authorization = `Bearer ${token}`
return http(config) // 拿新 token 原样重发,业务层无感
})两处细节值得单独说:
1、config._retried 不能省。重放回来仍然 401 时(比如账号刚被禁用),没有这个标记就是无限重放。
2、刷新接口自己必须跳过这个拦截器。refresh 过期返回的也是 401,让它走进这段代码就成了自己刷自己。给它单独一个 axios 实例,或者在 config 上打个 skipAuth 标记。
2.3 登出
登出是三件事,少一件都会留尾巴:
1、通知服务端吊销 refresh token。只清本地的话凭证在服务端仍然有效,等于没登出。
2、清干净前端状态:store、缓存,以及已经注册进 router 的动态路由。页面不刷新时 router 是同一个实例,上一个账号的路由还躺在里面。
3、跳登录页时带上 redirect=当前路径,登录后回到原处。
多标签页同步用 storage 事件最省事,它只在其他标签页触发,正好是想要的语义:
window.addEventListener('storage', (e) => {
if (e.key === 'token' && !e.newValue) location.reload() // 别的标签页登出了
})三、权限的四层落点
| 层 | 拦的是 | 实现位置 | 被绕过会怎样 |
|---|---|---|---|
| 路由 | 页面进不进得去 | 全局前置守卫 | 手输 URL 就进去了 |
| 菜单 | 入口看不看得见 | 菜单数据过滤 | 猜到路径照样进 |
| 按钮 | 操作点不点得到 | v-if 或自定义指令 | 控制台直接调方法 |
| 接口 | 数据给不给 | 服务端 | 没有「被绕过」,它就是底线 |
前三层的价值不在拦截,在于「不让用户看见做不了的事」——一个点下去才报「无权限」的按钮,比根本不显示更糟。
四、这份权限表由谁定
三种做法,实际项目里第三种最常见:
| 后端下发菜单树 | 前端配表 + 角色过滤 | 前端配表 + 权限码过滤 | |
|---|---|---|---|
| 前端拿到什么 | 完整的路由 / 菜单 JSON | 角色名 | 权限码集合 |
| 权限调整 | 改数据库即时生效 | 要发版 | 新增页面才发版 |
| 前端复杂度 | 要做「字符串到组件」的映射 | 低 | 低 |
| 后端复杂度 | 要维护一张菜单表 | 只返角色 | 只返权限码 |
| 适合 | 菜单可配置、多租户 | 角色写死的内部系统 | 大多数中后台 |
不管选哪种,前端都不该写 role === 'admin' 这样的判断。角色是会变的(今天加一个「审计员」,明天把导出权限从主管挪给专员),而权限码 order:export 稳定。RBAC 的链路是「用户 → 角色 → 权限码」,前端只吃最后一段,角色到权限码的映射留在服务端。
五、实现
5.1 一份真源
菜单和路由是同一棵树的两种投影,分开维护必然漂移——加了页面忘了加菜单,或者删了菜单路由还通。所以定一条规矩:路由表是唯一真源,菜单从它推导。
路由的 meta 上挂这几个字段就够用了:
| 字段 | 干什么 |
|---|---|
title | 菜单文字、面包屑、标签页标题共用一份 |
icon | 菜单图标 |
code | 进这个页面需要的权限码,不填表示登录即可 |
hidden | 有路由但不进菜单,详情页、编辑页这类 |
keepAlive | 是否缓存组件实例 |
5.2 路由守卫
const WHITE_LIST = ['/login', '/404']
router.beforeEach(async (to) => {
if (WHITE_LIST.includes(to.path)) return true // 白名单最先返回,否则自己拦自己
const user = useUserStore()
if (!user.token) return { path: '/login', query: { redirect: to.fullPath } }
if (user.routesReady) return true
// 有 token 但还没拉过用户信息:刷新页面后一定走到这里
try {
await user.fetchProfile() // 权限码只认服务端这一次返回
filterRoutes(asyncRoutes, user.codes).forEach((r) => {
user.removers.push(router.addRoute(r)) // addRoute 的返回值是移除函数,登出要用
})
user.routesReady = true
return { ...to, replace: true } // 关键:在新路由表上重新匹配一次
} catch {
await user.logout()
return { path: '/login', query: { redirect: to.fullPath } }
}
})return { ...to, replace: true } 是整段里最容易漏的一行。这次导航是在 addRoute 之前匹配完的,此刻的 to 指向的还是那条当时不存在的记录,直接放行就是白屏。重新发起一次同地址的导航让它重新匹配,replace 是为了不在历史记录里留一条多余的。
通配 404 那条同理。Vue Router 3 按注册顺序匹配,通配路由必须最后注册;Vue Router 4 改成按路径打分排序,通配的分最低、抢不走具体路径,注册顺序不再是问题——但首次进入时它仍然会先命中一次,因为那时动态路由还没加进去。所以别在守卫里拿「当前匹配到了 404」当判断依据,依据永远是 routesReady。
5.3 字符串怎么变成组件
后端下发菜单树时 component 是一个字符串。Vite 下不能 require,更不要 eval:
const modules = import.meta.glob('/src/views/**/*.vue') // 值是返回 Promise 的函数,天然懒加载
function toRoute(node) {
const loader = modules[`/src/views/${node.component}.vue`]
if (!loader) console.warn('[route] 找不到组件:', node.component) // 拼不到是 undefined,白屏且不报错
return {
path: node.path,
name: node.name,
component: node.component === 'Layout' ? Layout : (loader ?? NotFound),
meta: { title: node.title, icon: node.icon, code: node.code },
children: node.children?.map(toRoute),
}
}import.meta.glob 的键是构建时静态分析出来的,所以路径里不能带变量。写成 import(node.component) 打包器不知道该把哪些文件打进产物,开发环境可能碰巧能跑,上线就是 404。
5.4 菜单从路由推导
function toMenu(routes, base = '') {
return routes
.filter((r) => !r.meta?.hidden && hasPerm(r.meta?.code))
.map((r) => ({
path: join(base, r.path),
title: r.meta.title,
icon: r.meta.icon,
children: r.children ? toMenu(r.children, join(base, r.path)) : undefined,
}))
.filter((m) => !m.children || m.children.length > 0) // 子项被过滤空了,父级也别显示
}最后那行是实测补上的:一个分组下面五个页面全都无权限时,不加它页面上会剩一个点开是空的父菜单。
5.5 按钮权限
判断只留一个入口,别在组件里各写各的:
export function hasPerm(code) {
if (!code) return true // 没标 code = 登录即可
const { codes } = useUserStore()
return codes.has('*') || codes.has(code)
}模板里有两种写法,区别比看上去大:
v-if="hasPerm('user:delete')" | 指令 v-perm="'user:delete'" | |
|---|---|---|
| 权限运行时变化 | 跟着变 | 不跟,指令只在 mounted 判一次 |
| 无权限时的组件 | 根本不创建 | 先创建,再把 DOM 摘掉 |
| 模板噪音 | 每处一个函数调用 | 一个属性 |
第二行是指令版真正的坑:el.remove() 摘掉的只是 DOM 节点,组件已经创建过了,它 setup 里发的请求照发。所以指令只适合套在纯展示元素上(按钮、链接),套在会自己拉数据的组件上就是「藏了但还在跑」。
app.directive('perm', {
mounted(el, binding) {
if (!hasPerm(binding.value)) el.remove()
},
})5.6 接口层
请求拦截注入凭证,响应拦截按状态码分流:
| 状态码 | 处置 |
|---|---|
| 401 | 刷新 token 后重放,刷不动就登出 |
| 403 | 提示「没有权限」,停在当前页 |
| 其他 4xx | 交给业务层自己处理,不要全局弹窗 |
六、症状对照表
真正会遇到的问题基本都在这张表里:
| 症状 | 成因 | 修法 |
|---|---|---|
| 刷新页面后白屏或掉 404 | 动态路由在内存里,刷新后要重新 addRoute,而这次导航已经匹配完了 | 守卫里 return { ...to, replace: true } |
| 登录页反复重定向 | 守卫对 /login 也做了鉴权判断 | 白名单最先返回 |
| 换账号后菜单还是上一个人的 | 登出没有移除动态路由 | 存下 addRoute 的返回值,登出时逐个调用 |
| 一次过期打出十几个刷新请求 | 每个 401 各刷各的 | 刷新单飞,复用同一个 promise |
| 401 无限重放 | 重放的请求没打标记 | config._retried |
| 按钮藏了,接口照样能调 | 只有前端在判断 | 服务端逐接口校验 |
| 菜单和路由对不上 | 两份数据各写各的 | 菜单由路由表推导 |
| 分组菜单点开是空的 | 子项全被过滤,父级还留着 | 过滤后再删一遍空父级 |
七、React 的对应写法
机制一一对得上,只是换了个形状:
| Vue | React Router |
|---|---|
| 全局前置守卫 | 包一层 <RequireAuth>,或在 loader 里 redirect() |
router.addRoute | useRoutes(filtered),路由表本来就是数据 |
v-perm 指令 | <Can code="user:delete"> 包装组件,或 usePerm() |
| Pinia store | Context + reducer,或任意状态库 |
React Router 6.4 起的 loader 反而更接近「守卫」的原意:数据和权限校验都在渲染之前完成,不会出现「组件先挂载、再发现没权限」的中间态。
八、上线前自查
- 服务端对每一个接口都做了权限校验,不依赖前端隐藏
- 401 和 403 的处置分开,403 不跳登录页
- 刷新任意深层页面都能正常进入
- 并发请求同时过期只触发一次刷新
- 登出后动态路由被移除,换账号登录菜单正确
- 登录页在白名单里,不会自己拦自己
- refresh token 走 HttpOnly Cookie,业务代码读不到
- 权限码集合来自服务端,前端没有
role === 'admin'这类判断
评论
评论加载中……