前端权限路由和菜单:前端控制显示,后端控制安全

前端能不能拦住一个没有权限的用户?答案是拦不住,也不该由前端来拦。前端只能决定"要不要把这个入口显示出来",真正决定"能不能操作"的永远是后端接口。这个职责划分看着简单,但团队里几乎每隔一段时间就会有人在某个细节上把它踩歪——要么把角色判断写死在组件里,要么以为隐藏了删除按钮就等于做完了权限控制,要么在动态路由这一步栽跟头。

这篇把最近处理后台系统权限路由时踩到的几个点理一遍,顺带记一下判断标准:哪些环节前端接得住,哪些环节必须靠接口兜住,具体到每个场景该怎么分工。

权限数据从后端来,前端不判断角色

后端接口返回的权限数据,团队里现在统一是这个结构:

1{
2  "menus": ["dashboard", "order", "user"],
3  "permissions": [
4    "order:view",
5    "order:edit",
6    "user:disable"
7  ]
8}

menus 决定哪些一级/二级菜单能显示,permissions 是更细的权限点,按钮级的控制靠这个字段。前端把这份数据拉回来之后生成菜单、动态路由和按钮显示状态,仅此而已,不应该在这份数据之上再自己发明一套判断逻辑。

最容易犯的错误是把角色判断直接写进组件:

1if (user.role === 'admin') {
2  showDelete = true
3}

这种写法在项目初期看不出问题,但角色和权限的对应关系是会变的。上个月给系统新增"运营主管"这个角色,要求能编辑订单但不能删除——真去改的时候才发现,前端到处写的是 role === 'admin',这种角色粒度的调整就得前端跟着发版,而且改的地方还不止一处。判断具体权限点就不会有这个问题:

1can('order:delete')

can 内部去查 permissions 数组里有没有对应的权限码,角色包含哪些权限完全是后端的事,后端调整了角色配置,前端代码一行都不用动。这也是我们这半年在做的一件事——把散落在各个组件里的 user.role === xxx 慢慢换成权限点判断,工作量不大,但换完之后运营那边调整角色权限,前端就再也不用因为这种事发版了。

动态路由:addRoute 加进去了,但守卫时机不对照样白搭

Vue Router 4 里加动态路由用 router.addRoute,写法很直接:

1async function setupPermissionRoutes() {
2  const permissions = await fetchPermissions()
3  const routes = buildRoutesByPermissions(permissions)
4
5  routes.forEach((route) => {
6    router.addRoute(route)
7  })
8}

问题不在这段代码本身,而在什么时候调用它。第一次接手这块逻辑的时候,是在登录成功的回调里调用一次 setupPermissionRoutes,平时点菜单跳转都没问题。但只要用户直接刷新页面,或者复制一个深层路由的地址在新标签页打开,比如 /orders/1024/edit,十有八九进 404。

原因很简单:页面刷新意味着整个 Vue 应用重新初始化,登录回调不会再执行一次,addRoute 也就没有被再调用一次。这时候访问 /orders/1024/edit,路由表里压根没有这条动态注册的记录,匹配不到就直接进了 Vue Router 未匹配到路由时的默认 404 处理。

正确的做法是把这一步挪到路由守卫里,在真正进入任何页面之前先确保权限路由已经注册好:

1router.beforeEach(async (to) => {
2  if (!authStore.ready) {
3    await authStore.loadCurrentUser()
4    await setupPermissionRoutes()
5    return to.fullPath
6  }
7})

这里 return to.fullPath 是关键,很容易被忽略。addRoute 注册的新路由不会让当前这次导航自动重新匹配,如果不手动返回目标地址重新触发一次导航,守卫走完之后,Vue Router 仍然拿着最初那次(还没加载动态路由时的)匹配结果去渲染,该 404 还是 404。返回 to.fullPath 相当于告诉路由"这次导航作废,拿着同样的目标地址重新走一遍",这一次路由表里已经有了对应的记录,能正常匹配上。

权限接口失败不能让守卫死循环

守卫里加了异步逻辑,就得考虑失败的情况。权限接口挂了、token 过期、网络超时,这些都可能发生。如果不处理,authStore.ready 永远是 false,用户每次导航都会重新触发 loadCurrentUser,接口再失败一次,陷入死循环,页面直接卡死在空白页,用户连报错信息都看不到。

1try {
2  await authStore.loadCurrentUser()
3} catch (error) {
4  message.error('权限加载失败,请重新登录')
5  return '/login'
6}

