前端权限控制与动态路由

场景题

现有一个大型后台管理系统,包含超级管理员、运营、财务三个角色。不同角色登录后:

  1. 左侧可见的菜单不同;
  2. 即使手动输入页面 URL,没有权限也应被拦截;
  3. 页面内的“删除配置”按钮仅限超级管理员使用。

如果你是前端 Owner,会如何设计并落地权限控制方案?

核心结论

权限控制要分为四层:

层级解决的问题是否属于安全边界
菜单权限用户能看到哪些导航入口
路由权限用户能进入哪些前端页面
元素权限用户能看到、操作哪些按钮或区域
接口与数据权限用户能否执行操作、访问哪些数据

前三层主要改善用户体验,真正可信的授权必须由服务端完成。前端代码、路由和状态都运行在用户设备上,可能被查看或修改,因此不能把“隐藏菜单”“不渲染按钮”或“未注册路由”描述成安全隔离。

一、权限模型设计

1. 角色与权限码

小型系统可以直接按角色判断:

meta: { roles: ['super-admin', 'operator'] }

系统变复杂后,更推荐前后端约定稳定的权限码:

system:config:view
system:config:delete
order:list:view
order:refund:create

典型 RBAC(Role-Based Access Control)关系是:

用户 -> 角色 -> 权限

服务端负责维护角色与权限的关系,并向前端返回当前用户最终拥有的权限集合。前端只判断权限码,不把“运营”“财务”等职位名称散落在业务组件中:

const permissions = new Set([
  'system:config:view',
  'system:config:delete',
])

const can = (permission: string) => permissions.has(permission)

如果授权还依赖资源归属、部门、地区、时间或订单状态,仅靠 RBAC 不够,可以结合 ABAC(基于属性的访问控制)或数据权限规则。最终判断仍应在服务端完成。

2. 权限数据来源

登录成功后,前端携带凭证请求当前用户信息,例如:

{
  "id": "u_1001",
  "roles": ["operator"],
  "permissions": [
    "dashboard:view",
    "order:list:view",
    "order:detail:view"
  ]
}

权限应以服务端结果为准。权限接口失败、返回未知权限或路由缺少必要配置时,采用默认拒绝原则,不能默认放行。

二、菜单与路由权限

1. 静态路由与受控路由

前端可以将路由分为两类:

  • 静态路由:登录页、403、404 等公共页面;
  • 受控路由:订单、财务、系统配置等需要权限的业务页面。
const controlledRoutes = [
  {
    path: '/system',
    meta: { title: '系统管理' },
    children: [
      {
        path: 'config',
        component: () => import('./pages/SystemConfig.vue'),
        meta: {
          title: '系统配置',
          permission: 'system:config:view',
          menu: true,
        },
      },
    ],
  },
]

页面组件使用动态 import() 做路由懒加载。是否在前端保存完整路由配置,与首屏包大小没有必然关系;首屏体积主要取决于是否正确拆包和按需加载。

2. 递归过滤路由

嵌套路由不能只在顶层调用一次 .filter(),需要递归处理,并移除没有可访问子节点的空目录:

type Route = {
  path: string
  meta?: { permission?: string; menu?: boolean }
  children?: Route[]
}

function filterRoutes(routes: Route[], can: (code: string) => boolean): Route[] {
  return routes.flatMap((route) => {
    const children = route.children
      ? filterRoutes(route.children, can)
      : undefined

    const required = route.meta?.permission
    const selfAllowed = !required || can(required)
    const hasAllowedChild = Boolean(children?.length)

    if (!selfAllowed && !hasAllowedChild) return []
    if (route.children && !hasAllowedChild && !required) return []

    return [{ ...route, children }]
  })
}

过滤完成后,可以使用 Vue Router 的 addRoute() 注册路由;React Router 则根据权限状态生成传给路由组件的配置。

3. 菜单和路由不要完全等同

菜单通常可以从已授权路由中派生,但菜单项与可访问页面不是一一对应关系。例如订单详情页允许通过列表跳转访问,却不需要显示在侧边栏。

因此建议同时配置:

  • permission:进入页面所需权限;
  • menu:是否显示为菜单;
  • titleiconorder:菜单展示信息。

“菜单隐藏但路由可访问”必须是显式配置,不能简单通过是否出现在菜单中判断页面权限。

4. 首次导航的初始化时序

刷新页面或直接访问深层 URL 时,需要先完成权限初始化,再继续首次导航:

恢复登录凭证
  -> 请求当前用户与权限
  -> 过滤并注册受控路由
  -> 生成菜单
  -> 继续原目标地址

初始化期间应显示明确的 Loading。权限请求失败时进入错误页或登录页,并提供重试,避免提前命中 404、无限重定向或长时间白屏。

路由守卫还要覆盖以下状态:

  • 未登录访问受控页面:跳转登录页,并保存原目标地址;
  • 已登录但无页面权限:进入 403;
  • 路由确实不存在:进入 404;
  • 权限仍在加载:等待初始化完成,不提前判定;
  • 权限配置异常:默认拒绝并记录监控日志。

