likes
comments
collection
share

nestjs学习:nestjs实现简单RBAC权限控制

作者站长头像
站长
· 阅读数 35

1.什么是RBAC?

RBAC(Role-Based Access Control)是一种访问控制模型,用于管理系统中的用户权限。在RBAC模型中,权限是根据用户的角色分配的,而不是直接分配给个别用户。每个角色都有一组特定的权限,用户被分配到角色后,就会继承该角色的权限。

相对于ACL(Access Control List,访问控制列表),RBAC具有以下优点:

  1. 简化权限管理:RBAC模型将权限分配给角色,而不是直接分配给用户。这样一来,当用户的角色发生变化时,只需要调整其角色的权限,而不需要逐个修改每个用户的权限。这简化了权限管理的复杂性,特别是在大型组织或系统中。
  2. 提高安全性:RBAC模型可以确保用户只能访问其所需的权限,而不会被授予不必要的权限。通过限制用户的权限范围,RBAC可以减少潜在的安全漏洞和错误配置的风险。
  3. 支持角色继承:RBAC模型支持角色之间的继承关系。这意味着一个角色可以继承另一个角色的权限,从而简化了权限的管理和维护。例如,一个高级管理员角色可以继承普通管理员角色的权限,而不需要为高级管理员重新定义相同的权限。
  4. 提高可扩展性:RBAC模型可以轻松地适应组织结构的变化和系统的扩展。当新的角色需要被添加时,只需要定义新的角色并分配相应的权限即可,而不需要修改现有的权限分配。

在ACL中,可以权限管理可以理解为下图:

nestjs学习:nestjs实现简单RBAC权限控制

ACL通过给每个用户记录其具备的权限实现权限控制。

在RBAC中,可以权限管理可以理解为下图:

nestjs学习:nestjs实现简单RBAC权限控制

RBAC模型将权限分配给角色,再通过给不同的用户分配不同的角色实现权限控制。相比ACL,RBAC对于我们打工仔来说更容易管理,不容易写bug。

举个例子

如果管理员权限有访问A、B、C的权限,在某一天,需要给管理员增加D的权限。在ACL中,存在管理员用户1,2,3,则需要分别给用户1,2,3添加访问D的权限。而在RBAC中,只需要给管理员这一角色添加D的权限。

2.前期准备

nestjs学习:nestjs实现简单RBAC权限控制

2.1使用typeorm建立数据库映射

在AppModule 引入 TypeOrmModule:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from './user/user.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      port: 3306,
      username: 'root',
      password: '111111',
      database: 'rbac_test',
      synchronize: true,
      logging: true,
      entities: [],
      poolSize: 10,
      connectorPackage: 'mysql2',
      extra: {
        authPlugin: 'sha256_password',
      },
    }),
    UserModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

2.2创建user模块

在user模块中分别添加User、Role、Permission的Entity

nestjs学习:nestjs实现简单RBAC权限控制

import {
  Column,
  CreateDateColumn,
  Entity,
  JoinTable,
  ManyToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { Role } from './role.entity';

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    length: 50,
  })
  username: string;

  @Column({
    length: 50,
  })
  password: string;

  @CreateDateColumn()
  createTime: Date;

  @UpdateDateColumn()
  updateTime: Date;

  @ManyToMany(() => Role)
  @JoinTable({
    name: 'user_role_relation',
  })
  roles: Role[];
}
import {
  Column,
  CreateDateColumn,
  Entity,
  JoinTable,
  ManyToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from 'typeorm';
import { Permission } from './permission.entity';

@Entity()
export class Role {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    length: 20,
  })
  name: string;

  @CreateDateColumn()
  createTime: Date;

  @UpdateDateColumn()
  updateTime: Date;

  @ManyToMany(() => Permission)
  @JoinTable({
    name: 'role_permission_relation',
  })
  permissions: Permission[];
}
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";

@Entity()
export class Permission {
    @PrimaryGeneratedColumn()
    id: number;

