前言
哈喽大家好!我是嘟老板。之前写了《一文带你了解多数企业系统都在用的 RBAC 权限管理策略》一文,比较详尽的讲述了 RBAC
权限管理的理论内容。恰好最近在开发 ZimuAdmin
的权限模块,于是特地整理一份实现篇,分享从服务端设计到前端权限控制的一整个实现过程。
ZimuAdmin
项目基于以下技术构建:
前端:
Vue3
:JavaScript
框架。Vite4.3
:构建工具。ElementPlus
:UI
框架。TypeScript
:类型系统。Sass
:CSS
预处理框架。
服务端 :
express
:Node
框架。pnpm
:包管理工具。TypeScript
:类型系统。routing-controlers
: 优化控制器层。TypeORM
:ORM
框架,操作数据库。MySql
:数据持久化。Redis
:数据缓存。
阅读本文您将收获:
- 服务端权限管理模块实体设计及用户权限接口实现。
- 前端导航菜单及动态路由匹配过程。
- 等等...
设计思路
基础的 RBAC
权限主要分为四个部分:用户、角色、权限 和 资源,其中资源又可分为 菜单资源、按钮资源、页面资源等。
这四部分的关系如图:
其中,权限 是资源集合的最小单位,按照业务要求,分配相关资源,如出纳权限、采购权限;角色 对应职责或岗位,可拥有多个权限,比如财务主管同时拥有出纳和采购权限;用户 对应到人/员工,可为每个员工分配一个或多个角色。
更多关于 RBAC 的介绍,请点击《一文带你了解多数企业系统都在用的 RBAC 权限管理策略》
完整的权限控制逻辑,需要 客户端 与 服务端 配合完成。服务端核心逻辑在于获取用户权限资源,响应给客户端。客户端再通过资源数据,生成导航菜单及路由匹配等,与客户端既有的静态资源进行整合,实现基于用户角色的权限管理。
接下来,我们具体来看一下服务端和前端分别处理的内容。
服务端
服务端模块主要定义 RBAC
四大组成部分,用户服务、角色服务、权限服务、菜单服务,对应的实体类,控制层和服务等相关内容。
由于代码量较大,非关键代码将省略,感兴趣的小伙伴,可点击 ZimuAdmin
查看。
定义实体类
用户实体 user.entity
核心属性:
- 用户相关属性,如 用户名
username
、姓名name
、性别sex
、电话tel
等。 status
:状态,可选 激活/停用。roles
: 关联的角色列表。
实体类定义
import { USER_GENDER, USER_STATUS, Y_N } from '@constants/enums'
import {
Column,
Entity,
JoinTable,
ManyToMany,
PrimaryColumn,
PrimaryGeneratedColumn
} from 'typeorm'
import { Role } from './role.entity'
@Entity('user')
export class User {
// rowId
@PrimaryGeneratedColumn()
id!: number
// 用户账号/工号
@PrimaryColumn({ name: 'user_name' })
username!: string
// 其他用户属性字段...
// 状态
@Column({
type: 'enum',
enum: USER_STATUS,
default: USER_STATUS.SERVING
})
status!: USER_STATUS
// 关联的角色列表
@ManyToMany(() => Role, role => role.users)
@JoinTable({
name: 'zm-role-user-relation',
joinColumn: {
name: 'user_id'
},
inverseJoinColumn: {
name: 'role_id'
}
})
roles!: Role[]
}
此处的
roles
属性,并不是用户表的具体字段,而是为关联角色数据定义的特殊属性。借助了TypeORM
对于多对多关系的支持,达到获取关联角色的目的。
后续实体中定义的menus
、authorizations
、users
同理。
菜单实体 menu.entity
核心属性
code
:编码,全局唯一。name
:名称。type
:类型,可选 菜单/按钮/页面level
:层级。sort
:排序序号。icon
:导航栏图标。status
:状态,可选 激活/停用。parent
:父菜单编码。
实体类定义
import { ACTIVATION_STATUS, MENU_TYPE, MENU_TYPE_DESC } from '@constants/enums'
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
ManyToMany,
PrimaryColumn,
PrimaryGeneratedColumn,
UpdateDateColumn,
VersionColumn
} from 'typeorm'
@Entity('menu')
export class Menu {
@PrimaryGeneratedColumn()
id!: number
// 菜单编码
@PrimaryColumn()
code!: string
// 菜单名称
@Column()
name!: string
// 菜单类型(菜单menu or 按钮button or 页面 page)
@Column({
type: 'enum',
enum: MENU_TYPE
})
type!: MENU_TYPE
// 层级
@Column()
level!: number
// 排序
@Column()
sort!: number
// 菜单图标
@Column()
icon!: string
// 状态(激活 or 停用)
@Column({
type: 'enum',
enum: ACTIVATION_STATUS,
default: ACTIVATION_STATUS.ACTIVATED
})
status!: ACTIVATION_STATUS
// 父菜单编码
@Column()
parent!: string
}
权限实体 auth.entity
核心属性
code
:编码,全局唯一。name
:名称。status
:状态,可选 激活/停用。roles
:关联的角色列表。menus
:关联的菜单列表。
实体类定义
import { ACTIVATION_STATUS } from '@constants/enums'
import {
Column,
Entity,
JoinTable,
ManyToMany,
PrimaryColumn,
PrimaryGeneratedColumn
} from 'typeorm'
import { Menu } from './menu.entity'
import { Role } from './role.entity'
@Entity('auth')
export class Auth {
@PrimaryGeneratedColumn()
id!: number
// 权限编码
@PrimaryColumn()
code!: string
// 权限名称
@Column()
name!: string
// 状态(激活 or 停用)
@Column({
type: 'enum',
enum: ACTIVATION_STATUS,
default: ACTIVATION_STATUS.ACTIVATED
})
status!: ACTIVATION_STATUS
// 关联的角色列表
@ManyToMany(() => Role, role => role.authorizations)
roles!: Role[]
// 关联的菜单列表
@ManyToMany(() => Menu, menu => menu.authorizations)
@JoinTable({
name: 'auth-menu-relation',
joinColumn: {
name: 'auth_id'
},
inverseJoinColumn: {
name: 'menu_id'
}
})
menus!: Menu[]
}
角色实体 role.entity
核心属性
code
:编码,全局唯一。name
:名称。status
:状态,可选 激活/停用。authorizations
:关联的权限列表。users
:关联的用户列表。
实体类定义
import { ACTIVATION_STATUS } from '@constants/enums'
import {
Column,
Entity,
JoinTable,
ManyToMany,
PrimaryColumn,
PrimaryGeneratedColumn
} from 'typeorm'
import { Auth } from './auth.entity'
import { User } from './user.entity'
@Entity('role')
export class Role {
@PrimaryGeneratedColumn()
id!: number
// 角色编码
@PrimaryColumn()
code!: string
// 角色名称
@Column()
name!: string
// 状态(激活 or 停用)
@Column({
type: 'enum',
enum: ACTIVATION_STATUS,
default: ACTIVATION_STATUS.ACTIVATED
})
status!: ACTIVATION_STATUS
// 关联的权限列表
@ManyToMany(() => Auth, auth => auth.roles)
@JoinTable({
name: 'role-auth-relation',
joinColumn: {
name: 'role_id'
},
inverseJoinColumn: {
name: 'auth_id'
}
})
authorizations!: Auth[]
// 关联的用户列表
@ManyToMany(() => User, user => user.roles)
users!: User[]
}
用户权限接口 user/auth
用户权限接口可以说是整个 RBAC
的核心,通过该接口,可以拿到当前用户所有的权限资源,并响应给前端做对应处理。
定义接口
在 UserController 中定义 /auth
接口,大致逻辑如下:
- 获取当前账户,登录成功后会将账户名保存至
Redis
,此处get
函数即是从Redis
中获取用户名,CURRENT_USER
是Redis
存储的 key 值。 - 调用 queryAuthByUsername 服务函数,通过用户名获取资源数据,具体逻辑请往下看。
/**
* 通过用户名获取所有权限,返回菜单、按钮、等所有资源
*
* @param username 用户名
* @returns resources 所有菜单资源数组
*/
@Get('/auth')
async auth() {
// 获取当前账户
const currentUsername = await get(CURRENT_USER)
const resources =
await this.currentService.queryAuthByUsername(currentUsername)
return success(resources)
}
定义服务函数
用户服务 UserService 中新增一个 根据用户账户查询权限资源 的方法 - queryAuthByUsername
,核心逻辑如下:
- 获取登录用户信息,若当前用户为超管权限(即
isAdmin
为 Y),则默认授权所有资源;否则执行后续逻辑。 - 通过 用户名,获取该用户关联的所有 角色 roleIds。
- 获取 角色 roleIds 关联的 权限 authIds,去重合并。
- 获取 权限 authIds 关联的 资源 menuIds,去重合并。
- 获取 资源 menuIds 对应的明细列表数据。
最终返回该用户已分配的所有资源列表,包括菜单、按钮、页面等。
/**
* 根据用户名查询所有资源数据
* @param username 用户名
*/
async queryAuthByUsername(username: string) {
// 1. 当前用户绑定的角色
const { isAdmin, roles } = await this.queryByUsername(username, {
relations: {
roles: true
}
})
// 若为超管权限,则返回所有的资源
if (isAdmin === Y_N.Y) {
const { rows: allMenus } = await this.menuService.queryList()
return allMenus
}
const roleIds = roles.map((r: Role) => r.id)
if (!roleIds.length) return []
const { rows: rolesWithAuth, total } = await this.roleService.queryList(
{
id: In(roleIds)
},
{
relations: {
authorizations: true
}
}
)
if (!total) return []
// 2. 所有角色关联的权限
const authIds = [
...new Set(
rolesWithAuth.reduce((ret: number[], role: Role) => {
if (role.authorizations.length) {
const authIdsOfRole = role.authorizations.map((a: Auth) => a.id)
ret.push(...authIdsOfRole)
}
return ret
}, []) as number[]
)
]
if (!authIds.length) return []
const { rows: authsWithMenus, total: authTotal } =
await this.authService.queryList(
{
id: In(authIds)
},
{
relations: {
menus: true
}
}
)
if (!authTotal) return []
// 3. 所有权限关联的资源列表
const menuIds = [
...new Set(
authsWithMenus.reduce((ret: number[], auth: Auth) => {
if (auth.menus.length) {
const menuIdsOfAuth = auth.menus.map((m: Menu) => m.id)
ret.push(...menuIdsOfAuth)
}
return ret
}, []) as number[]
)
]
const { rows: allMenus } = await this.menuService.queryList({
id: In(menuIds)
})
return allMenus
}
上述实现可以用
QueryBuilder
进行精简,之所以写这么一堆,是为了向大家展示每一步的数据获取逻辑,方便理解整个过程。
前端
前端权限控制可以分为以下两部分:
- 导航菜单
- 路由控制
其中导航菜单是指系统界面展示的功能入口菜单,通常在界面左侧或顶部,用户可以通过点击某个菜单,展示指定的页面内容;路由控制是指对于前端路由的权限控制,目前 SPA
应用都是通过路由进行页面跳转,根据权限对路由进行精准控制,可有效避免越权问题。比如通过浏览器地址栏输入链接跳转,若仅仅控制了导航菜单,没有控制路由,就可能允许查看未授权的页面。
整个处理流程可简化为下图:
以上流程从初始化权限开始,入口函数包含以下内容:
- 获取用户权限数据。
- 筛选权限菜单,供后续导航菜单和路由控制使用。
- 根据权限菜单,初始化 Vue 路由。
- 将平铺的数组结构菜单数据,转换为导航菜单需要的树形结构。
具体代码如下:
// 初始化权限
const initAuthMenus = async (username: string) => {
// menus 包含所有权限资源,菜单、按钮等
const menus = await getUserAuth(username)
// 筛选菜单类型
flatMenus.value = menus.filter(
(m: ZiMuAuth.Menu) => m.type === MENU_TYPE.MENU
)
// 初始化 Vue 路由
routeStore.initRoutes(flatMenus.value)
// 导航菜单树形结构
authMenus.value = transformFlatMenusToTree(flatMenus.value)
isAuthInitialized.value = true
}
注:
由于导航菜单和路由都是针对菜单类型的资源,所以先筛选出菜单资源flatMenus
,避免多余的处理。
接下来分别看下导航菜单和路由控制如何处理。
导航菜单
通过上面接口部分可知,目前用户权限结构的响应结构是平铺的数组形式,而系统的导航菜单通常是存在层级关系的,即可能有一级菜单、二级菜单、三级菜单甚至更多,所以我们需要先将接口返回的数组结构数据,转换为导航菜单可用的树形结构。
这块主要是通过算法,将平铺的数组结构转化为树形结构,代码如下:
/**
* 将平铺的菜单结构转化为树形结构
* @param flatMenus 平铺的菜单列表
* @returns
*/
export function transformFlatMenusToTree(flatMenus: ZiMuAuth.Menu[]) {
if (!flatMenus.length) return []
const result: ZiMuAuth.Menu[] = []
// 菜单 code 与 菜单对象的映射
const menuCodeMap = new Map()
for (const menu of flatMenus) {
menu.children = []
menuCodeMap.set(menu.code, menu)
}
/**
* 1. 若当前菜单存在父菜单 parent,则将其加入到父菜单的 children 中
* 2. 若无父菜单,插入结果数组
*/
for (const menu of flatMenus) {
const parent = menuCodeMap.get(menu.parent)
if (parent) parent.children.push(menu)
else result.push(menu)
}
return sortMenuTree(result)
}
/**
* 树形结构菜单排序
* @param menus 树形结构菜单
* @returns
*/
export function sortMenuTree(menus: ZiMuAuth.Menu[]) {
if (!menus.length) return []
menus.sort((pre, next) => Number(pre.sort) - Number(next.sort))
for (const menu of menus) {
if (menu.children) sortMenuTree(menu.children)
}
return menus
}
流程图中有一点没有展示,就是菜单排序 sortMenuTree
,通过菜单实体定义部分可以看出,创建菜单时可配置排序属性 sort,即允许用户按照业务需求,自定义菜单的顺序,因此排序步骤十分必要。
路由控制
路由控制的处理相对麻烦一些,我们采用静态路由方式,即前端维护完整的路由表,再通过与权限菜单进行匹配,将匹配结果添加到最终路由中。
我们将路由分为两类,一类是始终需要展示的固定路由 constantVueRoutes
,如 登录页、错误页(404、403 等)等;另一类是通过权限配置的动态路由 matchedRoutes
,如菜单管理、用户管理等。
路由匹配需遵循规则:菜单的编码(code)需要与路由的 name 属性一致。
综上,匹配成功的路由需满足以下两个条件:
- 路由配置项未定义 name 属性(通常不允许该情况)或 name 属性的值在权限菜单内。
- 路由配置不是固定路由配置(即无需权限配置便可访问的路由,通常包括登录页,错误页等)。
以下是匹配路由的核心函数:
/**
* 通过菜单匹配生效的路由配置
* @param menus 权限菜单
*/
export function matchRoutesByAuthMenus(
vueRoutes: RouteRecordRaw[],
menus: ZiMuAuth.Menu[]
) {
if (!menus.length || !vueRoutes.length) return [...constantVueRoutes]
const menuCodes = menus.map(m => m.code)
const matchRoutes = (routes: RouteRecordRaw[]) => {
// 系统固定路由不参与匹配,直接允许访问
const target: RouteRecordRaw[] = []
for (const route of routes) {
/**
* 匹配条件:
* 1. route 未定义 name 属性,通常不存在该情况 或 route.name 在权限菜单内
* 2. route 不是固定路由配置(即无需权限配置,即可访问的路由,通常包括 登录页,错误页,首页等)
*/
const matched =
(!route.name || menuCodes.includes(route.name as string)) &&
!isConstantRoute(route)
if (matched) {
if (route.children?.length) {
route.children = matchRoutes(route.children)
}
// 若 target 中不存在该 route,则加入 target
target.push(route)
}
}
return target
}
const matchedRoutes: RouteRecordRaw[] = matchRoutes(vueRoutes)
const allRoutes = [...constantVueRoutes, ...matchedRoutes]
return allRoutes
}
matchRoutesByAuthMenus
函数的执行结果,便是最终要生效的路由,调用 Vue Router
addRoute
api 循环添加到路由中即可。
for (const route of matchedRoutes) {
router.addRoute(route)
}
到这,从权限数据到前端控制的整个流程就算完成啦,如果说还差一点什么,可能就是菜单、角色等模块的管理页面及创建、分配等操作,不过这些都是很基本的增删改查,且代码量较多,就不在文中体现了。
结语
本文重点介绍了 RBAC
权限管理的实现流程,从服务端实体类定义、权限接口定义到前端导航菜单及路由的控制等,比较详尽的讲述了整个实现过程。旨在帮助同学们加深对于 RBAC
权限管理从设计到实现整个过程的理解。希望对您有所帮助。相关代码已上传至 GitHub,欢迎 star。
如您对文章内容有任何疑问或想深入讨论,欢迎评论区留下您的问题和见解。
技术简而不凡,创新生生不息。我是 嘟老板,咱们下期再会。
往期推荐
- 💡💡💡Vue3 用了这么久还没体验过 JSX/TSX?来封装个业务弹窗玩玩
- 👏👏👏厉害了 Vue Vine !Vue 组件还能这样写!!!
- TypeORM 知多少?来看看 Node 服务端如何基于 TypeORM 封装 BaseService
- 还在考虑 node 服务端如何管理环境变量?来试试 node-config
- 还在纠结 node 服务端缓存怎么做?来试试 Redis
- 还在好奇 node 后端登录流程怎么做?进来聊聊吧
- 搞定 TS 装饰器,让你写 Node 接口更轻松
- 一文带你了解多数企业系统都在用的 RBAC 权限管理策略
- 还不会搭建 Node 服务?一文带你了解如何用 express + ts 搞定后端