5. 退出与账号切换

退出登录、切换账号或租户时,需要同时清理:

  • 用户信息和权限集合;
  • 动态注册的路由;
  • 菜单和标签页缓存;
  • 与身份关联的业务缓存。

否则同一浏览器中后登录的低权限用户可能短暂看到上一个用户留下的菜单或页面。Vue Router 的 addRoute() 会返回移除函数,可以集中保存并在退出时调用;也可以重建路由实例。

三、元素与视图权限

两个角色可以进入同一页面,但页面中的操作权限可能不同。建议统一提供 can、组件或指令,避免各业务组件自行读取角色。

Vue 示例

<button v-auth="'system:config:delete'" @click="removeConfig">
  删除配置
</button>

React 示例

<Permission code="system:config:delete">
  <button onClick={removeConfig}>删除配置</button>
</Permission>

无权限时直接不渲染节点即可。不要把 display: none、删除 DOM 或返回 null 当作安全措施——用户仍然可以直接构造接口请求。

如果一个操作需要多个权限,应明确区分语义:

can('system:config:delete')
canAny(['config:delete', 'config:admin'])
canAll(['config:view', 'config:delete'])

对于不可操作但希望用户了解其存在的功能,也可以保留禁用按钮并说明原因;隐藏还是禁用属于产品体验选择,不改变服务端必须鉴权的要求。

四、接口与数据权限:最终安全边界

服务端必须对每个受保护接口验证:

  1. 请求者是否已经通过认证;
  2. 请求者是否拥有执行当前动作的权限;
  3. 请求者是否有权操作当前资源或数据范围;
  4. 当前资源状态是否允许该操作。

例如用户拥有 order:detail:view,也不代表可以查看所有订单。服务端还要判断订单是否属于该用户可访问的部门、区域或租户,不能只相信前端提交的 tenantIddepartmentId 等字段。

401 与 403 的处理

  • 401 Unauthorized:实际表示“未认证”,常见于凭证缺失或失效。前端可以清理登录态,跳转登录页,并保留返回地址;
  • 403 Forbidden:身份有效,但没有当前操作权限。前端应保留登录态,展示无权限页面或操作提示;
  • 业务规则冲突可以根据接口规范使用合适的业务错误码或 409 Conflict,不要全部混成 403。

响应拦截器适合处理通用行为,但页面仍应对具体业务错误提供准确反馈。

五、容易被追问的工程问题

权限发生变化,前端如何更新?

服务端始终实时校验,因此旧权限不会绕过接口授权。前端可以在重新登录、切换租户、页面刷新或收到权限变更通知时重新拉取权限,并重建路由和菜单。对权限敏感的系统还可以设置权限版本号或较短的缓存周期。

为什么还需要前端权限控制?

它不能代替后端鉴权,但可以:

  • 避免向用户展示无法使用的入口;
  • 在页面跳转前给出及时反馈;
  • 统一菜单、页面和操作的展示规则;
  • 降低误操作概率,改善使用体验。

动态路由是否一定优于静态路由守卫?

不一定。两种方案都可以正确实现:

  • 动态注册适合大型后台系统,可直接从授权路由派生菜单;
  • 静态注册全部路由并在守卫中校验权限,实现更简单,也便于类型检查和路由分析;
  • 无论采用哪种方式,页面组件都可以懒加载,服务端都必须独立鉴权。

选择方案时要考虑路由规模、菜单生成方式、SSR、微前端以及维护成本,而不是把动态注册本身当成安全能力。

面试回答示例

我会把权限控制拆成菜单、路由、元素和接口数据四层,并统一使用服务端下发的权限码。

登录后先获取当前用户的权限集合,递归过滤受控路由,完成动态注册后再继续首次导航,同时从授权路由中生成菜单。详情页这类隐藏菜单但允许访问的页面会单独配置,不能把菜单和路由权限完全等同。页面中的删除按钮通过统一的 can 方法、Vue 指令或 React 权限组件控制是否渲染。

这些前端措施只负责体验,不是安全边界。删除接口必须在服务端同时校验登录状态、操作权限和数据范围。401 表示凭证无效,可跳转登录;403 表示已经登录但无权限,应保留会话并提示无权访问。

工程上我还会处理刷新时权限初始化、403 与 404 的区分,以及退出、账号切换时动态路由和权限缓存的清理。权限接口失败或配置异常时采用默认拒绝,避免意外放行。

总结

前端权限控制的重点不是把页面“藏起来”,而是建立职责清晰、默认拒绝、可统一维护的权限体系:

服务端定义并校验权限

前端获取权限集合

路由控制页面准入 ── 菜单控制导航展示

元素权限控制操作入口

服务端再次校验动作与数据范围

记住一句话:前端权限控制体验,后端权限保障安全。