    @Column({
        length: 50
    })
    name: string;
    
    @Column({
        length: 100,
        nullable: true
    })
    desc: string;

    @CreateDateColumn()
    createTime: Date;

    @UpdateDateColumn()
    updateTime: Date;
}

使用 *@Entity() *装饰器将一个类标记为实体后,TypeORM 将会自动创建与该类对应的数据库表,并且可以通过该实体类进行数据库操作,如插入、更新、删除和查询等。

nestjs学习:nestjs实现简单RBAC权限控制

在TypeOrm.forRoot 的 entities 数组加入这三个 entity。

TypeOrmModule.forRoot函数会在应用程序的根模块中调用,并传入一个配置对象,该对象包含了数据库连接的相关信息。这样,在应用程序启动时,TypeORM会使用这些配置信息来建立与数据库的连接,并根据需要执行数据库操作。

将nest程序跑起来,将会自动生成对应的数据库,并建立表和表之间的关联关系。

2.3启动nest服务器

npm run start:dev

成功生成所需的表并且关联关系正确

nestjs学习:nestjs实现简单RBAC权限控制nestjs学习:nestjs实现简单RBAC权限控制

2.4生成种子数据

@InjectEntityManager()
  entityManager: EntityManager;
  //! 生成种子数据
  async initData() {
    const user1 = new User();
    user1.username = '张三';
    user1.password = '111111';

    const user2 = new User();
    user2.username = '李四';
    user2.password = '222222';

    const user3 = new User();
    user3.username = '王五';
    user3.password = '333333';

    const role1 = new Role();
    role1.name = '管理员';

    const role2 = new Role();
    role2.name = '普通用户';

    const permission1 = new Permission();
    permission1.name = '新增 aaa';

    const permission2 = new Permission();
    permission2.name = '修改 aaa';

    const permission3 = new Permission();
    permission3.name = '删除 aaa';

    const permission4 = new Permission();
    permission4.name = '查询 aaa';

    const permission5 = new Permission();
    permission5.name = '新增 bbb';

    const permission6 = new Permission();
    permission6.name = '修改 bbb';

    const permission7 = new Permission();
    permission7.name = '删除 bbb';

    const permission8 = new Permission();
    permission8.name = '查询 bbb';

    role1.permissions = [
      permission1,
      permission2,
      permission3,
      permission4,
      permission5,
      permission6,
      permission7,
      permission8,
    ];

    role2.permissions = [permission1, permission2, permission3, permission4];

    user1.roles = [role1];

    user2.roles = [role2];

    await this.entityManager.save(Permission, [
      permission1,
      permission2,
      permission3,
      permission4,
      permission5,
      permission6,
      permission7,
      permission8,
    ]);

    await this.entityManager.save(Role, [role1, role2]);

    await this.entityManager.save(User, [user1, user2]);
  }

数据库中生成如下数据

nestjs学习:nestjs实现简单RBAC权限控制

3.实现主要登录、校验、权限管理

3.1登录接口实现

  • 安装ValidationPipe用到的包
npm install --save class-validator class-transformer
  • 创建dto并添加校验装饰器
import { IsNotEmpty, Length } from 'class-validator';

export class UserLoginDto {
  @IsNotEmpty()
  @Length(1, 50)
  username: string;

  @IsNotEmpty()
  @Length(1, 50)
  password: string;
}
  • main.ts中全局启动ValidationPipe

nestjs学习:nestjs实现简单RBAC权限控制

  • 使用 @nestjs/jwt库来生成JWT令牌

nestjs学习:nestjs实现简单RBAC权限控制首先,JwtModule.register() 方法被调用,接受一个对象作为参数。这个对象包含了 JWT 模块的配置信息。

global: true 表示将该模块注册为全局模块,使得整个应用程序都可以使用 JWT 功能。

secret: 'zzz' 是用于签名和验证 JWT 的密钥。在实际应用中,应该使用一个更长、更复杂的密钥来增加安全性。

