Skip to content

用户权限、动态路由与页面权限控制项目实战文档 ​

适合人群:希望在 Vue2、Vue3、React、Nuxt、小程序、Node/Nest/Express 项目中真正落地登录鉴权、RBAC、动态菜单、动态路由、页面访问控制、按钮权限和接口权限的前端/全栈开发者。

学习目标:理解“前端权限”和“后端权限”的边界,能设计权限模型、路由模型、菜单模型,并在不同框架中实现可维护、可测试、可扩展的权限系统。

核对时间:2026-06-30。本文参考 Vue Router、React Router、Nuxt、NestJS、Express、微信小程序路由等官方资料,并结合企业后台、SaaS、多租户系统常见实践整理。


目录 ​

  1. 权限系统到底解决什么问题
  2. 前端权限和后端权限的边界
  3. 常见权限模型:RBAC、ABAC、ACL、Feature Flag
  4. 权限系统的核心数据结构
  5. 动态路由和动态菜单的关系
  6. Vue2 项目实现
  7. Vue3 项目实现
  8. React 项目实现
  9. Nuxt 项目实现
  10. 小程序项目实现
  11. NestJS 后端权限实现
  12. Express 后端权限实现
  13. 页面渲染、按钮权限和组件权限
  14. 接口权限、菜单权限、数据权限怎么配合
  15. 多租户和组织权限
  16. 常见问题与反模式
  17. 测试清单
  18. 官方参考资料

1. 权限系统到底解决什么问题 ​

权限系统不是只做“登录后才能访问页面”。

真实项目中通常要解决五类问题:

  1. 用户是否已经登录。
  2. 用户能不能进入某个页面。
  3. 用户能不能看到某个菜单。
  4. 用户能不能点击某个按钮或使用某个功能。
  5. 用户能不能访问某个接口或某条数据。

这些问题看起来都叫权限,但控制位置不同:

  • 登录态:前端和后端都要判断。
  • 页面权限:前端路由守卫负责体验,后端仍要保护接口。
  • 菜单权限:前端负责渲染。
  • 按钮权限:前端负责隐藏/禁用,后端负责接口权限。
  • 数据权限:必须由后端控制。

1.1 一个典型后台系统 ​

用户 Alice 是“运营专员”。

她可以:

  • 访问订单列表。
  • 查看订单详情。
  • 导出自己负责区域的订单。

她不可以:

  • 删除订单。
  • 修改商品价格。
  • 访问财务报表。
  • 查看其他区域订单。

前端应该:

  • 不显示财务报表菜单。
  • 进入 /finance 时跳转 403。
  • 不显示删除按钮。
  • 导出按钮根据权限展示。

