RBAC 权限系统实战(一):页面级访问控制全解析
前言
本篇文章主要讲解 RBAC 权限方案在中后台管理系统的实现
在公司内部写过好几个后台系统,都需要实现权限控制,在职时工作繁多,没有系统性的来总结一下相关经验,现在人已离职,就把自己的经验总结一下,希望能帮助到你
本文是《通俗易懂的中后台系统建设指南》系列的第九篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统
权限模型有哪些?
主流的权限模型主要分为以下五种:
- ACL模型:访问控制列表
- DAC模型:自主访问控制
- MAC模型:强制访问控制
- ABAC模型:基于属性的访问控制
- RBAC模型:基于角色的权限访问控制
这里不介绍全部的权限模型,有兴趣你可以看看这篇文章:权限系统就该这么设计,yyds
如果你看过、用过市面上一些开源后台系统及权限设计,你会发现它们主要都是基于 RBAC 模型来实现的
为什么是 RBAC 权限模型?
好问题!我帮你问了下 AI
| 对比维度 | ACL (访问控制列表) | RBAC (基于角色) | ABAC (基于属性) |
|---|---|---|---|
| 核心逻辑 |
用户 ↔ 权限 直接点对点绑定,无中间层 |
用户 ↔ 角色 ↔ 权限 引入“角色”解耦,权限归于角色 |
属性 + 规则 = 权限 动态计算 (Who, When, Where) |
| 优点 | 模型极简,开发速度快,适合初期 MVP | 结构清晰,复用性高,符合企业组织架构,维护成本低 | 极度灵活,支持细粒度控制 (如:只能在工作日访问) |
| 缺点 | 用户量大时维护工作呈指数级增长,极易出错 | 角色爆炸:若特例过多,可能导致定义成百上千个角色 | 开发复杂度极高,规则引擎难设计,有一定的性能消耗 |
| 适用场景 | 个人博客、小型内部工具 | 中大型后台系统、SaaS 平台 (行业标准) | 银行风控、AWS IAM、国家安全级系统 |
总结来说,在后台系统的场景下,RBAC 模型在灵活性(对比ACL)和复杂性(对比ABAC)上取得了一个很好的平衡
RBAC 概念理解
RBAC 权限模型,全称 Role-Based Access Control,基于角色的权限访问控制
模型有三要素:
- 用户(User):系统主体,即操作系统的具体人员或账号
- 角色(Role):角色是一组权限的集合,代表了用户在组织中的职能或身份
- 权限(Permission):用户可以对系统资源进行的访问或操作能力
RBAC 的设计是将角色绑定权限,用户绑定角色,从而实现权限控制
![]()
并且,它们之间的逻辑关系通常是多对多的:
用户 - 角色 (User-Role): 一个用户可以拥有多个角色(例如:某人既是“项目经理”又是“技术委员会成员”)
角色 - 权限(Role-Permission): 一个角色包含多个权限(例如:“人事经理”角色拥有“查看员工”、“编辑薪资”等权限)
主导权限控制的前端、后端方案
市面上这些开源 Admin 的权限控制中,存在两种主要的权限主导方案:前端主导的权限方案和后端主导的权限方案
前端主导的权限方案
前端主导的权限方案,一个主要的特征是菜单数据由前端维护,而不是存在数据库中
后端只需要在登录后给到用户信息,这个信息中会包含用户的角色,根据这个角色信息,前端可以筛选出具有权限的菜单、按钮
这种方案的主要逻辑放在前端,而不是后端数据库,所以安全性没保障,灵活性也较差,要更新权限,就需要改动前端代码并重新打包上线,无法支持“动态配置权限”
适合一些小型、简单系统
后端主导的权限方案
后端控制方案,即登录后在返回用户信息时,还会给到此用户对应的菜单数据和按钮权限码等
菜单数据、按钮权限码等都存在数据库,这样一来,安全性、灵活性更高,要更新权限数据或用户权限控制,提供相应接口即可修改
倒也不是说前端完全不用管菜单数据,而是前端只需要维护一些静态菜单数据,比如登录页、异常页(404、403...)
在企业级后台系统中,后端主导的权限方案是比较常用的,本文只介绍后端主导的权限方案
权限方案整体流程
在开始写代码之前,要清晰知道整体实现流程,我画了一张图来直观展示:
![]()
后台系统中的 RBAC 权限实战
权限菜单类型定义
首先,在前后端人员配合中,我们最好约定一套菜单数据的结构,比如:
import type { RouteMeta, RouteRecordRaw, RouteRecordRedirectOption } from 'vue-router';
import type { Component } from 'vue';
import type { DefineComponent } from 'vue';
import type { RouteType } from '#/type';
declare global {
export interface CustomRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {
/**
* 路由地址
*/
path?: string;
/**
* 路由名称
*/
name?: string;
/**
* 重定向路径
*/
redirect?: RouteRecordRedirectOption;
/**
* 组件
*/
component?: Component | DefineComponent | (() => Promise<unknown>);
/**
* 子路由信息
*/
children?: CustomRouteRecordRaw[];
/**
* 路由类型
*/
type?: RouteType;
/**
* 元信息
*/
meta: {
/**
* 菜单标题
*/
title: string;
/**
* 菜单图标
*/
menuIcon?: string;
/**
* 排序
*/
sort?: number;
/**
* 是否在侧边栏菜单中隐藏
* @default false
*/
hideMenu?: boolean;
/**
* 是否在面包屑中隐藏
* @default false
*/
hideBreadcrumb?: boolean;
/**
* 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
* @default false
*/
hideParentIfSingleChild?: boolean;
};
}
/**
* 后端返回的权限路由类型定义
*/
export type PermissionRoute = Omit<CustomRouteRecordRaw, 'component' | 'children' | 'type'> & {
/**
* 路由ID
*/
id?: number;
/**
* 路由父ID
*/
parentId?: number;
/**
* 组件路径(后端返回时为字符串,前端处理后为组件)
*/
component: string;
/**
* 子路由信息
*/
children?: PermissionRoute[];
/**
* 路由类型
*/
type: RouteType;
};
}
在 router.d.ts 找到类型文件
以上面的类型定义为例,我们约定 PermissionRoute 类型是后端返回的权限路由类型:
我这里使用 ApiFox 来 Mock 权限路由数据,数据是这样的:
![]()
从登录页到路由守卫
权限方案的第一步,是登录并拿到用户信息
假设我们现在用 Element Plus 搭建起了一个登录页面,当用户点击登录时,我们需要做这几件事:
- 调用登录接口,将账号、密码发送给后端进行验证,验证通过则返回 JWT 信息
- 将返回的 JWT 信息保存到本地,后续每次请求都携带 Token 来识别用户身份并决定你能拿到的权限路由数据
- 触发路由守卫拦截
![]()
在 account-login.vue 找到全部代码
基本 Vue Router 配置
登录完成后,我们就可以触发路由守卫了,但在写路由守卫之前,我们先来配置一下基本的 Vue Router
在整个权限系统中,我们将路由数据分为两种:
- 静态路由:系统固定的路由,比如登录页、异常页(404、403...)
- 动态路由:由后端接口返回的用户角色对应的菜单路由数据
静态路由是直接由前端定义,不会从后端接口返回、不会根据用户角色动态变化,所以这部分路由我们直接写好然后注册到 Vue Router 中即可
Vue Router 配置:
import { createRouter, createWebHashHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import type { App } from 'vue';
import type { ImportGlobRoutes } from './typing';
import { extractRoutes } from './helpers';
import { afterEachGuard, beforeEachGuard } from './guards';
/** 静态路由 */
const staticRoutes = extractRoutes(
import.meta.glob<ImportGlobRoutes>(['./modules/constant-routes/**/*.ts'], {
eager: true,
}),
);
/** 系统路由 */
const systemRoutes = extractRoutes(
import.meta.glob<ImportGlobRoutes>(['./modules/system-routes/**/*.ts'], {
eager: true,
}),
);
const router = createRouter({
history: createWebHashHistory(),
routes: [...staticRoutes, ...systemRoutes] as RouteRecordRaw[],
strict: true,
scrollBehavior: () => ({ left: 0, top: 0 }),
});
beforeEachGuard(router);
afterEachGuard(router);
/** 初始化路由 */
function initRouter(app: App<Element>) {
app.use(router);
}
export { router, initRouter, staticRoutes };
图中的静态路由和系统路由是同一类路由数据,即静态路由
这个配置文件可以在 router/index.ts 找到
这个基本的 Vue Router 配置,做了这么几件事:
- 导入
modules文件夹下的静态路由进行注册 - 路由初始化配置
initRouter,在main.ts中调用 - 注册全局前置守卫
beforeEach、全局后置守卫afterEach
我们实现动态路由注册的逻辑就写在 beforeEach 中
值得一提的是,使用了 import.meta.glob 来动态导入指定路径下的文件模块,这是 Vite 提供的一种导入方式,参考:Vite Glob 导入
路由守卫与动态注册
路由守卫是 Vue Router 提供的一种机制,主要用来通过跳转或取消的方式守卫导航:Vue Router 路由守卫
重头戏在全局前置守卫 router.beforeEach 中实现,来看看我们做哪些事:
import { ROUTE_NAMES } from '../config';
import type { RouteRecordNameGeneric, RouteRecordRaw, Router } from 'vue-router';
import { getLocalAccessToken } from '@/utils/permission';
import { userService } from '@/services/api';
import { nprogress } from './helpers';
import { storeToRefs } from 'pinia';
/** 登录认证页面:账号登录页、短信登录页、二维码登录页、忘记密码页、注册页... */
const authPages: RouteRecordNameGeneric[] = [
ROUTE_NAMES.AUTH,
ROUTE_NAMES.ACCOUNT_LOGIN,
ROUTE_NAMES.SMS_LOGIN,
ROUTE_NAMES.QR_LOGIN,
ROUTE_NAMES.FORGOT_PASSWORD,
ROUTE_NAMES.REGISTER,
];
/** 页面白名单:不需要登录也能访问的页面 */
const pageWhiteList: RouteRecordNameGeneric[] = [...authPages];
export function beforeEachGuard(router: Router) {
router.beforeEach(async (to) => {
/** 进度条:开始 */
nprogress.start();
const { name: RouteName } = to;
const userStore = useUserStore();
const { getAccessToken, getRoutesAddStatus, registerRoutes } = storeToRefs(userStore);
const { setRoutesAddStatus, setUserInfo, logout } = userStore;
/** 访问令牌 */
const accessToken = getAccessToken.value || getLocalAccessToken();
// 1.用户未登录(无 Token)
if (!accessToken) {
const isWhitePage = pageWhiteList.includes(RouteName);
// 1.1 未登录,如果访问的是白名单中的页面,直接放行
if (isWhitePage) return true;
nprogress.done();
// 1.2 未登录又不在白名单,则拦截并重定向到登录页
return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
}
// 如果已登录用户试图访问登录页,避免重复登录,要强制重定向到首页
if (authPages.includes(RouteName)) {
nprogress.done();
return { name: ROUTE_NAMES.ROOT };
}
// 判断是否需要动态加载路由的操作
if (!getRoutesAddStatus.value) {
// isRoutesAdded 默认为 false(未持久化),在已经动态注册过时会设置为true,在页面刷新时会重置为 false
try {
// 1.拉取用户信息
const userInfo = await userService.getUserInfo();
// 2.将用户信息存入 Store
setUserInfo(userInfo);
// 3.动态注册路由,registerRoutes 是处理后的路由表
registerRoutes.value.forEach((route) => {
router.addRoute(route as unknown as RouteRecordRaw);
});
// 4.标记路由已添加
setRoutesAddStatus(true);
// 5.中断当前导航,重新进入守卫
return { ...to, replace: true };
} catch (error) {
// 获取用户信息失败(如 Token 过期失效、网络异常)
logout();
nprogress.done();
// 重定向回登录页,让用户重新登录
return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
}
}
return true;
});
}
在 before-each-guard.ts 找到全部代码
上面的代码已经给出了很详细的注释,从整体角度来讲,我们做了两件事:
- 处理一些情况,比如用户未登录、登录后访问登录页、白名单等情况
- 拉取用户信息,动态注册路由
![]()
在路由守卫中“拉取用户信息”,一般来说,除了返回用户本身的信息外,还会给到权限路由信息、权限码信息,这里的数据结构可以跟后端进行约定
![]()
比如在 vue-clean-admin 中,返回的数据结构是这样的:
在 ApiFox 文档可以找到用户接口说明:ApiFox 文档 - 用户信息
![]()
后端路由结构的转化
在通过“拉取用户信息”拿到路由数据后,并不是直接注册到 Vue Router,而是需要进行处理转化,才能符合 Vue Router 定义的路由表结构,registerRoutes 就是处理后的路由表,处理后的类型定义可以参考 CustomRouteRecordRaw
处理什么内容呢?
比如,接口拿到的路由数据字段 component 是一个字符串路径,这是一个映射路径,映射到前端项目下的真实组件路径
![]()
实现路由结构转换的代码,我写在了 router/helpers.ts,最主要逻辑是 generateRoutes 函数:
/**
* 生成符合 Vue Router 定义的路由表
* @param routes 未转化的路由数据
* @returns 符合结构的路由表
*/
export function generateRoutes(routes: PermissionRoute[]): CustomRouteRecordRaw[] {
if (!routes.length) return [];
return routes.map((route) => {
const { path, name, redirect, type, meta } = route;
const baseRoute: Omit<CustomRouteRecordRaw, 'children'> = {
path,
name,
redirect,
type,
component: loadComponent(route),
meta: {
...meta,
// 是否在侧边栏菜单中隐藏
hideMenu: route.meta?.hideMenu || false,
// 是否在面包屑中隐藏
hideBreadcrumb: route.meta?.hideBreadcrumb || false,
// 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
hideParentIfSingleChild: route.meta?.hideParentIfSingleChild || false,
},
};
// 是目录数据,设置重定向路径
if (type === PermissionRouteTypeEnum.DIR) {
baseRoute.redirect = redirect || getRedirectPath(route);
}
// 递归处理子路由
const processedChildren =
route.children && route.children.length ? generateRoutes(route.children) : undefined;
return {
...baseRoute,
...(processedChildren ? { children: processedChildren } : {}),
};
});
}
经过 generateRoutes 处理的路由表,再 addRoute 到 Vue Router 中
侧边栏菜单的渲染
当路由守卫的逻辑走完后,就进入到首页,在首页中,我们会根据路由表(转换过的)来渲染侧边栏菜单
侧边栏菜单是拿 Element Plus 的 el-menu 组件来做的,我们封装了一个菜单组件,除了渲染路由数据外,也更方便自定义配置菜单属性(meta)来实现一些功能
封装不难,就是拿处理后的路由表循环渲染 menu-item,根据 meta 配置项来实现"是否隐藏菜单","当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容"等
![]()
菜单组件的封装代码在 basic-menu 文件夹中
到这一步,已经实现了动态权限路由及侧边栏菜单的渲染,但还不算完
因为我们还不能自由定义菜单信息、角色信息、用户信息来实现权限控制,在下一篇文章来聊聊管理模块
了解更多
系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏
实战项目:vue-clean-admin
交流讨论
文章如有错误或需要改进之处,欢迎指正