signOptions 是一个对象,用于配置 JWT 的签名选项。

    • expiresIn: '7d' 表示 JWT 的过期时间为 7 天。这意味着生成的 JWT 在 7 天后会自动失效,需要重新登录或获取新的令牌。
  • user.service.ts中实现登录的主要逻辑

需要注入EntityManager

  @InjectEntityManager()
  entityManager: EntityManager;
async login(loginUser: UserLoginDto) {
    const user = await this.entityManager.findOne(User, {
      where: {
        username: loginUser.username,
      },
      relations: {
        roles: true,
      },
    });

    if (!user) {
      throw new HttpException('用户不存在', HttpStatus.ACCEPTED);
    }

    if (user.password != loginUser.password) {
      throw new HttpException('密码错误', HttpStatus.ACCEPTED);
    }
    return user;
  }
  • user.controller.ts中实现调用service方法并将service返回的user信息存入token中

需要提前注入JwtService

@Controller('user')
export class UserController {
  @Inject(JwtService) private jwtService: JwtService;

  constructor(private readonly userService: UserService) {}
  @Get('init')
  async initData() {
    await this.userService.initData();
    return 'done';
  }

  @Post('login')
  async login(@Body() loginUser: UserLoginDto) {
    const user = await this.userService.login(loginUser);

    const token = this.jwtService.sign({
      user: {
        username: user.username,
        roles: user.roles,
      },
    });
    return {
      token,
    };
  }
}
  • 使用postman测试接口

nestjs学习:nestjs实现简单RBAC权限控制

成功返回token,在下次访问时需要带上token才能访问某些做出限制的资源

3.2权限控制

  • 添加 aaa、bbb 两个模块,分别生成 CRUD 方法
nest g resource aaa 
nest g resource bbb 

此时的接口没有做限制,可以直接访问。

nestjs学习:nestjs实现简单RBAC权限控制

假设这些接口需要进行权限验证才能访问。管理员角色具有 aaa 和 bbb 的增删改查权限,而普通用户只有 bbb 的增删改查权限。因此,需要对接口调用进行限制。因此,要实现以下两个目标:

  1. 只有在登录状态下才能访问。
  2. 只有在通过权限验证后才能访问。

3.2.1登录状态

使用guard来检查登录状态

  • 新建一个login.guard.ts
nest g guard login
import {
  CanActivate,
  ExecutionContext,
  Inject,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { Role } from 'src/user/entities/role.entity';

declare module 'express' {
  interface Request {
    user: {
      username: string;
      roles: Role[];
    };
  }
}

@Injectable()
export class LoginGuard implements CanActivate {
  @Inject(JwtService) private jwtService: JwtService;

  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request: Request = context.switchToHttp().getRequest();

    const authorization = request.headers.authorization;
    if (!authorization) {
      throw new UnauthorizedException('用户未登录');
    }

    try {
      const token = authorization.split(' ')[1];
      const data = this.jwtService.verify(token);

      request.user = data.user;
      return true;
    } catch (e) {
      throw new UnauthorizedException('token失效,请重新登录');
    }
  }
}

在guard中将请求头的authorization字段取出,在HTTP请求中,Authorization头字段用于向服务器提供身份验证凭据。它与JWT(JSON Web Token)之间存在一定的关系。

JWT是一种用于在网络应用之间安全传输信息的开放标准(RFC 7519)。它使用JSON对象作为令牌,用于在客户端和服务器之间传递声明。JWT通常用于身份验证和授权,以及在分布式系统中传递声明性信息。当使用JWT进行身份验证时,通常将JWT作为身份验证凭据放置在Authorization头字段中。这是通过在Authorization头字段的值中添加"Bearer"关键字和JWT令牌来完成的。例如:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

