前端鉴权方案设计与实现

凭证放哪、双 token 怎么无感刷新、权限怎么落到路由 / 菜单 / 按钮 / 接口四层,以及动态路由那几个必踩的坑。结论先说:前端鉴权做的是体验,服务端才是边界。

「鉴权」在需求文档里通常只有一行:不同角色看到不同的菜单。真做起来会发现它被切成四段落在完全不同的地方——路由、菜单、按钮、接口,而且每一段出问题的样子都不一样:有的是白屏,有的是刷新后掉 404,有的是明明藏了按钮却还能把数据删掉。

这篇把一套中后台的鉴权从头理一遍:先分清认证和授权,再看凭证怎么存、token 怎么刷,最后是四层权限的落地代码和实测会踩的坑。示例用 Vue 3 + Vue Router 4 + Pinia,末尾给一张 React 的对应关系表。

一、先把两件事分开

认证 Authentication授权 Authorization
回答什么你是谁你能干什么
发生时机一次(登录)每一次请求、每一次渲染
前端拿到什么一个凭证一份权限码集合
失败状态码401 Unauthorized403 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:

js
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 事件最省事,它只在其他标签页触发,正好是想要的语义:

js
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 路由守卫

js
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

js
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 菜单从路由推导

js
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 按钮权限

判断只留一个入口,别在组件里各写各的:

js
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 里发的请求照发。所以指令只适合套在纯展示元素上(按钮、链接),套在会自己拉数据的组件上就是「藏了但还在跑」。

js
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 的对应写法

机制一一对得上,只是换了个形状:

VueReact Router
全局前置守卫包一层 <RequireAuth>,或在 loader 里 redirect()
router.addRouteuseRoutes(filtered),路由表本来就是数据
v-perm 指令<Can code="user:delete"> 包装组件,或 usePerm()
Pinia storeContext + reducer,或任意状态库

React Router 6.4 起的 loader 反而更接近「守卫」的原意:数据和权限校验都在渲染之前完成,不会出现「组件先挂载、再发现没权限」的中间态。

八、上线前自查

  • 服务端对每一个接口都做了权限校验,不依赖前端隐藏
  • 401 和 403 的处置分开,403 不跳登录页
  • 刷新任意深层页面都能正常进入
  • 并发请求同时过期只触发一次刷新
  • 登出后动态路由被移除,换账号登录菜单正确
  • 登录页在白名单里,不会自己拦自己
  • refresh token 走 HttpOnly Cookie,业务代码读不到
  • 权限码集合来自服务端,前端没有 role === 'admin' 这类判断

评论

评论加载中……

登录后再评论

注册要用邮箱收个验证码,只为确认邮箱能收信,不会拿去做别的。账号设置