Vue3+NestJS实现权限管理系统(五):基于RBAC 权限控制实现

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 自定义指令进行实现,敬请期待~

相关代码地址

相关推荐
山山而川粤2 小时前
母婴用品系统|Java|SSM|JSP|
java·开发语言·后端·学习·mysql
过往记忆2 小时前
告别 Shuffle!深入探索 Spark 的 SPJ 技术
大数据·前端·分布式·ajax·spark
高兴蛋炒饭3 小时前
RouYi-Vue框架,环境搭建以及使用
前端·javascript·vue.js
m0_748240444 小时前
《通义千问AI落地—中》:前端实现
前端·人工智能·状态模式
ᥬ 小月亮4 小时前
Vue中接入萤石等直播视频(更新中ing)
前端·javascript·vue.js
玉红7774 小时前
R语言的数据类型
开发语言·后端·golang
神雕杨5 小时前
node js 过滤空白行
开发语言·前端·javascript
网络安全-杰克5 小时前
《网络对抗》—— Web基础
前端·网络
m0_748250745 小时前
2020数字中国创新大赛-虎符网络安全赛道丨Web Writeup
前端·安全·web安全
周伯通*5 小时前
策略模式以及优化
java·前端·策略模式