RBAC 权限系统实战(一):页面级访问控制全解析
本篇文章主要讲解 RBAC 权限方案在中后台管理系统的实现 在公司内部写过好几个后台系统,都需要实现权限控制,在职时工作繁多,没有系统性的来总结一下相关经验,现在人已离职,就把自己的经验总结一下,希望能帮助到你 主流的权限模型主要分为以下五种: 这里不介绍全部的权限模型,有兴趣你可以看看这篇文章:权限系统就该这么设计,yyds 如果你看过、用过市面上一些开源后台系统及权限设计,你会发现它们主要都是基于 RBAC 模型来实现的 好问题!我帮你问了下 AI 总结来说,在后台系统的场景下,RBAC 模型在灵活性(对比ACL)和复杂性(对比ABAC)上取得了一个很好的平衡 RBAC 权限模型,全称 Role-Based Access Control,基于角色的权限访问控制 模型有三要素: RBAC 的设计是将角色绑定权限,用户绑定角色,从而实现权限控制 并且,它们之间的逻辑关系通常是多对多的: 用户 - 角色 (User-Role): 一个用户可以拥有多个角色(例如:某人既是“项目经理”又是“技术委员会成员”) 角色 - 权限(Role-Permission): 一个角色包含多个权限(例如:“人事经理”角色拥有“查看员工”、“编辑薪资”等权限) 市面上这些开源 Admin 的权限控制中,存在两种主要的权限主导方案:前端主导的权限方案和后端主导的权限方案 前端主导的权限方案,一个主要的特征是菜单数据由前端维护,而不是存在数据库中 后端只需要在登录后给到用户信息,这个信息中会包含用户的角色,根据这个角色信息,前端可以筛选出具有权限的菜单、按钮 这种方案的主要逻辑放在前端,而不是后端数据库,所以安全性没保障,灵活性也较差,要更新权限,就需要改动前端代码并重新打包上线,无法支持“动态配置权限” 适合一些小型、简单系统 后端控制方案,即登录后在返回用户信息时,还会给到此用户对应的菜单数据和按钮权限码等 菜单数据、按钮权限码等都存在数据库,这样一来,安全性、灵活性更高,要更新权限数据或用户权限控制,提供相应接口即可修改 在企业级后台系统中,后端主导的权限方案是比较常用的,本文只介绍后端主导的权限方案 在开始写代码之前,要清晰知道整体实现流程,我画了一张图来直观展示: 首先,在前后端人员配合中,我们最好约定一套菜单数据的结构,比如: 以上面的类型定义为例,我们约定 我这里使用 ApiFox 来 Mock 权限路由数据,数据是这样的: 权限方案的第一步,是登录并拿到用户信息 假设我们现在用 Element Plus 搭建起了一个登录页面,当用户点击登录时,我们需要做这几件事: 登录完成后,我们就可以触发路由守卫了,但在写路由守卫之前,我们先来配置一下基本的 Vue Router 在整个权限系统中,我们将路由数据分为两种: 静态路由是直接由前端定义,不会从后端接口返回、不会根据用户角色动态变化,所以这部分路由我们直接写好然后注册到 Vue Router 中即可 Vue Router 配置: 这个配置文件可以在 router/index.ts 找到 这个基本的 Vue Router 配置,做了这么几件事: 我们实现动态路由注册的逻辑就写在 值得一提的是,使用了 路由守卫是 Vue Router 提供的一种机制,主要用来通过跳转或取消的方式守卫导航:Vue Router 路由守卫 重头戏在全局前置守卫 上面的代码已经给出了很详细的注释,从整体角度来讲,我们做了两件事: 在路由守卫中“拉取用户信息”,一般来说,除了返回用户本身的信息外,还会给到权限路由信息、权限码信息,这里的数据结构可以跟后端进行约定 比如在 vue-clean-admin 中,返回的数据结构是这样的: 在通过“拉取用户信息”拿到路由数据后,并不是直接注册到 Vue Router,而是需要进行处理转化,才能符合 Vue Router 定义的路由表结构, 处理什么内容呢? 比如,接口拿到的路由数据字段 实现路由结构转换的代码,我写在了 router/helpers.ts,最主要逻辑是 经过 当路由守卫的逻辑走完后,就进入到首页,在首页中,我们会根据路由表(转换过的)来渲染侧边栏菜单 侧边栏菜单是拿 Element Plus 的 封装不难,就是拿处理后的路由表循环渲染 菜单组件的封装代码在 basic-menu 文件夹中 到这一步,已经实现了动态权限路由及侧边栏菜单的渲染,但还不算完 因为我们还不能自由定义菜单信息、角色信息、用户信息来实现权限控制,在下一篇文章来聊聊管理模块 系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏 实战项目:vue-clean-admin 文章如有错误或需要改进之处,欢迎指正前言
本文是《通俗易懂的中后台系统建设指南》系列的第九篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统
权限模型有哪些?
为什么是 RBAC 权限模型?
对比维度 ACL (访问控制列表) RBAC (基于角色) ABAC (基于属性) 核心逻辑 用户 ↔ 权限
直接点对点绑定,无中间层用户 ↔ 角色 ↔ 权限
引入“角色”解耦,权限归于角色属性 + 规则 = 权限
动态计算 (Who, When, Where)优点 模型极简,开发速度快,适合初期 MVP 结构清晰,复用性高,符合企业组织架构,维护成本低 极度灵活,支持细粒度控制
(如:只能在工作日访问)缺点 用户量大时维护工作呈指数级增长,极易出错 角色爆炸:若特例过多,可能导致定义成百上千个角色 开发复杂度极高,规则引擎难设计,有一定的性能消耗 适用场景 个人博客、小型内部工具 中大型后台系统、SaaS 平台 (行业标准) 银行风控、AWS IAM、国家安全级系统 RBAC 概念理解

主导权限控制的前端、后端方案
前端主导的权限方案
后端主导的权限方案
倒也不是说前端完全不用管菜单数据,而是前端只需要维护一些静态菜单数据,比如登录页、异常页(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 类型是后端返回的权限路由类型:clean-admin ApiFox 文档在线地址

从登录页到路由守卫

在 account-login.vue 找到全部代码
基本 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 };
图中的静态路由和系统路由是同一类路由数据,即静态路由
modules 文件夹下的静态路由进行注册initRouter ,在 main.ts 中调用beforeEach、全局后置守卫 afterEachbeforeEach 中import.meta.glob 来动态导入指定路径下的文件模块,这是 Vite 提供的一种导入方式,参考:Vite Glob 导入路由守卫与动态注册
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 找到全部代码


在 ApiFox 文档可以找到用户接口说明:ApiFox 文档 - 用户信息

后端路由结构的转化
registerRoutes 就是处理后的路由表,处理后的类型定义可以参考 CustomRouteRecordRawcomponent 是一个字符串路径,这是一个映射路径,映射到前端项目下的真实组件路径
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 中侧边栏菜单的渲染
el-menu 组件来做的,我们封装了一个菜单组件,除了渲染路由数据外,也更方便自定义配置菜单属性(meta)来实现一些功能menu-item,根据 meta 配置项来实现"是否隐藏菜单","当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容"等
了解更多
交流讨论