失败了就明确跳转登录页,而不是让守卫继续往下走。这个坑之前在测试环境自己模拟 token 过期场景时踩到过一次——页面直接白屏,查下来就是守卫里没有 catch,loadCurrentUser 抛出异常之后守卫函数本身没有返回值,Vue Router 当作导航被取消处理,停在了原地。加上 catch 之后,至少能保证失败时用户会被送到一个明确的地方。

菜单和路由不是一回事,别用同一份数据描述两件事

刚开始做这块功能时,图省事,菜单直接拿路由表过滤生成——凡是路由表里的项,只要有权限就出现在菜单里。这个思路在页面少的时候没问题,页面一多就会发现很多路由根本不该出现在菜单里:详情页、编辑页、结果页、各种嵌套在业务流程里的中间页面。

解决办法是在路由的 meta 里单独标记这条路由要不要出现在菜单:

1{
2  path: '/orders/:id',
3  component: OrderDetail,
4  meta: {
5    permission: 'order:view',
6    hiddenInMenu: true,
7  },
8}

生成菜单的逻辑里过滤掉 hiddenInMenutrue 的项,但权限判断这一层完全不受影响——没有 order:view 权限的人,就算知道详情页的地址直接输进地址栏,守卫里一样会拦下来。菜单是"给用户看的入口",路由权限是"访问控制",这两件事用同一份 meta 描述,但不是同一回事,不能因为不显示在菜单就以为不需要权限校验,也不能因为有权限就一定要出现在菜单里。

按钮权限:封装一层,不要到处写 includes

业务页面里判断按钮权限,最原始的写法是这样:

1<button v-if="permissions.includes('order:delete')">删除</button>

单个页面这么写没什么问题,但后台系统页面一多,这种判断会散落在几十个组件里。哪天权限判断逻辑要调整——比如要支持"权限码带通配符"这种更灵活的匹配,或者要加一层"权限点失效时间"的判断——就得把这几十处 includes 全部改一遍。

团队现在的做法是封装成组件:

1<Permission code="order:delete">
2  <button>删除</button>
3</Permission>

或者用指令,写法更接近原生:

1<button v-permission="'order:delete'">删除</button>

两种都行,团队里两种用法都有人在用,还没有统一成一种,但至少判断逻辑收敛到了一个地方。这一点上还在纠结要不要趁这次调整顺手把指令和组件合并成一套,毕竟同一个项目里两套按钮权限写法长期共存也不太美观,但目前优先级不高,先放着。

不管用哪种封装,都要记住这只是体验层面的处理。真正的删除操作,接口必须自己校验权限,不能假设"前端隐藏了按钮就等于没人能调用这个接口"。接口返回 403 时,前端要有明确反馈,而不是让请求静默失败:

1if (error.status === 403) {
2  message.error('没有权限执行该操作')
3}

这里有个真实发生过的场景:某个用户通过浏览器devtools直接构造了一个删除请求,绕过了前端的按钮权限判断。后端接口本身有权限校验,请求被拒绝返回了 403,但当时前端没处理这个状态码,用户看到的是一个卡住不动的删除按钮,以为是页面卡死了,报了个"bug"过来。查下来才发现是权限拦截生效了,只是提示没跟上。这也印证了开头那句话——前端拦不住恶意请求,但前端至少要能识别后端的拒绝并给出反馈,不然会被误判成故障。

权限会变,缓存要能刷新

权限不是登录一次就定型的。管理员随时可能在后台把某个用户的角色调整了,或者收回了某个权限点,用户自己那边如果不刷新页面,拿着的还是旧权限数据——按钮可能显示着,点下去却被后端拒绝,体验很差。

现在的处理思路是几层叠加:

登录时拉取一次权限,存入内存里的 store,同时做一份本地缓存,减少刷新页面时的等待。刷新页面时不直接读缓存渲染,而是缓存先展示、后台重新拉取校正,拉取回来和缓存不一致就用最新的覆盖。接口返回 401 或 403,分别处理成"重新登录"和"权限不足提示",不要混在一起处理成同一种报错。关键系统比如财务、订单结算这些页面,额外提供一个手动"刷新权限"的入口,不依赖用户自己刷新浏览器。

不要把权限长期存在 localStorage 里从来不失效。之前排查过一次问题,某个用户反馈自己权限被收回了但页面上功能还在,查下来就是权限数据缓存在 localStorage,没有设置过期时间,也没有在关键操作前重新校验,用户那边刷新过页面但缓存命中了旧数据,直接用了将近一天。后来给缓存加了个时间戳,超过一定时长强制重新拉取,类似的问题就没再出现过。

关于 Pinia:这次没有把权限状态迁过去