在服务器端,当收到带有Authorization头字段的请求时,它会提取JWT令牌并进行验证,即代码中的this.jwtService.verify(token)。验证过程包括检查令牌的签名有效期和其他声明。如果验证成功,服务器将允许请求继续进行,并使用JWT中的声明来进行授权和身份验证。

总结来说,Authorization头字段用于在HTTP请求中传递身份验证凭据,而JWT是一种常用的身份验证凭据格式。将JWT放置在Authorization头字段中是一种常见的做法,以便在服务器端进行验证和授权。

这里采用providers数组而不采用@UseGuards()来注入Guard

nestjs学习:nestjs实现简单RBAC权限控制

当Guard被绑定到APP_GUARD令牌时,它将自动应用于整个应用程序的所有路由处理程序。这意味着每个请求都会经过该Guard进行身份验证和授权检查,以确保只有经过验证的用户可以访问受保护的路由。这就存在一个问题:即使我们使用login接口登录时,仍然会触发该guard。

AppModule 添加 token 为 APP_XXXprovider 的可以用来声明全局 Guard、Pipe、Intercepter。

nestjs学习:nestjs实现简单RBAC权限控制

解决所有路由都会返回用户未登录问题
  • 添加custom-decorator.ts 来放分配权限的装饰器
import { SetMetadata } from '@nestjs/common';

export const RequireLogin = () => SetMetadata('require-login', true);

分别在AaaController和BbbController中使用该装饰器

nestjs学习:nestjs实现简单RBAC权限控制

  • 修改LoginGuard来判断是否需要登录

nestjs学习:nestjs实现简单RBAC权限控制 getAllAndOverridereflector 对象的一个方法,它接受两个参数:装饰器的名称和一个数组。该方法的作用是获取指定装饰器的参数,并且如果在给定的数组中找到多个装饰器,则返回最后一个装饰器的参数。在这段代码中,装饰器的名称是 'require-login' ,而数组包含了两个元素:context.getClass()context.getHandler()。这两个方法是获取当前上下文的类和处理程序的方法。

此时,可以正常登录,因为登录接口并没有设置metadata,通过反射取到的require-login为undefined。

nestjs学习:nestjs实现简单RBAC权限控制

nestjs学习:nestjs实现简单RBAC权限控制

在访问aaa的请求中携带token后也可以正常访问

nestjs学习:nestjs实现简单RBAC权限控制

目前的实现是,只要携带了token就能正常访问,不会考虑用户的权限,还需要做用户的权限校验。

3.2.2权限控制

  • 新建一个PermmissionGuard来做权限控制
nest g guard permission
  • 全局注入PermmissionGuard

nestjs学习:nestjs实现简单RBAC权限控制

PermissionGuard的注入写在LoginGuard后面,LoginGuard会先执行

nestjs学习:nestjs实现简单RBAC权限控制

LoginGuard中,如果用户处于登录状态,会解析token拿到user信息,并将user挂载在request上,传递给PermissionGuard

nestjs学习:nestjs实现简单RBAC权限控制

token中的user信息在登录的时候写入。

  • 根据用户的角色来查询该登录用户所具有的权限,初步实现PermmissionGuard
import {
  CanActivate,
  ExecutionContext,
  Inject,
  Injectable,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { UserService } from 'src/user/user.service';
import { Request } from 'express';
import { Permission } from 'src/user/entities/permission.entity';

@Injectable()
export class PermissionGuard implements CanActivate {
  @Inject(UserService) private userService: UserService;

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    if (!request.user) {
      return true;
    }
    //! 可能一个用户是多个角色
    const roles = await this.userService.findRoleByIds(
      request.user.roles.map((item) => item.id),
    );
    //! 可能存在roles[0].permissions 和roles[1].permissions
    const permissions: Permission[] = roles.reduce((total, current) => {
      total.push(...current.permissions);
      return total;
    }, []);
    return true;
  }
}

以上代码主要实现两点功能:

  1. 通过调用userService.findRoleByIds来获取用户对应角色具备的权限。
  2. 处理可能存在roles[0].permissions 和roles[1].permissions,即一个用户饰演多个角色。

