RBAC 简介
RBAC 全称为 Role Based Access Control(基于角色的权限控制)。顾名思义它是将权限分配给角色,然后将角色分配给用户,那么用户就拥有了这个角色的所有权限。
创建表
了解了什么是 RBAC,接下来我们需要做到是如何将其和我们的后台管理系统相结合。首先我们需要知道在我们的项目中权限指的是什么?
我们都知道,在后台管理系统中有很多菜单,而不同用户登录后展现的菜单可能并不一样,因为他们的角色不同,角色不同意味着权限不一样,所以说在我们项目中菜单就是权限。除此之外,还有一些按钮展示也不一样,所以它们也是权限。为了方便起见,我们只需要创建一张菜单(Menu)表,它包含目录,菜单,按钮三种类型,我们需要什么就根据什么条件查询即可。接下来我们就生成一个菜单模块,并创建一张名为fs_menu
的数据表。
这里直接使用nest g res menu
生成一个菜单模块,新建entities/menu.entity.ts
目录用于存放实体
js
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("fs_menu")
export class Menu {
@PrimaryGeneratedColumn()
id: number;
//标题
@Column({
length: 20,
})
title: string;
//排序
@Column()
order_num: number;
//父id
@Column({ nullable: true })
parent_id: number;
//菜单类型 1:目录,2:菜单,3:按钮
@Column()
menu_type: number;
//菜单图标
@Column({
length: 50,
nullable: true,
})
icon: string;
//组件路径
@Column({
length: 50,
nullable: true,
})
component: string;
//权限标识
@Column({
length: 50,
nullable: true,
})
permission: string;
//路由
@Column({
length: 50,
})
path: string;
@Column({
type: "bigint",
})
create_by: number;
//状态 1:启用 0:禁用
@Column({
default: 1,
})
status: number;
@CreateDateColumn()
create_time: Date;
@UpdateDateColumn()
update_time: Date;
}
回到app.module.ts
中,前面我们每创建一个表都需要导入一次实体,很麻烦。这里我们设置一下 entities: ['**/*.entity.js']
,autoLoadEntities: true,
就可以帮我们自动寻找后缀为.entity.js
文件自动加载创建表了
启动项目,你就会发现fs_menu
表就已经自动创建好啦
同样的角色模块及角色表fs_role
也一样创建
js
import {
Column,
CreateDateColumn,
Entity,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
import { Menu } from "../../menu/entities/menu.entity";
@Entity("fs_role")
export class Role {
@PrimaryGeneratedColumn({
type: "bigint",
})
id: number;
//角色名
@Column({
length: 20,
})
role_name: string;
//排序
@Column()
role_sort: number;
//角色状态 启用:1 关闭:0
@Column({
default: 1,
})
status: number;
//备注
@Column({ length: "100", nullable: true })
remark: string;
//创建人Id
@Column({
type: "bigint",
})
create_by: number;
//更新人Id
@Column({
type: "bigint",
})
update_by: number;
@CreateDateColumn()
create_time: Date;
@UpdateDateColumn()
update_time: Date;
@ManyToMany(() => Menu)
@JoinTable({
name: "fs_role_menu_relation",
})
menus: Menu[];
}
这里我们使用了@ManyToMany(() => Menu)
来和菜单表实现了多对多的关系,因为一个角色可以有多个菜单,同样的,一个菜单也可以有多个角色。当然我们这里暂时只用到一个角色包含多个菜单的情况。启动项目后,nest 会为我们创建一个fs_role
表,同时也创建了一个fs_role_menu_relation
关系表,这个表的作用就是将角色和菜单关联起来。譬如:
角色 A(id 为 2)拥有菜单 1(id 为 1)、菜单 2(id 为 2)、和菜单 3(id 为 3),那么关系表就如下
除了角色和菜单,用户和角色也是多对多的关系,所以我们需要调整一下用户表的实体(user/entities/user.entity.ts)
js
import {
Column,
Entity,
PrimaryGeneratedColumn,
BeforeInsert,
ManyToMany,
JoinTable,
} from "typeorm";
import encry from "../../utils/crypto";
import * as crypto from "crypto";
import { Role } from "src/role/entities/role.entity";
@Entity("fs_user")
export class User {
@PrimaryGeneratedColumn({
type: "bigint",
})
id: number; // 标记为主键,值自动生成
@Column({ length: 30 })
username: string; //用户名
@Column({ nullable: true })
nickname: string; //昵称
@Column()
password: string; //密码
@Column({ nullable: true })
avatar: string; //头像
@Column({ nullable: true })
email: string; //邮箱
@Column({ nullable: true })
salt: string;
@ManyToMany(() => Role)
@JoinTable({
name: "fs_user_role_relation",
})
roles: Role[];
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
create_time: Date;
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
update_time: Date;
@BeforeInsert()
beforeInsert() {
this.salt = crypto.randomBytes(4).toString("base64");
this.password = encry(this.password, this.salt);
}
}
ok,到这里我们就将这三张表创建完毕,并将他们的关系通过关系表进行了关联。接下来我们就写几个新增接口添加一些数据拥有调试
新增菜单
下面我们来开发一个新增菜单的接口,然后将系统管理
、角色管理
、菜单管理
、用户管理
新增到菜单表中。他们的关系结构如下
ini
--系统管理;
--用户管理;
--菜单管理;
--角色管理;
首先我们需要在 menu 模块新建dto/create-menu.dto.ts
来规定前端传来的参数
js
import { IsNotEmpty, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateMenuDto {
@IsNotEmpty({ message: '菜单名不可为空' })
@ApiProperty({
example: '菜单1',
})
title: string;
@ApiProperty({
example: 1,
})
order_num: number;
@ApiProperty({
example: 1,
required: false,
})
@IsOptional()
parent_id?: number;
@ApiProperty({
example: 1,
})
menu_type: number;
@ApiProperty({
example: 'menu',
})
icon: string;
@IsOptional()
@ApiProperty({
example: 'AA/BB',
required: false,
})
component?: string;
@IsNotEmpty({ message: '路由不可为空' })
@ApiProperty({
example: 'BB',
})
path: string;
@ApiProperty({
example: 11,
})
create_by: number;
@IsOptional()
@ApiProperty({
example: 'sys:post:list',
required: false,
})
permission?: string;
}
里面用到很多装饰器比如@IsOptional:规定参数可选
,@IsNotEmpty:参数不能为空
等等。
定义完后在menu.module.ts
引入menu
的实体并注入
然后在menu.service.ts
通过@InjectRepository
装饰器中注入到参数中就可以操作这个表了
最后写一个创建菜单的函数
js
async createMenu(createMenuDto: CreateMenuDto) {
try {
await this.menuRepository.save(createMenuDto);
return '菜单新增成功';
} catch (error) {
throw new ApiException('菜单新增失败', 20000);
}
}
在menu.controller.ts
中定义一个路由调用
js
import { Body, Controller, Post } from '@nestjs/common';
import { MenuService } from './menu.service';
import {
ApiOperation,
ApiTags,
ApiOkResponse,
ApiParam,
} from '@nestjs/swagger';
import { CreateMenuDto } from './dto/create-menu.dto';
import { Public } from 'src/public/public.decorator';
import { Request } from '@nestjs/common';
@Controller('menu')
@ApiTags('菜单权限模块')
export class MenuController {
constructor(private readonly menuService: MenuService) {}
@Post('/createMenu')
@Public()
@ApiParam({ name: 'createMenuDto', type: CreateMenuDto })
@ApiOperation({ summary: '新增菜单' })
async createMenu(
@Body()
createMenuDto: CreateMenuDto,
) {
return await this.menuService.createMenu(createMenuDto);
}
}
为了方便测试,我们暂时加了@Public
装饰器,这样就不会进行 token 验证了 ,同时像@ApiParam、 @ApiOperation
等装饰器都是swagger
文档相关装饰器,用于接口文档的展示,当然如果你不需要也可以不写
最后我们通过工具(这里用的是apifox
)直接调用http://localhost:3000/menu/createMenu
并传入相关参数就能新增菜单了,比如新增用户管理菜单
其中parent_id
是父级目录(系统管理的 id),最终我们操作完成后表是这样的
我们需要注意的是最上层的目录的parent_id
是为null
的,因为它没有父级比如系统管理
新增角色、用户
接下来我们再写一个新增角色的接口,并为这些角色赋予一些菜单权限。这里需要注意一个特别的角色超级管理员
,我们默认这个角色拥有所有的权限
和上面一样,先创建dto
规定前端传的参数
js
import { IsArray, IsNotEmpty, IsNumber, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateRoleDto {
@IsNotEmpty({ message: '角色名不可为空' })
@ApiProperty({
example: '技术人员',
})
role_name: string;
@ApiProperty({
example: '备注',
required: false,
})
@IsOptional()
remark?: string;
@IsNotEmpty({ message: '角色状态不可为空' })
@ApiProperty({
example: 1,
description: '角色状态,1表示启用,0表示禁用',
})
status: number;
@ApiProperty({
example: [1],
required: false,
})
@IsOptional()
@IsArray({
message: 'role_ids必须是数组',
})
@IsNumber({}, { each: true, message: 'role_ids必须是数字数组' })
menu_ids?: number[];
@IsNotEmpty({ message: '排序不可为空' })
@ApiProperty({
example: 1,
})
role_sort: number;
@IsNotEmpty({ message: '创建人id不可为空' })
@ApiProperty({
example: 1,
})
create_by: number;
@IsNotEmpty({ message: '更新人id不可为空' })
@ApiProperty({
example: 1,
})
update_by: number;
}
然后在role.module.ts
导入menu和role
表的实体
在role.service.ts
中写创建角色逻辑
js
async create(createRoleDto: CreateRoleDto): Promise<string> {
const row = await this.roleRepository.findOne({
where: { role_name: createRoleDto.role_name },
});
if (row) {
throw new ApiException('角色已存在', ApiErrorCode.COMMON_CODE);
}
const newRole = new Role();
if (createRoleDto.menu_ids?.length) {
//查询包含menu_ids的菜单列表
const menuList = await this.menuRepository.find({
where: {
id: In(createRoleDto.menu_ids),
},
});
//赋值给newRole(插入表中之后就会在关系表中生成对应关系)
newRole.menus = menuList;
}
try {
await this.roleRepository.save({ ...createRoleDto, ...newRole });
return 'success';
} catch (error) {
throw new ApiException('系统异常', ApiErrorCode.FAIL);
}
}
在role.controller.ts
中调用createRole
函数即可
js
//...
@Controller('role')
@ApiTags('角色模块')
export class RoleController {
constructor(private readonly roleService: RoleService) {}
@Public()
@Post('createRole')
@ApiParam({
name: 'CreateRoleDto',
type: CreateRoleDto,
})
createRole(@Body() createRoleDto: CreateRoleDto) {
return this.roleService.create(createRoleDto);
}
}
最后调用role/createRole
新增一个超级管理员角色和一个用于系统管理,菜单管理,角色管理的普通角色
用于测试
最终表如下
到这里我们便完成了角色的创建。同样的逻辑,我们再在 user 模块写一个创建用户的接口
js
async create(createUserDto: CreateUserDto): Promise<string> {
const userExists = await this.userRepository.findOne({
where: { username: createUserDto.username },
});
if (userExists)
throw new ApiException('用户已存在', ApiErrorCode.USER_EXIST);
try {
const newUser = new User();
if (createUserDto.role_ids?.length) {
//查询需要绑定的角色列表(自动在关联表生成关联关系)
const roleList = await this.roleRepository.find({
where: {
id: In(createUserDto.role_ids),
},
});
console.log(roleList);
newUser.roles = roleList;
}
newUser.username = createUserDto.username;
newUser.password = createUserDto.password;
await this.userRepository.save(newUser);
return 'success';
} catch (error) {
console.log(error);
throw new ApiException('系统异常', ApiErrorCode.FAIL);
}
}
同时新增两个用户并分别赋予管理员
和角色1
角色
管理员用户
角色 1 用户
获取菜单路由接口
接下来我们来实现不同用户获取不同的菜单列表,并将菜单列表转换成树形结构返回给前端使用。根据上面的 RBAC 权限我们大致画一个实现流程
来到我们菜单模块service
中,编写一个获取路由的函数
js
async getRouters(req): Promise<Menu[]> {
//user.guard中注入的解析后的JWTtoken的user
const { user } = req;
//根据关联关系通过user查询user下的菜单和角色
const userList: User = await this.userRepository
.createQueryBuilder('fs_user')
.leftJoinAndSelect('fs_user.roles', 'fs_role')
.leftJoinAndSelect('fs_role.menus', 'fs_menu')
.where({ id: user.sub })
.orderBy('fs_menu.order_num', 'ASC')
.getOne();
//是否为超级管理员,是的话查询所有菜单
const isAdmin = userList.roles?.find((item) => item.role_name === 'admin');
let routers: Menu[] = [];
if (isAdmin) {
routers = await this.menuRepository.find({
order: {
order_num: 'ASC',
},
where: {
status: 1,
},
});
return convertToTree(routers);
}
interface MenuMap {
[key: string]: Menu;
}
// console.log(userList.roles[0].menus);
//根据id去重
const menus: MenuMap = userList?.roles.reduce(
(mergedMenus: MenuMap, role: Role) => {
role.menus.forEach((menu: Menu) => {
mergedMenus[menu.id] = menu;
});
return mergedMenus;
},
{},
);
routers = Object.values(menus);
return convertToTree(routers);
}
其中convertToTree
就是将扁平菜单列表转成树形结构菜单列表的函数
js
export const convertToTree = (menuList, parentId: number | null = null) => {
const tree = [];
for (let i = 0; i < menuList.length; i++) {
if (menuList[i].parent_id === parentId) {
const children = convertToTree(menuList, menuList[i].id);
if (children.length) {
menuList[i].children = children;
}
tree.push(menuList[i]);
}
}
return tree;
};
它接受两个参数:
menuList
是原始的扁平菜单列表,parentId
是当前节点的父节点 ID,初始值为null
。函数首先创建了一个空数组
tree
用于存储树形结构。然后使用循环遍历menuList
,对每个菜单项进行判断,如果当前菜单项的parent_id
等于传入的parentId
,则将其视为当前节点的子节点,继续递归调用convertToTree
函数来构建子树。如果子节点存在,则将子节点数组赋值给当前菜单项的children
属性,最后将当前菜单项添加到tree
数组中。
最终,函数返回构建好的树形结构数组 tree
。
最后我们在controller
中定义一个接口就完成了
js
@Post('/getRouters')
@ApiOperation({ summary: '获取路由' })
async getRouters(@Request() req) {
return await this.menuService.getRouters(req);
}
我们尝试一下,首先我们先用user1
用户调用登录接口拿到 token,复制到我们的请求工具apifox
请求头中(记的加上前缀Bearer
),然后请求一下就可以拿到属于这个用户的菜单啦
到这里我们基本已经处理完菜单获取相关的权限了,但是像是一些接口调用的权限,如列表的获取,前端按钮点击调用等等的权限还没处理。所以下一篇文章将介绍如何处理这些权限。后端我们将通过 Guard 守卫配合自定义装饰器实现,而前端方面则通过 Vue3 自定义指令进行实现,敬请期待~