顺带记一句和这次改造相关的小决定。目前权限相关的状态还是放在 Vuex 里管理,authStorepermissionStore 这些命名也是 Vuex 的写法。Pinia 这半年官方文档里已经把它列为推荐的状态管理方案,写法比 Vuex 简洁不少,类型推导也更省心,组里已经有人在内部小工具里试过,反馈还不错。

这次权限路由的改造没有顺带把状态管理迁移过去,原因很现实:权限逻辑牵扯的地方太多,路由守卫、按钮指令、菜单生成到处都在读这份状态,改造窗口期里优先把逻辑本身理顺,状态管理框架换不换是另一件事,不想两件事搅在一起,出问题了不好定位是哪一层的锅。等这轮改造稳定下来,后面找个新模块试点用 Pinia 写权限状态,再看要不要把老的部分也迁过去。

权限点变多之后,权限码本身也要有约定

这次改造过程中还发现一个之前没太当回事的问题:权限码的命名越来越乱。有的模块用 order:delete,有的写成 orderDelete,还有个别历史模块用的是 delete_order。这些权限码本身只是字符串,后端和前端对不上大小写或者分隔符,权限判断就直接失效,而且这种失效不会报错,can() 只是安静地返回 false,排查起来很费时间。

现在统一约定成 模块:动作 这种形式,全部小写、冒号分隔,新增权限点必须遵守,历史遗留的几个不规范权限码列了个清单,慢慢在各自模块改造的时候顺手改掉,不搞一次性大规模替换,风险不好控制。

退出登录要清理旧的动态路由

addRoute 加进去的路由,退出登录之后不会自己消失。如果账号 A 退出、账号 B 登录同一个页面(比如同事之间共用一台测试机调试),B 账号权限比 A 少,理论上应该看不到某些页面,但如果只是重新调用了一次 setupPermissionRoutes 给 B 注册新的路由,A 账号之前注册的那些路由记录还挂在路由表里,没被清理掉,B 账号照样能通过地址栏直接访问 A 才有权限看的页面。

这个问题是在测试环境里连续切换两个账号联调时自己碰到的,复现路径很简单,两个账号连续登录同一个页面就能重现。排查后发现问题出在退出登录的逻辑里只清了 authStore 里的用户信息和 token,没有对路由表做任何处理。Vue Router 4 提供了 removeRoute,配合 addRoute 返回的卸载函数使用最省事:

1const dynamicRouteRemovers: Array<() => void> = []
2
3async function setupPermissionRoutes() {
4  const permissions = await fetchPermissions()
5  const routes = buildRoutesByPermissions(permissions)
6
7  routes.forEach((route) => {
8    const remove = router.addRoute(route)
9    dynamicRouteRemovers.push(remove)
10  })
11}
12
13function resetPermissionRoutes() {
14  dynamicRouteRemovers.forEach((remove) => remove())
15  dynamicRouteRemovers.length = 0
16  authStore.ready = false
17}

退出登录时调用 resetPermissionRoutes,把之前注册的动态路由全部卸载,authStore.ready 重置为 false,下一次进入应用时守卫会重新走一遍权限加载流程,新账号注册的路由才是干净的。这一步在最初设计的时候完全没想到,总觉得"重新登录会覆盖"就够了,实际上路由表这种运行时注册的东西,不清理就是一直叠加。

权限和路由懒加载放一起要注意加载时机

后台系统的路由基本都用了懒加载,这在 2022 年已经是标准做法:

1const routes = [
2  {
3    path: '/orders',
4    component: () => import('@/views/order/OrderList.vue'),
5    meta: { permission: 'order:view' },
6  },
7]

这一步本身没什么坑,懒加载和权限路由并不冲突,buildRoutesByPermissions 生成的 component 字段一样可以是个返回 import() 的函数,addRoute 照样能正确处理。真正容易出问题的是权限校验和组件加载的顺序——如果在路由守卫里先做了权限判断,判断通过了才去触发组件的动态 import,那网络慢的时候用户会经历一次"确认有权限"和"等组件资源加载"的两段等待,叠加起来偶尔会让页面卡顿感更明显。

后来的处理是把这两件事解耦:权限只决定这条路由存不存在于路由表里,存在了就正常走 Vue Router 自己的懒加载机制去处理组件资源加载,不要在业务代码里额外插入一层"判断完权限再手动触发加载"的逻辑,这种手动插入反而更容易拖慢体验,也更难和路由本身的 loading 状态、错误边界配合。

表格列和 Tab 页签的权限颗粒度更细

按钮权限做完之后,还有两类地方经常被漏掉:表格的列和页面内的 Tab 页签。这两类 UI 元素的权限判断和普通按钮不太一样,普通按钮是"整个元素显示或不显示",而表格列涉及到列的顺序、宽度这些额外状态,处理起来容易多写一层逻辑。