此时携带token访问aaa接口,数据库返回如下权限结果

nestjs学习:nestjs实现简单RBAC权限控制

此时数据库权限的名称并没有和如下代码中接口的名称对应

nestjs学习:nestjs实现简单RBAC权限控制

  • 建立对应关系--在刚刚定义的custom-decorator.ts 中新增RequirePermission装饰器
import { SetMetadata } from '@nestjs/common';

export const RequireLogin = () => SetMetadata('require-login', true);

export const RequirePermission = (...permissions: string[]) =>
  SetMetadata('require-permission', permissions);

当使用RequirePermission装饰器修饰一个控制器或处理程序方法时,它会将一个元数据键值对'require-permission'权限数组``permissions作为值添加到该方法或控制器的元数据中。

在AaaController中使用,传入修饰器函数的值需要和数据库中的值一致。

nestjs学习:nestjs实现简单RBAC权限控制

  • 修改PermissionGuard ,实现权限控制
import {
  CanActivate,
  ExecutionContext,
  Inject,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { UserService } from 'src/user/user.service';
import { Request } from 'express';
import { Permission } from 'src/user/entities/permission.entity';
import { Reflector } from '@nestjs/core';

@Injectable()
export class PermissionGuard implements CanActivate {
  @Inject(UserService) private userService: UserService;
  @Inject(Reflector) private reflector: Reflector;

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    if (!request.user) {
      return true;
    }
    //! 可能一个用户是多个角色
    const roles = await this.userService.findRoleByIds(
      request.user.roles.map((item) => item.id),
    );
    //! 可能存在roles[0].permissions 和roles[1].permissions
    const permissions: Permission[] = roles.reduce((total, current) => {
      total.push(...current.permissions);
      return total;
    }, []);
    //! 获取当前handler的元数据
    const requirePermissions = this.reflector.getAllAndOverride(
      'require-permission',
      [context.getClass(), context.getHandler()],
    );

    const isPermit = permissions.some((item) => {
      return item.name == requirePermissions;
    });
    if (isPermit || requirePermissions == undefined) {
      return true;
    } else {
      throw new UnauthorizedException('您没有权限访问该接口');
    }
  }
}

通过反射拿到当前handler上设置的元数据,再与之前获取到的当前用户的权限列表做对比,如果当前handler对应的requirePermissions存在于permissions中,则返回true,否则抛出UnauthorizedException

nestjs学习:nestjs实现简单RBAC权限控制

登录李四账号,李四拥有着aaa接口的crud权限,没有bbb接口的权限。

nestjs学习:nestjs实现简单RBAC权限控制

nestjs学习:nestjs实现简单RBAC权限控制

测试结果和代码结果一致。

至此,整个权限控制已经完成!但是在每次接口请求都会请求数据库,且涉及多个表的联查。此处可以使用redis进行优化。

4.使用redis做缓存

  • 安装redie的相关包
npm install redis 
  • 新建一个redis模块和服务
nest g module redis
nest g service redis
  • RedisModule中添加redisprovider
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
import { createClient } from 'redis';

@Global()
@Module({
  providers: [
    RedisService,
    {
      provide: 'REDIS_CLENT',
      async useFactory() {
        const client = createClient({
          socket: {
            host: '127.0.0.1',
            port: 6379,
          },
        });
        await client.connect();
        return client;
      },
    },
  ],
  exports: [RedisService],
})
export class RedisModule {}

useFactory 是一个用于创建提供者的工厂方法。它允许我们在运行时动态地创建和配置提供者

