十四、基于RABC和CASL的权限管控(一)(nestjs+next.js从零开始一步一步创建通用后台管理系统)

99 阅读5分钟

前言

后端业务系统的权限主要包括接口权限、按钮权限、数据权限。其中接口权限和按钮权限都是通过后端的路由权限管控的,比如订单表的查询、新增、编辑、删除都对应后端的一个api接口,所以使用RABC就可以实现接口权限管控。 本章主要实现基本的RABC权限框架,下一章在此基础上实现细粒度权限。

原理

在接口上使用装饰器设置接口所需要的权限,如@permission('order:update')。
使用路由守卫判断用户是否有该路由的权限:
    1、取出接口的权限码(即'order:update'2、查询当前用户拥有的角色及权限(user->role->permission)
    3、判断该用户的权限中是否有该接口的权限码,有则放行,无则返回403

具体实现

目录

  • 1、表结构设计
  • 2、创建相关模块
  • 3、实现装饰器
  • 4、实现路由守卫
  • 5、测试

1、表结构设计

分别创建用户表user、角色表role、权限表permission

//user.entity.ts
import { ApiProperty } from "@nestjs/swagger";
import { BaseEntity } from "src/common/base/entities/base.entity";
import { Role } from "src/system/role/entities/role.entity";
import { Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity('sys_user')
export class User extends BaseEntity{
  @ApiProperty({
    example: "自动生成",
    description: "用户ID",
  })
  @PrimaryGeneratedColumn('uuid',{name: 'user_id', comment: '用户ID' })
  public userId: string;

  @ApiProperty({
    example: "admin",
    description: "用户名",
  })
  @Column({ type: 'varchar', name: 'user_name',length: 32, comment: '用户登录账号' })
  username: string;

  @ApiProperty({
    example: "admin123",
    description: "密码",
  })
  @Column({ type: 'varchar', name: 'password',length: 200, nullable: false, comment: '用户登录密码' })
  password?: string;
 
  @ApiProperty({
    example: "0",
    description: "用户类型 0 管理员 1 普通用户",
  })
  @Column({ type: 'int',name: 'user_type', comment: '用户类型 0 管理员 1 普通用户', default: 1 })
  userType: number;

  @ApiProperty({
    example: "aa@163.com",
    description: "用户邮箱",
  })
  @Column({ type: 'varchar',name: 'email', comment: '用户邮箱', default: ''})
  email?: string;

  @ApiProperty({
    example: "0",
    description: "是否冻结用户 0 不冻结 1 冻结",
  })
  @Column({ type: 'int', name: 'freezed',comment: '是否冻结用户 0 不冻结 1 冻结', default: 0 })
  freezed: number;

  @ApiProperty({
    example: "",
    description: "用户头像(base64编码的图片字符串)",
  })
  @Column({ type: 'varchar',name: 'avatar', comment: '用户头像', default: ''})
  avatar?: string;
  
  //增加这个字段后才可以使用关系查询用户包含的roles
  @ManyToMany(() => Role)
  @JoinTable({
    name: 'sys_user_role',
    joinColumn: { name: 'user_id', referencedColumnName: 'userId' },
    inverseJoinColumn: { name: 'role_id', referencedColumnName: 'roleId' }
  })
  roles: Role[];
}

//role.entity.ts
import { ApiProperty } from "@nestjs/swagger";
import { Permission } from "src/system/permission/entities/permission.entity";
import { User } from "src/system/user/entities/user.entity";
import { Column, CreateDateColumn, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";

@Entity('sys_role')
export class Role {
  @ApiProperty({
    example: "自动生成",
    description: "用户ID",
  })
  @PrimaryGeneratedColumn('uuid',{name: 'role_id', comment: '角色ID' })
  public roleId: string;

  @Column({ type: 'varchar', length: 50, comment: '角色名称' })
  name: string
  
  @Column({ type: 'varchar', length: 255, comment: '角色描述'})
  desc: string

  @CreateDateColumn({ type: 'timestamp', comment: '创建时间' })
  createTime: Date

  @UpdateDateColumn({ type: 'timestamp', comment: '更新时间' })
  updateTime: Date

  @Column({ type: 'int', comment: '是否为系统内置 0 否 1 是', default: 0 })
  isSystem: number

  @ManyToMany(() => User)
  @JoinTable({
    name: 'sys_user_role',
    joinColumn: { name: 'role_id', referencedColumnName: 'roleId' },
    inverseJoinColumn: { name: 'user_id', referencedColumnName: 'userId' }
  })
  users: User[];

  //增加这个字段后才可以使用关系查询用户包含的roles
  @ManyToMany(() => Permission)
  @JoinTable({
    name: 'sys_role_permission',
    joinColumn: { name: 'role_id', referencedColumnName: 'roleId' },
    inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'permissionId' }
  })
  permissions: Permission[];
}
//permission.entity.ts
import { ApiProperty } from "@nestjs/swagger";
import { Policy } from "src/system/policy/entities/policy.entity";
import { Role } from "src/system/role/entities/role.entity";
import { BaseEntity, Column, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn, Unique } from "typeorm";

@Entity('sys_permission')
export class Permission extends BaseEntity{
  @ApiProperty({
    example: "自动生成",
    description: "许可ID",
  })
  @PrimaryGeneratedColumn('uuid',{name: 'permission_id', comment: '许可ID' })
  public permissionId: string;

  @Column({ type: 'varchar', length: 50, comment: '名称' })
  @Unique(['name'])
  name: string; 
  
  @Column({ type: 'varchar', length: 50, comment: '权限码' })
  action: string // 权限码:READ,CREATE,UPDATE,DELETE,MANAGE

  @Column({ type: 'int', comment: '权限类型 0 菜单 1 页面 2 组件 3 按钮' })
  type: number

  @ManyToMany(() => Role)
  @JoinTable({
    name: 'sys_role_permission',
    joinColumn: { name: 'permission_id', referencedColumnName: 'permissionId' },
    inverseJoinColumn: { name: 'role_id', referencedColumnName: 'roleId' }
  })
  roles: Role[];

}

2、创建相关模块

使用命令创建各模块,系统相关的模块放在system目录下,其他业务模块放在module目录下。

nest g res system/user --no-spec
nest g res system/role --no-spec
nest g res system/permission --no-spec

其中用户服务中增加获取当前用户信息代码和测试用的分页查询代码:

/**
   * 获取当前用户信息
   *
   * @param currentUser 当前用户实体
   * @returns 返回当前用户信息和用户角色及权限
   */
  async getCurrentUser(currentUser: User) {
    // 同时查询用户角色和权限
    let queryBuilder = this.dataSource
      .getRepository(User)
      .createQueryBuilder('u')
      .leftJoinAndSelect('u.roles', 'r')
      .leftJoinAndSelect('r.permissions', 'p')
      .where('u.userId = :userId')
      .setParameter('userId', currentUser.userId);      

    const user = await queryBuilder.getOne();
    if(!user){
      throw new HttpException('用户不存在', HttpStatus.EXPECTATION_FAILED);
    }
    
    delete user.password;    
    return user;
  }
 async getUserList(dto: ListUserDto) {
    const { username, pageNum, pageSize } = dto;
    const skipCount = pageSize * (pageNum - 1);

    let queryBuilder = this.dataSource
    .getRepository(User)
    .createQueryBuilder('u')
    .leftJoinAndSelect('u.roles', 'r')
    .leftJoinAndSelect('r.permissions', 'p');
 
    // 如果有模糊检索条件
    if (username) {
      queryBuilder = queryBuilder
        .where('u.username Like :username')
        .setParameter('username', `%${username}%`);
    }
    const [list,count] = await queryBuilder.skip(skipCount).take(pageSize).getManyAndCount();
    
    // 手动映射结果
    return {
      rows: list.map((user) => {
        delete user.password;
        return user;
      }),
      total: count,
      pageNum,
    };
  }

user.controller.ts中增加访问接口

  @Get()  
  /**
   * 获取所有用户列表
   *
   * @param dto 用户列表查询条件
   * @returns 用户列表
   */
  getUserList(@Body() dto: any) {
    return this.userService.getUserList(dto);
  }

3、实现装饰器

//role-permission.decorator.ts
import { SetMetadata } from "@nestjs/common";

export const PERMISSION_KEY="permission";

export const Permission=(permission:string)=>SetMetadata(PERMISSION_KEY,permission);

1748854294819.png

4、实现路由守卫

具体的实现步骤可以参考代码的注释。如果在controller类上设置权限,可以设置为[:],表示不控制权限。

import { CanActivate, ExecutionContext, ForbiddenException, HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { PERMISSION_KEY } from 'src/common/decorators/role-permission.decorator';
import { ALLOW_NO_TOKEN } from 'src/system/auth/decorators/token.decorator';
import { UserService } from 'src/system/user/user.service';

@Injectable()
export class RolePermissionGuard implements CanActivate{
  constructor(
    private reflector: Reflector,
    private userService:UserService,
  ){}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    //1、如果不需要token,则不验证权限
    const allowNoToken = this.reflector.getAllAndOverride<boolean>(ALLOW_NO_TOKEN, [context.getHandler(), context.getClass()])
    if (allowNoToken) return true;
    //2、取到类和方法上的权限注解
    //2.1、拿到方法上的权限
    const handlerPermission=this.reflector.get<string[]>(PERMISSION_KEY, context.getHandler());
    const handler=handlerPermission instanceof Array?handlerPermission.join(""):handlerPermission;
    //2.2、拿到类上的权限 类上的权限码优先级高
    const classPermission=this.reflector.get<string[]>(PERMISSION_KEY, context.getClass());
    const cls=classPermission instanceof Array?classPermission.join(""):classPermission;
    //2.3、如果权限是*:*,则表示拥有所有权限,直接放行
    if(cls==="*:*" || handler==="*:*")
      return true;

    //3、取到用户的权限
    //3.1、拿到请求中的当前用户
    const req=context.switchToHttp().getRequest();    
    //3.2、查询当前用户是否拥有权限
    const user=await this.userService.getCurrentUser(req.user);
    //3.3、提取所有角色的所有许可的 action
    const allPermissionCodes = user.roles.reduce((acc, role) => {
      return acc.concat(role.permissions.map(permission => permission.action));
    }, []);

    //4、判断当前用户是否有权限
    const hasPermission=cls?allPermissionCodes.some((item)=>item===cls):
    handler?allPermissionCodes.some((item)=>item===handler):false;
     
    return hasPermission;
  }
}

5、测试

设置用户和角色:

image.png 权限表设置: image.png 设置权限:

1748855270153.png

访问登录接口拿到token:

image.png 在postman或Bruno中访问设置了权限的接口。

1、其中auth认证必须设置,否则请求头中的user用户信息是没有的。操作方法:

image.png

2、访问接口

image.png

修改接口权限,重新访问:

image.png