目前用的办法是把列配置本身写成一个数组,渲染前根据权限过滤:

1const allColumns = [
2  { prop: 'id', label: '订单号' },
3  { prop: 'amount', label: '金额' },
4  { prop: 'profit', label: '利润', permission: 'order:viewProfit' },
5  { prop: 'operator', label: '操作人', permission: 'order:viewOperator' },
6]
7
8const visibleColumns = computed(() =>
9  allColumns.filter((col) => !col.permission || can(col.permission))
10)

profit 这一列涉及利润数据,只有部分角色能看,如果直接在模板里用 v-if 一列列判断,列一多模板会很难读,抽成数组过滤之后清爽不少,后面加列也只需要在数组里加一项。Tab 页签同理,不同角色能看到的 Tab 数量不一样,按同样的思路处理,用一个带 permission 字段的数组过滤出当前用户能看到的 Tab 列表,而不是在模板里写一堆 v-if

前端权限和后端字段级脱敏是两个层次

这次改造也顺带理清楚了一个容易混淆的概念:权限控制和字段级脱敏不是一回事,虽然表现上都是"某些数据某些人看不到"。权限控制的是"能不能进入某个页面、能不能点某个按钮",本质是功能维度的开关;字段脱敏是"同一条记录里,某个字段对不同角色显示不同的值",比如客户手机号对普通客服显示打码后的 138****1234,对主管显示完整号码,这种脱敏理论上应该由后端在返回数据时就处理好,而不是前端拿到完整数据后自己做字符串替换再展示。

之前有个模块图省事,后端一次性把完整手机号返回给前端,前端拿到后按角色判断要不要打码显示。这种做法的问题是完整数据已经出现在了浏览器的网络请求里,打开开发者工具的网络面板就能看到明文,所谓的"打码"只是界面层面的遮挡,起不到真正的保护作用。这一点和开头讲的"前端隐藏按钮不等于后端不校验"是同一个道理的另一种体现——凡是涉及敏感数据的展示控制,该在后端做的脱敏就要在后端做,前端只负责展示后端已经处理好的结果,不要经手一遍完整数据再自己遮挡。

多标签页之间权限状态不同步的问题

这次改造还顺带发现了一个此前没暴露过的边界情况:用户开了两个浏览器标签页,在其中一个页面里退出登录,另一个标签页并不知道这件事,内存里的 authStore 还是登录状态,继续操作会发出带着旧 token 的请求,后端自然会返回 401,但前端这边如果没有对这种"标签页之间状态不同步"的情况做处理,用户会看到一堆莫名其妙的报错,搞不清楚是接口坏了还是自己账号有问题。

处理办法是用 storage 事件在标签页之间广播登录状态的变化。退出登录时除了清理当前标签页的状态,顺手写一个 localStorage 的标记:

1function logout() {
2  resetPermissionRoutes()
3  authStore.reset()
4  localStorage.setItem('auth:logout', String(Date.now()))
5}
6
7window.addEventListener('storage', (event) => {
8  if (event.key === 'auth:logout') {
9    resetPermissionRoutes()
10    authStore.reset()
11    router.replace('/login')
12  }
13})

storage 事件只会在其他标签页触发,当前标签页自己写入 localStorage 不会收到这个事件,所以两边逻辑要分开写,一处是主动退出时执行,一处是被动接收广播时执行。这个点属于比较边缘的场景,后台系统用户很少会同时开好几个标签页操作同一个账号,但金融、订单结算这类对状态一致性要求高的系统,这种同步机制还是有必要提前做好,不然一旦遇到就是让人摸不着头脑的诡异报错。

接下来还没收口的两件事

这次改造暂时告一段落,但有两处还没处理完。一是权限码命名,历史遗留的那几个不规范权限码列了清单,后面各个模块顺手改造的时候再一起清理,不打算单独立项做一次性替换。二是多标签页同步这类边缘场景,目前只覆盖了退出登录这一种情况,如果后面遇到角色调整之后多个标签页权限不一致的问题,大概率还得往 storage 事件那套机制里再加东西。

这次改造过程中比较有体会的一点是,"接口失败时守卫死循环"和"addRoute 之后要重新导航"这两个坑都不容易在设计阶段想到,都是真的踩过一次才补上的。前端负责把入口显示对、把权限不足的提示做清楚、把散落的判断收进少数几个组件或指令里,这些事做顺手了,能省不少后续的排查成本;但"能不能操作"最终拦不拦得住,还是得看接口那一层的校验有没有跟上,这一点不会因为前端做得再细致就有变化。