后端必须:

  • 拒绝 /api/finance/**。
  • 拒绝删除订单接口。
  • 导出时只导出 Alice 有权限的数据。

2. 前端权限和后端权限的边界 ​

2.1 前端权限负责体验 ​

前端权限主要负责:

  • 控制菜单显示。
  • 控制路由访问。
  • 控制按钮显示或禁用。
  • 控制页面局部渲染。
  • 提前拦截明显无权限操作。
  • 给用户更好的反馈。

2.2 后端权限负责安全 ​

后端权限负责:

  • 验证 token。
  • 验证角色。
  • 验证权限码。
  • 验证数据范围。
  • 审计操作。
  • 防止绕过前端直接调接口。

任何只在前端做的权限都不是安全权限。

用户可以:

  • 修改浏览器 localStorage。
  • 手动输入 URL。
  • 在 DevTools 里改路由对象。
  • 直接调用接口。
  • 修改小程序本地缓存。

所以后端接口权限永远不能省。

2.3 推荐分工 ​

权限类型前端负责后端负责
登录态跳转登录、保存 token、刷新 token校验 token、签发 token、失效 token
菜单权限渲染菜单返回用户可见菜单或权限码
页面权限路由守卫、403 页面接口权限、页面 SSR 权限
按钮权限隐藏或禁用按钮操作接口校验
数据权限展示后端返回数据过滤数据范围
租户权限选择租户、切换上下文校验租户隔离

2.4 守卫跳转中的开放重定向 ​

登录守卫普遍携带回跳参数:

text
/login?redirect=/users/detail

如果登录成功后无脑 router.push(query.redirect),攻击者可以构造 /login?redirect=https://evil.com 把你的站点变成钓鱼跳板(会话凭据在跳转链路上的泄漏、用户信任被借用)。这就是 OWASP Unvalidated Redirects 问题在权限系统里的具体形态。

规则:回跳参数只接受站内相对路径。校验至少要做:

ts
function safeRedirect(target: string): string {
  // 只放行以单个 / 开头的站内路径;
  // //evil.com 是协议相对 URL,/\evil.com 会被浏览器宽容解析为 //evil.com
  if (!target.startsWith("/") || /^\/\/|^\/\\/.test(target)) return "/";
  return target;
}

💡 更严格的方案是服务端把用户常用回跳目标登记为枚举 key(?r=user-detail),前端只查表;带反斜杠、编码变体的输入一律回默认页——浏览器对协议相对 URL 的宽容解析是这类绕过的主流手法。


3. 常见权限模型:RBAC、ABAC、ACL、Feature Flag ​

3.1 RBAC ​

RBAC:Role-Based Access Control,基于角色的访问控制。

模型:

text
用户 -> 角色 -> 权限

例子:

text
Alice -> 运营专员 -> order:read, order:export
Bob   -> 管理员   -> user:create, user:delete, order:delete

适合:

  • 后台管理系统。
  • 企业内部系统。
  • 权限相对稳定。

3.2 ABAC ​

ABAC:Attribute-Based Access Control,基于属性的访问控制。

判断条件可能包含:

  • 用户部门。
  • 用户等级。
  • 资源所属区域。
  • 当前时间。
  • 订单状态。
  • 租户。

例子:

text
用户部门 == 订单所属部门
并且
订单状态 != 已归档

适合:

  • 数据权限复杂。
  • 多租户。
  • 审批流。
  • 组织架构权限。

3.3 ACL ​

ACL:Access Control List,访问控制列表。

例子:

json
{
  "resourceId": "doc_123",
  "allowUsers": ["u1", "u2"],
  "allowRoles": ["admin"]
}

适合:

  • 文档协作。
  • 文件系统。
  • 单资源授权。

3.4 Feature Flag ​

Feature Flag 不是传统权限,但经常和权限一起用。

它控制:

  • 某个功能是否对某批用户开放。
  • 灰度发布。
  • A/B 测试。
  • 紧急关闭功能。

例子:

ts
if (flags.newDashboard && hasPermission("dashboard:read")) {
  renderNewDashboard();
}

Feature Flag 控制“功能开关”,权限控制“用户是否有资格使用”。

3.5 RBAC 的层次模型(NIST 四级) ​

实践中的“RBAC”深度差别很大,NIST 的参考模型把它分成四级,逐级叠加:

级别名称增加的能力典型判据
1Core RBAC用户 → 角色 → 权限权限只能通过角色授予,不给用户直挂权限
2Hierarchical RBAC角色继承主管 ⊇ 员工,高级角色自动拥有低级角色权限
3Constrained RBAC职责分离(SoD)“审批人”与“申请人”不能是同一人;互斥角色约束
4Symmetric RBAC双向可查从权限反查角色:谁拥有 order:delete?

多数中后台项目落在 1~2 级,3 级只在审批/财务合规场景需要。Core RBAC 有一条常被违反的铁律:权限只关联角色,不给用户单独开权限。一旦允许“给张三加一条 user:delete”,就退化成 ACL,审计时无法回答“谁能删用户”。

💡 Session 是 RBAC 里被忽略的一环:模型中用户在一次会话内激活的是其角色的一个子集。这正是“管理员平时以普通成员身份浏览、需要时显式切换到管理视角”的设计依据(最小权限 + 提权留痕),也是前端要区分“我拥有的角色”和“我当前生效的角色”的原因。

3.6 模型选型:分层而不是二选一 ​

text
RBAC  -> 稳定的岗位能力(能不能用这个功能)
ABAC  -> 上下文条件(这次操作满不满足规则)
ACL   -> 单个资源的例外共享(这份文档给谁看)
Flag  -> 功能是否开放(灰度、A/B、紧急关停)

“RBAC 还是 ABAC”通常是错问题,对的问题是“哪个决策由哪一层负责”。判据:规则能被“角色 × 资源动作”穷举 → RBAC;规则依赖运行时属性(部门、区域、时间、订单状态、租户)→ ABAC 条件;只对单个对象生效 → ACL。前端拿到的权限码应是 RBAC 计算结果,数据可见范围由后端 ABAC 条件过滤,两者不要混在同一个权限码里。


4. 权限系统的核心数据结构 ​

4.1 用户信息 ​

ts
export interface CurrentUser {
  id: string;
  name: string;
  roles: string[];
  permissions: string[];
  tenantId?: string;
  organizationIds?: string[];
}

4.2 权限码 ​

权限码建议按资源和动作命名:

text
user:read
user:create
user:update
user:delete
order:read
order:export
finance:read

不要用:

text
canSeeButton1
isAdminPageVisible

权限码要表达业务能力,不要绑定某个 UI。

4.3 路由 meta ​

ts
export interface RouteMeta {
  title: string;
  icon?: string;
  requiresAuth?: boolean;
  permissions?: string[];
  roles?: string[];
  hidden?: boolean;
}

4.4 菜单模型 ​

ts
export interface MenuItem {
  id: string;
  title: string;
  path: string;
  icon?: string;
  permission?: string;
  children?: MenuItem[];
}

菜单和路由可以同源,也可以分开。

同源适合:

  • 中小后台。
  • 菜单基本等于路由。

分开适合:

  • 菜单结构和路由结构不同。
  • 页面可通过多个入口访问。
  • 微前端。
  • 菜单由后端配置。

4.5 后端视角:RBAC 的数据模型落地 ​

前端拿到的 permissions: string[] 是这套表结构 join 出来的结果,架构师要能看懂(或评审)这条链路:

text
users ──< user_roles >── roles ──< role_permissions >── permissions
                                                          │
                                              menu / api / button 资源表引用 permission code
sql
-- 最小可用模型:复用已有 users 表,新增 4 张表(2 张主表 + 2 张关联表)
CREATE TABLE roles (id BIGINT PRIMARY KEY, code VARCHAR(64) UNIQUE, name VARCHAR(64));
CREATE TABLE permissions (id BIGINT PRIMARY KEY, code VARCHAR(128) UNIQUE, resource VARCHAR(64), action VARCHAR(32));
CREATE TABLE user_roles (user_id BIGINT, role_id BIGINT, PRIMARY KEY (user_id, role_id));
CREATE TABLE role_permissions (role_id BIGINT, permission_id BIGINT, PRIMARY KEY (role_id, permission_id));
-- /api/me 的权限码即:
SELECT DISTINCT p.code FROM user_roles ur
  JOIN role_permissions rp ON rp.role_id = ur.role_id
  JOIN permissions p ON p.id = rp.permission_id
 WHERE ur.user_id = :userId;

三个设计决策点:

决策推荐理由
前端判断依据用角色还是权限码权限码角色是管理概念、变动频繁;权限码是能力契约,UI 不应随“管理员”改定义而逐页搜索替换(对应 16.2)
权限码存哪、如何下发存资源侧(菜单/API 注册表引用 code),/api/me 聚合下发避免“菜单表、路由 meta、接口注解”三处手写同一 code 漂移(对应 16.3)
权限变更生效时机变更后使旧 token/缓存失效,或 permissions_version 随响应头下发纯靠登录时快照,会出现“回收权限后旧前端仍可见入口”的窗口

⚠️ 不要把整棵角色树/继承关系塞进 JWT。JWT 无状态、签发后不可撤销,塞权限等于把“撤销”变成“等过期”。合理做法:JWT 只放 userId + sessionId,权限在服务端会话缓存(带版本号/失效事件),/api/me 每次读缓存。

💡 数据量视角:一个中大型后台权限码常在数百到数千个,全部展开进每次请求头会浪费带宽。前端全量下发(用于路由/按钮过滤)没问题——它本来就是本次会话的渲染输入;接口校验永远用“当前请求所需 code ∈ 用户权限集”的点查,不下发全量给第三方。


5. 动态路由和动态菜单的关系 ​

5.1 静态路由 ​

静态路由是项目启动时就写死的路由:

ts
const routes = [
  { path: "/login", component: LoginPage },
  { path: "/403", component: ForbiddenPage },
  { path: "/", component: Layout },
];

适合:

  • 登录页。
  • 错误页。
  • 公共页面。
  • 基础布局。

5.2 动态路由 ​

动态路由是登录后根据权限添加或过滤的路由。

常见做法:

  1. 前端维护完整路由表,根据权限过滤。
  2. 后端返回菜单/路由配置,前端映射组件。
  3. 微前端由主应用根据权限加载子应用路由。

5.3 推荐策略 ​

中后台项目推荐:

  • 前端维护组件映射和基础路由。
  • 后端返回权限码或菜单树。
  • 前端根据权限过滤路由和菜单。
  • 后端接口仍做权限校验。

不推荐完全相信后端返回的组件路径,例如:

json
{
  "component": "@/views/admin/SuperSecret.vue"
}

更安全的方式是前端维护白名单映射:

ts
const componentMap = {
  UserList: () => import("@/pages/user-list/index.vue"),
  OrderList: () => import("@/pages/order-list/index.vue"),
};

后端只返回 key:

json
{
  "componentKey": "UserList"
}

白名单映射还带来一个附带收益:后端返回的 key 无法映射到组件时(配置错、版本不匹配),前端可以降级渲染“未知页面”占位并上报,而不是执行任意路径的动态 import。

5.4 两种实现路线的取舍:运行时 addRoute vs 全量注册 + 守卫拦截 ​

维度方案 A:过滤后 addRoute(Vue 常见)方案 B:全量注册,守卫拦截(React 常见)
路由表完整性无权限路由不存在于匹配表全部存在,访问时判定
越权直接输 URL落到 catch-all → 404(需重定向到 403 才友好)守卫返回 /403,语义清晰
首屏成本登录后多一次权限请求 + 建路由无额外等待
代码分割只加载有权限页面的 chunk懒加载在真正访问时才拉包,浪费可控
心智负担动态增删、退出重置、刷新时序只有守卫一处判定

选型判据:权限决定“存在哪些页面”(角色间菜单差异大、页面数量多)→ 方案 A;权限只决定“能不能进”(页面集合固定)→ 方案 B 更稳。

⚠️ 不要用 hidden: true 表达权限——hidden 的语义是“有权限但不放菜单”(详情页、编辑页),权限判定是另一件事;一个字段承担两个语义必然漂移。

5.5 刷新与首屏时序(动态路由最容易出错的地方) ​

刷新浏览器时 SPA 从零启动,路由表是空的,而地址栏已经在 /users/detail。必须保证顺序:

text
1. 恢复 token(cookie/localStorage)
2. 拉 /api/me(用户 + 权限码),失败 → 401 处理
3. 过滤 asyncRoutes → 逐条 router.addRoute()
4. 用 to.fullPath 重新触发一次导航,让新注册的路由参与匹配

第 4 步缺失就出现经典 bug:刷新后 404,点进菜单却正常(addRoute() 只登记新路由,不会改写当前已完成的匹配结果,必须手动再触发一次导航,Vue Router 官方文档明确了这一点)。守卫内的正确写法:加完路由后 return to.fullPath(Vue Router 4)或 next({ ...to, replace: true })(Vue Router 3)——重定向语义等价于 replace,不会在历史里留下多余条目。

⚠️ 两个连锁坑:其一,重定向会让守卫再次执行,必须有 routesLoaded 之类的标志位防止“加路由 → 重定向 → 再加路由”的无限循环;其二,catch-all 兜底路由 path: '/:catchAll(.*)' 必须最后注册——若它先于动态路由存在,未注册路径会被它吞进 404 页,重定向后仍匹配到它。

5.6 权限收窄与路由退订 ​

router.addRoute() 返回一个移除函数,router.removeRoute(name) 按名移除;移除父路由会连带移除其全部 alias 和子路由。工程上把移除函数收集起来管理生命周期:

ts
let removers: Array<() => void> = [];

export function addPermissionRoutes(permissions: string[]) {
  removers.forEach((remove) => remove());
  removers = filterRoutes(asyncRoutes, permissions).map((route) =>
    router.addRoute(route)
  );
}

export function resetPermissionRoutes() {
  removers.forEach((remove) => remove());
  removers = [];
}

两个易漏点:退出登录时必须 resetPermissionRoutes()(否则换账号登录继承上个账号的动态路由,见 16.4);权限收窄(管理员在线收回某角色权限)要能触发同样的重建,常见做法是轮询/websocket 收到 permissions_version 变化后重跑 addPermissionRoutes 并校正当前路由——停留在已被移除页面上的用户会看到 403 而不是白屏。


6. Vue2 项目实现 ​

6.1 技术栈 ​

text
Vue 2
Vue Router 3
Vuex 3
Vue CLI / Webpack
Axios

6.2 路由定义 ​

src/router/routes.js:

js
import Layout from "@/layout/index.vue";

export const constantRoutes = [
  {
    path: "/login",
    component: () => import("@/views/login/index.vue"),
    meta: { public: true },
  },
  {
    path: "/403",
    component: () => import("@/views/error/403.vue"),
    meta: { public: true },
  },
];

export const asyncRoutes = [
  {
    path: "/users",
    component: Layout,
    meta: {
      title: "用户管理",
      permissions: ["user:read"],
    },
    children: [
      {
        path: "",
        name: "UserList",
        component: () => import("@/views/user/UserList.vue"),
        meta: {
          title: "用户列表",
          permissions: ["user:read"],
        },
      },
    ],
  },
];

6.3 权限过滤函数 ​

src/permission/filterRoutes.js:

js
export function hasPermission(route, permissions) {
  const required = route.meta && route.meta.permissions;
  if (!required || required.length === 0) return true;
  return required.some((permission) => permissions.includes(permission));
}

export function filterAsyncRoutes(routes, permissions) {
  const result = [];

  routes.forEach((route) => {
    const copy = { ...route };

    if (hasPermission(copy, permissions)) {
      if (copy.children) {
        copy.children = filterAsyncRoutes(copy.children, permissions);
      }
      result.push(copy);
    }
  });

  return result;
}

6.4 Vuex 权限模块 ​

src/store/modules/permission.js:

js
import { asyncRoutes, constantRoutes } from "@/router/routes";
import { filterAsyncRoutes } from "@/permission/filterRoutes";

export default {
  namespaced: true,
  state: {
    routes: constantRoutes,
    addRoutes: [],
  },
  mutations: {
    SET_ROUTES(state, routes) {
      state.addRoutes = routes;
      state.routes = constantRoutes.concat(routes);
    },
  },
  actions: {
    generateRoutes({ commit }, permissions) {
      const accessedRoutes = filterAsyncRoutes(asyncRoutes, permissions);
      commit("SET_ROUTES", accessedRoutes);
      return accessedRoutes;
    },
  },
};

6.5 路由守卫 ​

src/permission/index.js:

js
import router from "@/router";
import store from "@/store";

router.beforeEach(async (to, from, next) => {
  const token = store.state.user.token;

  if (to.meta && to.meta.public) {
    next();
    return;
  }

  if (!token) {
    next(`/login?redirect=${to.fullPath}`);
    return;
  }

  if (!store.state.user.loaded) {
    const user = await store.dispatch("user/fetchCurrentUser");
    const routes = await store.dispatch(
      "permission/generateRoutes",
      user.permissions
    );

    routes.forEach((route) => router.addRoute(route));
    next({ ...to, replace: true });
    return;
  }

  next();
});

6.6 Vue2 注意点 ​

Vue Router 3 老项目中常见 addRoutes(),但更新版本更推荐逐条 addRoute()。如果项目锁在旧版本,要按实际版本处理。

注意:

  • 动态路由添加后要 next({ ...to, replace: true }),避免首次进入 404。
  • 退出登录时要重置路由,否则下一个用户可能继承上一个用户的动态路由。
  • 后端接口仍必须做权限校验。

6.7 按钮权限指令 ​

src/directives/permission.js:

js
import store from "@/store";

export default {
  inserted(el, binding) {
    const required = binding.value;
    const permissions = store.state.user.permissions;
    const allowed = required.some((item) => permissions.includes(item));

    if (!allowed) {
      el.parentNode && el.parentNode.removeChild(el);
    }
  },
};

使用:

vue
<button v-permission="['user:delete']">删除用户</button>

7. Vue3 项目实现 ​

7.1 技术栈 ​

text
Vue 3
Vue Router 4
Pinia
TypeScript
Vite

7.2 路由类型增强 ​

src/router/types.ts:

ts
export interface AppRouteMeta {
  title?: string;
  public?: boolean;
  permissions?: string[];
  roles?: string[];
  hidden?: boolean;
}

Vue Router 可以通过类型声明扩展 RouteMeta。

src/router.d.ts:

ts
import "vue-router";

declare module "vue-router" {
  interface RouteMeta {
    title?: string;
    public?: boolean;
    permissions?: string[];
    roles?: string[];
    hidden?: boolean;
  }
}

7.3 路由定义 ​

ts
import type { RouteRecordRaw } from "vue-router";

export const constantRoutes: RouteRecordRaw[] = [
  {
    path: "/login",
    component: () => import("@/pages/login/index.vue"),
    meta: { public: true },
  },
  {
    path: "/403",
    component: () => import("@/pages/error/403.vue"),
    meta: { public: true },
  },
];

export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: "/users",
    component: () => import("@/app/layouts/default.vue"),
    meta: {
      title: "用户管理",
      permissions: ["user:read"],
    },
    children: [
      {
        path: "",
        name: "UserList",
        component: () => import("@/pages/users/index.vue"),
        meta: {
          title: "用户列表",
          permissions: ["user:read"],
        },
      },
    ],
  },
];

7.4 Pinia 用户 store ​

ts
import { defineStore } from "pinia";

export interface CurrentUser {
  id: string;
  name: string;
  roles: string[];
  permissions: string[];
}

export const useAuthStore = defineStore("auth", {
  state: () => ({
    token: localStorage.getItem("token"),
    user: null as CurrentUser | null,
    routesLoaded: false,
  }),
  actions: {
    async fetchCurrentUser() {
      const user = await fetch("/api/me").then((res) => res.json());
      this.user = user;
      return user as CurrentUser;
    },
    logout() {
      this.token = null;
      this.user = null;
      this.routesLoaded = false;
      localStorage.removeItem("token");
    },
  },
});

7.5 动态添加路由 ​

ts
import router from "@/router";
import { asyncRoutes } from "@/router/routes";

function canAccess(route: RouteRecordRaw, permissions: string[]) {
  const required = route.meta?.permissions as string[] | undefined;
  if (!required || required.length === 0) return true;
  return required.some((permission) => permissions.includes(permission));
}

export function filterRoutes(routes: RouteRecordRaw[], permissions: string[]) {
  return routes
    .filter((route) => canAccess(route, permissions))
    .map((route) => ({
      ...route,
      children: route.children
        ? filterRoutes(route.children, permissions)
        : undefined,
    }));
}

export function addPermissionRoutes(permissions: string[]) {
  const routes = filterRoutes(asyncRoutes, permissions);
  routes.forEach((route) => router.addRoute(route));
  return routes;
}

7.6 路由守卫 ​

ts
import router from "@/router";
import { useAuthStore } from "@/stores/auth";
import { addPermissionRoutes } from "@/router/permission";

router.beforeEach(async (to) => {
  const auth = useAuthStore();

  if (to.meta.public) {
    return true;
  }

  if (!auth.token) {
    return {
      path: "/login",
      query: { redirect: to.fullPath },
    };
  }

  if (!auth.routesLoaded) {
    const user = auth.user ?? (await auth.fetchCurrentUser());
    addPermissionRoutes(user.permissions);
    auth.routesLoaded = true;

    return { ...to, replace: true };
  }

  const required = to.meta.permissions;
  if (required && !required.some((p) => auth.user?.permissions.includes(p))) {
    return "/403";
  }

  return true;
});

7.7 按钮权限 composable ​

ts
import { computed } from "vue";
import { useAuthStore } from "@/stores/auth";

export function usePermission() {
  const auth = useAuthStore();

  function hasPermission(required: string | string[]) {
    const permissions = auth.user?.permissions ?? [];
    const list = Array.isArray(required) ? required : [required];
    return list.some((item) => permissions.includes(item));
  }

  return {
    hasPermission,
    permissions: computed(() => auth.user?.permissions ?? []),
  };
}

使用:

vue
<script setup lang="ts">
const { hasPermission } = usePermission();
</script>

<template>
  <button v-if="hasPermission('user:delete')">删除</button>
</template>

8. React 项目实现 ​

8.1 技术栈 ​

text
React
React Router
TypeScript
Zustand / Redux Toolkit
TanStack Query

8.2 路由模型 ​

tsx
import type { RouteObject } from "react-router-dom";

export interface AppRouteObject extends RouteObject {
  meta?: {
    title?: string;
    public?: boolean;
    permissions?: string[];
    hidden?: boolean;
  };
  children?: AppRouteObject[];
}

8.3 路由定义 ​

tsx
import { Navigate } from "react-router-dom";
import { DefaultLayout } from "@/app/layouts/DefaultLayout";

export const routes: AppRouteObject[] = [
  {
    path: "/login",
    lazy: async () => ({
      Component: (await import("@/pages/login")).LoginPage,
    }),
    meta: { public: true },
  },
  {
    path: "/",
    element: <DefaultLayout />,
    children: [
      {
        path: "users",
        lazy: async () => ({
          Component: (await import("@/pages/users")).UserListPage,
        }),
        meta: {
          title: "用户管理",
          permissions: ["user:read"],
        },
      },
    ],
  },
  {
    path: "*",
    element: <Navigate to="/404" replace />,
  },
];

8.4 ProtectedRoute ​

tsx
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuthStore } from "@/shared/auth/useAuthStore";

export function ProtectedRoute({ permissions }: { permissions?: string[] }) {
  const location = useLocation();
  const { token, user } = useAuthStore();

  if (!token) {
    return (
      <Navigate
        to="/login"
        replace
        state={{ redirect: location.pathname + location.search }}
      />
    );
  }

  if (permissions?.length) {
    const userPermissions = user?.permissions ?? [];
    const allowed = permissions.some((item) => userPermissions.includes(item));

    if (!allowed) {
      return <Navigate to="/403" replace />;
    }
  }

  return <Outlet />;
}

路由使用:

tsx
{
  element: <ProtectedRoute permissions={["user:read"]} />,
  children: [
    {
      path: "/users",
      element: <UserListPage />
    }
  ]
}

8.5 动态路由和菜单 ​

React Router 通常不需要像 Vue Router 那样运行时 addRoute()。更常见做法是:

  1. 维护完整 route config。
  2. 登录后获取用户权限。
  3. 根据权限过滤菜单。
  4. 页面访问时通过 ProtectedRoute 拦截。

菜单过滤:

ts
export function filterMenu(menu: MenuItem[], permissions: string[]) {
  return menu
    .filter((item) => {
      if (!item.permission) return true;
      return permissions.includes(item.permission);
    })
    .map((item) => ({
      ...item,
      children: item.children
        ? filterMenu(item.children, permissions)
        : undefined,
    }));
}

8.6 按钮权限组件 ​

tsx
import { useAuthStore } from "@/shared/auth/useAuthStore";

export function Can(props: {
  permissions: string | string[];
  children: React.ReactNode;
  fallback?: React.ReactNode;
}) {
  const userPermissions = useAuthStore((state) => state.user?.permissions ?? []);
  const required = Array.isArray(props.permissions)
    ? props.permissions
    : [props.permissions];

  const allowed = required.some((item) => userPermissions.includes(item));

  return allowed ? <>{props.children}</> : <>{props.fallback ?? null}</>;
}

使用:

tsx
<Can permissions="user:delete">
  <button>删除用户</button>
</Can>

9. Nuxt 项目实现 ​

9.1 Nuxt 权限特点 ​

Nuxt 有两类权限点:

  • 客户端路由跳转权限。
  • 服务端渲染和 server/api 权限。

如果页面 SSR,权限不能只在客户端判断,否则可能先渲染出不该展示的内容。

9.2 route middleware ​

app/middleware/auth.ts:

ts
export default defineNuxtRouteMiddleware((to) => {
  const token = useCookie("token");

  if (to.meta.public) {
    return;
  }

  if (!token.value) {
    return navigateTo({
      path: "/login",
      query: { redirect: to.fullPath },
    });
  }
});

页面声明:

vue
<script setup lang="ts">
definePageMeta({
  middleware: ["auth"],
  permissions: ["user:read"],
});
</script>

9.3 权限 middleware ​

app/middleware/permission.ts:

ts
export default defineNuxtRouteMiddleware(async (to) => {
  const user = useState<CurrentUser | null>("current-user");

  if (!user.value) {
    user.value = await $fetch("/api/me");
  }

  const required = to.meta.permissions as string[] | undefined;

  if (required?.length) {
    const allowed = required.some((item) =>
      user.value?.permissions.includes(item)
    );

    if (!allowed) {
      return navigateTo("/403");
    }
  }
});

页面:

vue
<script setup lang="ts">
definePageMeta({
  middleware: ["auth", "permission"],
  permissions: ["finance:read"],
});
</script>

9.4 server/api 权限 ​

server/utils/auth.ts:

ts
export async function requirePermission(event: H3Event, permission: string) {
  const token = getCookie(event, "token");

  if (!token) {
    throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
  }

  const user = await verifyTokenAndLoadUser(token);

  if (!user.permissions.includes(permission)) {
    throw createError({ statusCode: 403, statusMessage: "Forbidden" });
  }

  return user;
}

server/api/users.get.ts:

ts
export default defineEventHandler(async (event) => {
  await requirePermission(event, "user:read");
  return getUsers();
});

9.5 Nuxt 动态菜单 ​

ts
export const useMenu = () => {
  const user = useState<CurrentUser | null>("current-user");

  return computed(() =>
    allMenus.filter((item) => {
      if (!item.permission) return true;
      return user.value?.permissions.includes(item.permission);
    })
  );
};

10. 小程序项目实现 ​

10.1 小程序权限特点 ​

小程序没有浏览器路由,也没有 Vue Router / React Router 这种前端路由库。它的页面由 app.json / pages.json 声明,页面跳转通过平台 API:

  • wx.navigateTo
  • wx.redirectTo
  • wx.switchTab
  • wx.reLaunch
  • wx.navigateBack

权限控制重点是:

  • 进入页面前检查登录态。
  • 页面 onLoad 时检查权限。
  • 菜单/功能入口根据权限渲染。
  • 调接口时后端校验权限。

10.2 页面权限配置 ​

permissions.ts:

ts
export const pagePermissions: Record<string, string[]> = {
  "/pages/user/index": ["user:read"],
  "/pages/order/index": ["order:read"],
  "/pages/finance/index": ["finance:read"],
};

10.3 封装安全跳转 ​

ts
import { pagePermissions } from "./permissions";

export function canAccessPage(path: string, permissions: string[]) {
  const required = pagePermissions[path];
  if (!required || required.length === 0) return true;
  return required.some((item) => permissions.includes(item));
}

export function navigateWithPermission(path: string) {
  const token = wx.getStorageSync("token");
  const permissions = wx.getStorageSync("permissions") || [];

  if (!token) {
    wx.redirectTo({ url: `/pages/login/index?redirect=${encodeURIComponent(path)}` });
    return;
  }

  if (!canAccessPage(path, permissions)) {
    wx.navigateTo({ url: "/pages/403/index" });
    return;
  }

  wx.navigateTo({ url: path });
}

10.4 页面 onLoad 校验 ​

ts
Page({
  onLoad() {
    const route = `/${this.route}`;
    const permissions = wx.getStorageSync("permissions") || [];

    if (!canAccessPage(route, permissions)) {
      wx.redirectTo({ url: "/pages/403/index" });
    }
  },
});

10.5 小程序菜单权限 ​

ts
const allMenus = [
  { title: "用户管理", path: "/pages/user/index", permission: "user:read" },
  { title: "订单管理", path: "/pages/order/index", permission: "order:read" },
];

function buildMenus(permissions: string[]) {
  return allMenus.filter((item) => permissions.includes(item.permission));
}

注意:

  • 小程序 tabBar 是静态配置,不能完全动态增删。
  • 如果 tabBar 页面要权限控制,可以进入后做校验并展示无权限页。
  • 普通功能菜单可以动态渲染。

11. NestJS 后端权限实现 ​

11.1 为什么用 Guard ​

NestJS 中 Guard 用来决定请求是否可以继续执行。权限、角色、登录态都适合用 Guard。

11.2 权限装饰器 ​

ts
import { SetMetadata } from "@nestjs/common";

export const PERMISSIONS_KEY = "permissions";

export const RequirePermissions = (...permissions: string[]) =>
  SetMetadata(PERMISSIONS_KEY, permissions);

11.3 Guard ​

ts
import {
  CanActivate,
  ExecutionContext,
  ForbiddenException,
  Injectable,
  UnauthorizedException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PERMISSIONS_KEY } from "./permissions.decorator";

@Injectable()
export class PermissionsGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext) {
    const required = this.reflector.getAllAndOverride<string[]>(
      PERMISSIONS_KEY,
      [context.getHandler(), context.getClass()]
    );

    if (!required || required.length === 0) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user) {
      throw new UnauthorizedException();
    }

    const allowed = required.some((permission) =>
      user.permissions.includes(permission)
    );

    if (!allowed) {
      throw new ForbiddenException();
    }

    return true;
  }
}

11.4 Controller 使用 ​

ts
@Controller("users")
@UseGuards(AuthGuard, PermissionsGuard)
export class UsersController {
  @Get()
  @RequirePermissions("user:read")
  listUsers() {
    return this.usersService.findAll();
  }

  @Delete(":id")
  @RequirePermissions("user:delete")
  deleteUser(@Param("id") id: string) {
    return this.usersService.delete(id);
  }
}

11.5 数据权限 ​

接口权限只判断能否调用接口,数据权限判断能看到哪些数据。

ts
async findOrders(user: CurrentUser) {
  if (user.permissions.includes("order:read:all")) {
    return this.orderRepository.find();
  }

  return this.orderRepository.find({
    where: {
      regionId: In(user.regionIds),
    },
  });
}

11.6 Guard 的执行时序与元数据作用域 ​

NestJS 官方请求生命周期:request 依次流过 middleware → guards → interceptors(前置)→ pipes → handler → interceptors(后置)→ filters。与权限直接相关的三条规则:

  • Guard 在所有 middleware 之后、任何 interceptor 和 pipe 之前执行,顺序为 global → controller → route 级。所以 AuthGuard 挂在 controller、PermissionsGuard 挂在方法上时,前者必然先跑,request.user 已就绪。
  • Guard 返回 false 时框架抛 ForbiddenException(403);想区分“未登录 401 / 无权限 403”要在 guard 内主动抛 UnauthorizedException/ForbiddenException,而不是 return false——这就是 11.3 的 Guard 里抛异常而非返回布尔值的原因。
  • reflector.getAllAndOverride(PERMISSIONS_KEY, [handler, class]) 的语义是“方法级元数据覆盖类级”;若想合并(类上声明 user:read、方法上追加 user:update),要用 getAll() 拿数组自行合并。方法级“覆盖”类级是最容易写出越权漏洞的地方:给类加 @RequirePermissions("admin") 后,某个方法又写了 @RequirePermissions("user:read"),实际生效的只有方法上的那条。

💡 免鉴权白名单用 SetMetadata(IS_PUBLIC_KEY, true) + 全局 Guard 检查,不要用“少挂一个 Guard”表达公开——全局 Guard(APP_GUARD)能兜住“忘挂权限”的新接口,逐 controller 手挂必然漏。


12. Express 后端权限实现 ​

12.1 鉴权 middleware ​

ts
export function authMiddleware(req, res, next) {
  const authorization = req.headers.authorization;
  const token = authorization?.replace("Bearer ", "");

  if (!token) {
    res.status(401).json({ message: "Unauthorized" });
    return;
  }

  try {
    req.user = verifyToken(token);
    next();
  } catch {
    res.status(401).json({ message: "Unauthorized" });
  }
}

12.2 权限 middleware ​

ts
export function requirePermissions(...required) {
  return (req, res, next) => {
    const user = req.user;

    if (!user) {
      res.status(401).json({ message: "Unauthorized" });
      return;
    }

    const allowed = required.some((permission) =>
      user.permissions.includes(permission)
    );

    if (!allowed) {
      res.status(403).json({ message: "Forbidden" });
      return;
    }

    next();
  };
}

12.3 路由使用 ​

ts
router.get(
  "/users",
  authMiddleware,
  requirePermissions("user:read"),
  async (req, res) => {
    const users = await userService.findAll();
    res.json(users);
  }
);

router.delete(
  "/users/:id",
  authMiddleware,
  requirePermissions("user:delete"),
  async (req, res) => {
    await userService.delete(req.params.id);
    res.status(204).end();
  }
);

12.4 Express 注意点 ​

  • middleware 顺序很重要,权限 middleware 必须在鉴权 middleware 后面。
  • 参数校验和权限校验是两件事。
  • 数据权限不能只靠 middleware,通常要进入 service/repository 层过滤。
  • 错误返回要统一格式。

13. 页面渲染、按钮权限和组件权限 ​

13.1 隐藏还是禁用 ​

隐藏:

  • 用户完全不应该知道这个功能。
  • 权限不足时不显示入口。

禁用:

  • 用户知道功能存在,但当前条件不满足。
  • 例如订单已关闭,不能编辑。

例子:

tsx
<Can permissions="order:delete">
  <button disabled={order.status === "paid"}>删除订单</button>
</Can>

这里:

  • order:delete 是权限问题。
  • order.status === "paid" 是业务状态问题。

不要混在一起。

13.2 权限 hooks ​

React:

ts
export function usePermission() {
  const permissions = useAuthStore((state) => state.user?.permissions ?? []);

  return {
    has: (permission: string) => permissions.includes(permission),
    hasAny: (items: string[]) => items.some((item) => permissions.includes(item)),
    hasAll: (items: string[]) => items.every((item) => permissions.includes(item)),
  };
}

Vue:

ts
export function usePermission() {
  const auth = useAuthStore();

  return {
    has: (permission: string) =>
      auth.user?.permissions.includes(permission) ?? false,
  };
}

13.3 表格操作列 ​

ts
const actions = [
  {
    label: "编辑",
    permission: "user:update",
    onClick: editUser,
  },
  {
    label: "删除",
    permission: "user:delete",
    danger: true,
    onClick: deleteUser,
  },
];

const visibleActions = actions.filter((action) =>
  hasPermission(action.permission)
);

14. 接口权限、菜单权限、数据权限怎么配合 ​

14.1 菜单权限 ​

决定“看不看得到入口”。

例子:

json
{
  "title": "用户管理",
  "path": "/users",
  "permission": "user:read"
}

14.2 页面权限 ​

决定“能不能进入页面”。

例子:

ts
meta: {
  permissions: ["user:read"]
}

14.3 接口权限 ​

决定“能不能执行操作”。

例子:

ts
@RequirePermissions("user:delete")
@Delete(":id")
deleteUser() {}

14.4 数据权限 ​

决定“能看到哪些数据”。

例子:

sql
where department_id in (:userDepartmentIds)

数据权限一定在后端做。前端最多展示筛选条件或提示。


15. 多租户和组织权限 ​

15.1 多租户 ​

多租户系统中,同一个用户可能属于多个租户:

ts
interface CurrentUser {
  id: string;
  tenantId: string;
  availableTenants: Tenant[];
  permissions: string[];
}

切换租户时:

  • 清空旧租户缓存。
  • 重新加载权限。
  • 重新生成菜单。
  • 重新拉取页面数据。
  • token 或请求 header 携带 tenant context。

15.2 组织数据权限 ​

常见范围:

  • 本人。
  • 本部门。
  • 本部门及子部门。
  • 指定部门。
  • 全部。

后端根据用户的数据范围拼接查询条件,前端不应该自己过滤敏感数据。


16. 常见问题与反模式 ​

16.1 只做前端权限 ​

这是安全漏洞。任何接口都必须后端校验。

16.2 把角色写死在页面 ​

不推荐:

ts
if (user.role === "admin") {
  showDeleteButton();
}

推荐:

ts
if (hasPermission("user:delete")) {
  showDeleteButton();
}

角色可以变化,权限码更稳定。

16.3 菜单、路由、权限三套数据互相漂移 ​

要么同源生成,要么建立清晰映射和测试。

16.4 退出登录不重置路由 ​

动态路由要在退出时清理,否则切换用户会出现权限污染。

16.5 权限码过细或过粗 ​

过粗:

text
admin

过细:

text
user:list:table:row:button:update:visible

合理:

text
user:read
user:update
user:delete

16.6 角色爆炸(Role Explosion):粒度设计的主要陷阱 ​

RBAC 最典型的失败模式:每个例外都新建一个角色——项目A管理员、西区只读专员、临时审计角色——最终角色数超过用户数,“谁能干什么”没人答得上来。根因几乎总是同一个:把上下文属性编码进了角色名。区域、租户、部门、时间这些是“条件/范围”,不是“岗位能力”。

三条对策(与 3.5/3.6 的模型分层一致):

  • 角色名只描述岗位能力(运营专员),区域/租户作为数据范围由后端 ABAC 条件过滤(order:read + region in (...)),不产生新角色。
  • 单资源级共享用 ACL(见 3.3),不要为“这一个对象”造角色。
  • 定期审计三个漂移信号:没有任何用户持有的角色、没有任何角色使用的权限码、“没人记得为什么存在”的授权——它们是权限体系腐化的体温计。

16.7 some 语义与权限判定的隐含约定 ​

本文所有过滤实现都用 required.some((p) => permissions.includes(p))——多码之间是 OR。这个语义要写进团队规范并与后端对齐:一个路由/接口若同时声明 ["order:read", "order:read:all"],OR 表示“任一即可”,AND 表示“必须同时”。后端 PermissionsGuard 若哪天被改成 AND 而前端还是 OR,会出现“前端放行的页面,接口 403”。复杂判定(如 order:read && !order:export:all)不要塞进权限码数组表达,改用命名好的组合权限码(order:export:all 蕴含 order:export)或后端策略接口。

16.8 一次性指令与异步权限 ​

v-permission 这类指令在 inserted 钩子里读一次 store 就决定移除元素——它是快照判定,不是响应式判定:

  • 权限数据晚于渲染到达(首屏竞态)时,元素可能“先被删掉、权限到了也不会回来”。
  • 权限在线收窄时,已渲染按钮不会消失。

对策:指令内部配合 v-if + 响应式判断,或在 updated 钩子里重新评估;更稳的做法是像 7.7/8.6 那样让权限进入模板求值(v-if="hasPermission(...)"、<Can>),天然响应。指令删除 DOM 的写法只应在“权限必然在路由渲染前就绪”的前提下使用。


17. 测试清单 ​

17.1 登录态 ​

  • 未登录访问受保护页面跳转登录。
  • 登录后跳回原页面。
  • token 过期跳转登录或刷新 token。
  • 退出登录后动态路由被清理。

17.2 页面权限 ​

  • 有权限能进入页面。
  • 无权限进入 403。
  • 手动输入 URL 也会拦截。
  • 刷新页面后权限仍生效。

17.3 菜单权限 ​

  • 菜单只显示有权限项。
  • 子菜单全部无权限时父菜单隐藏。
  • 切换用户后菜单刷新。

17.4 按钮权限 ​

  • 无权限按钮不显示或禁用。
  • 直接调接口被后端拒绝。
  • 权限变更后页面刷新正确。

17.5 数据权限 ​

  • 用户只能看到授权范围数据。
  • 修改 query 参数不能越权。
  • 导出接口也做数据权限。

18. 官方参考资料 ​

基于 VitePress 构建的个人学习文档知识库