useFactory 适用于以下场景:

  1. 动态配置:当我们需要根据一些条件来动态配置提供者时,可以使用 useFactory。例如,我们可以根据环境变量的值来创建不同的数据库连接。
  2. 异步初始化:有时,我们需要在提供者初始化之前执行一些异步操作,例如从远程服务器获取配置。在这种情况下,我们可以使用 useFactory 返回一个 PromiseNestJS 会等待 Promise 解析完成后再创建提供者。
  3. 复杂依赖关系:当提供者的创建需要依赖其他提供者时,我们可以使用 useFactory 来注入这些依赖项。这样,我们可以在工厂函数中访问其他提供者,并根据需要进行配置。
  • 在service中添加redis的查找和存储方法
import { Inject, Injectable } from '@nestjs/common';
import { RedisClientType } from 'redis';

@Injectable()
export class RedisService {
  @Inject('REDIS_CLENT') private redisClient: RedisClientType;

  async listGet(key: string) {
    //!在Redis中,LRANGE是一个用于获取列表(List)的指令 以下代码相当于LRANGE key start stop
    return await this.redisClient.lRange(key, 0, -1);
  }

  async listSet(key: string, list: string[], ttl?: number) {
    for (let i = 0; i < list.length; i++) {
      await this.redisClient.lPush(key, list[i]);
    }
    if (ttl) {
      await this.redisClient.expire(key, ttl);
    }
  }
}

Redis中,LRANGE是一个用于获取列表(List)的指令,lRange代码相当于LRANGE key start stopRedisLPUSH命令用于将一个或多个值插入到列表的头部,LPUSH key value [value ...]

  • 将RedisService注入到PermissionGuard,修改PermissionGuard
import {
  CanActivate,
  ExecutionContext,
  Inject,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { UserService } from 'src/user/user.service';
import { Request } from 'express';
import { Permission } from 'src/user/entities/permission.entity';
import { Reflector } from '@nestjs/core';
import { RedisService } from 'src/redis/redis.service';

@Injectable()
export class PermissionGuard implements CanActivate {
  @Inject(UserService) private userService: UserService;
  @Inject(Reflector) private reflector: Reflector;
  //! 注入redisService
  @Inject(RedisService) private redisService: RedisService;

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request: Request = context.switchToHttp().getRequest();
    if (!request.user) {
      return true;
    }
    //!得假设用户名不能重复了哈
    let permissions = await this.redisService.listGet(
      `user_${request.user.username}_permission`,
    );
    console.log(permissions);

    if (permissions.length == 0) {
      console.log('请求数据库');

      //! 可能一个用户是多个角色
      const roles = await this.userService.findRoleByIds(
        request.user.roles.map((item) => item.id),
      );
      let permissionsList: Permission[] = roles.reduce((total, current) => {
        total.push(...current.permissions);
        return total;
      }, []);
      permissions = permissionsList.map((item) => item.name);

      this.redisService.listSet(
        `user_${request.user.username}_permission`,
        permissions,
        60 * 30,
      );
    }

    //! 获取当前handler的元数据
    const requirePermissions = this.reflector.getAllAndOverride(
      'require-permission',
      [context.getClass(), context.getHandler()],
    );

    const isPermit = permissions.some((item) => {
      return item == requirePermissions;
    });
    if (isPermit || requirePermissions == undefined) {
      return true;
    } else {
      throw new UnauthorizedException('您没有权限访问该接口');
    }
  }
}

首先查询是否有该用户的权限存放在redis中,如果没有就查询数据库,并将查找的权限结果存入redis中,并设置过期时间为30分钟。下次再访问的时候直接从redis中取。

  • 测试下

使用李四访问他没有权限访问的bbb

nestjs学习:nestjs实现简单RBAC权限控制

nestjs学习:nestjs实现简单RBAC权限控制

使用李四访问他有权限访问的aaa

nestjs学习:nestjs实现简单RBAC权限控制

nestjs学习:nestjs实现简单RBAC权限控制

34行的console.log('请求数据库');没有打印,说明确实从redis中取了数据。

nestjs学习:nestjs实现简单RBAC权限控制

RedisInsight中可以看出多了李四的数据。

总结

转载自:https://juejin.cn/post/7266463919141060623
评论
请登录