外观
用户权限、动态路由与页面权限控制项目实战文档
适合人群:希望在 Vue2、Vue3、React、Nuxt、小程序、Node/Nest/Express 项目中真正落地登录鉴权、RBAC、动态菜单、动态路由、页面访问控制、按钮权限和接口权限的前端/全栈开发者。
学习目标:理解“前端权限”和“后端权限”的边界,能设计权限模型、路由模型、菜单模型,并在不同框架中实现可维护、可测试、可扩展的权限系统。
核对时间:2026-06-30。本文参考 Vue Router、React Router、Nuxt、NestJS、Express、微信小程序路由等官方资料,并结合企业后台、SaaS、多租户系统常见实践整理。
目录
- 权限系统到底解决什么问题
- 前端权限和后端权限的边界
- 常见权限模型:RBAC、ABAC、ACL、Feature Flag
- 权限系统的核心数据结构
- 动态路由和动态菜单的关系
- Vue2 项目实现
- Vue3 项目实现
- React 项目实现
- Nuxt 项目实现
- 小程序项目实现
- NestJS 后端权限实现
- Express 后端权限实现
- 页面渲染、按钮权限和组件权限
- 接口权限、菜单权限、数据权限怎么配合
- 多租户和组织权限
- 常见问题与反模式
- 测试清单
- 官方参考资料
1. 权限系统到底解决什么问题
权限系统不是只做“登录后才能访问页面”。
真实项目中通常要解决五类问题:
- 用户是否已经登录。
- 用户能不能进入某个页面。
- 用户能不能看到某个菜单。
- 用户能不能点击某个按钮或使用某个功能。
- 用户能不能访问某个接口或某条数据。
这些问题看起来都叫权限,但控制位置不同:
- 登录态:前端和后端都要判断。
- 页面权限:前端路由守卫负责体验,后端仍要保护接口。
- 菜单权限:前端负责渲染。
- 按钮权限:前端负责隐藏/禁用,后端负责接口权限。
- 数据权限:必须由后端控制。
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 的参考模型把它分成四级,逐级叠加:
| 级别 | 名称 | 增加的能力 | 典型判据 |
|---|---|---|---|
| 1 | Core RBAC | 用户 → 角色 → 权限 | 权限只能通过角色授予,不给用户直挂权限 |
| 2 | Hierarchical RBAC | 角色继承 | 主管 ⊇ 员工,高级角色自动拥有低级角色权限 |
| 3 | Constrained RBAC | 职责分离(SoD) | “审批人”与“申请人”不能是同一人;互斥角色约束 |
| 4 | Symmetric 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 codesql
-- 最小可用模型:复用已有 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 动态路由
动态路由是登录后根据权限添加或过滤的路由。
常见做法:
- 前端维护完整路由表,根据权限过滤。
- 后端返回菜单/路由配置,前端映射组件。
- 微前端由主应用根据权限加载子应用路由。
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
Axios6.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
Vite7.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 Query8.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()。更常见做法是:
- 维护完整 route config。
- 登录后获取用户权限。
- 根据权限过滤菜单。
- 页面访问时通过
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.navigateTowx.redirectTowx.switchTabwx.reLaunchwx.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:delete16.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. 官方参考资料
- Vue Router 导航守卫:https://router.vuejs.org/guide/advanced/navigation-guards.html
- Vue Router Route Meta:https://router.vuejs.org/guide/advanced/meta.html
- Vue Router 动态路由:https://router.vuejs.org/guide/advanced/dynamic-routing.html
- Vue Router 3 文档:https://v3.router.vuejs.org/
- React Router 官方文档:https://reactrouter.com/
- Nuxt Route Middleware:https://nuxt.com/docs/guide/directory-structure/middleware
- Nuxt definePageMeta:https://nuxt.com/docs/api/utils/define-page-meta
- NestJS Guards:https://docs.nestjs.com/guards
- NestJS Authorization:https://docs.nestjs.com/security/authorization
- Express middleware:https://expressjs.com/en/guide/using-middleware.html
- NestJS 请求生命周期:https://docs.nestjs.com/faq/request-lifecycle
- NIST Role-Based Access Control(Sandhu/Ferraiolo/Kuhn):https://csrc.nist.gov/publications/detail/white-paper/2020/06/01/the-nist-model-for-role-based-access-control-towards-a-unified-standard/final
- OWASP Unvalidated Redirects and Forwards:https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html
- 微信小程序页面路由:https://developers.weixin.qq.com/miniprogram/dev/framework/app-service/route.html
- 微信小程序路由 API:https://developers.weixin.qq.com/miniprogram/dev/api/route/wx.navigateTo.html