Nest.js 系列——在 Nest.js 中使用 rbac 进行权限控制

前言

一般后台的系统都是需要权限控制的,而且最常用的就是 rbac 权限控制,这里我们就来看看如何在 nest 中使用 rbac 进行权限控制。rbac 的权限控制主要分为三个部分,用户、角色、权限,用户和角色是多对多的关系,角色和权限也是多对多的关系,用户和权限是多对多的关系,这里我们就来看看如何在 nest 中使用 rbac 进行权限控制。

由上图可以看出,先把权限分配给角色,然后在把角色分配给用户,这样就可以实现权限控制了。如果想要修改一个用户的权限,只需要修改角色的权限就可以了,这样就可以实现权限的统一管理。那就在 nest 简单实践先 rabc 权限吧。

创建数据库

首先我们需要创建数据库,这里我们使用 mysql 数据库,创建数据库的 sql 语句如下:

sql 复制代码
CREATE DATABASE `nest_rbac` DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;

创建项目

创建项目的命令如下:

bash 复制代码
nest new nest-rbac

安装 typorm 相关依赖

bash 复制代码
npm install --save @nestjs/typeorm typeorm mysql2

配置 typeorm 相关配置,打开app.module.ts文件:

typescript 复制代码
import { Module } from '@nestjs/common'
import { AppController } from './app.controller'
import { AppService } from './app.service'
import { TypeOrmModule } from '@nestjs/typeorm'

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: '123456',
      database: 'nest_rbac',
      synchronize: true,
      logging: true,
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      poolSize: 10,
      connectorPackage: 'mysql2'
    })
  ],
  controllers: [AppController],
  providers: [AppService]
})
export class AppModule {}

创建用户模块

bash 复制代码
nest g resource user

数据库表之间的关系如图所示:

然后添加用户、角色、权限的实体类,在user/entity文件夹下添加:

  • user.entity.ts
typescript 复制代码
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[]
}

通过@ManyToMany 装饰器来表示多对多的关系,然后通过@JoinTable 来指定关联表user_role_relation

  • role.entity.ts
typescript 复制代码
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[]
}

通过@ManyToMany 装饰器来表示多对多的关系,然后通过@JoinTable 来指定关联表role_permission_relation

  • permission.entity.ts
typescript 复制代码
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
}

然后重新运行项目,会自动创建表。这样一个初始化项目就建立好了,接下来开始实现 rbac 权限控制。首先先对数据库添加一些测试数据,通过定义一个接口方法来添加。在user/user.service.ts文件中添加一个 initData 方法来初始化数据:

typescript 复制代码
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'
import { InjectEntityManager } from '@nestjs/typeorm'
import { CreateUserDto } from './dto/create-user.dto'
import { UpdateUserDto } from './dto/update-user.dto'
import { EntityManager, In } from 'typeorm'
import { User } from './entities/user.entity'
import { Role } from './entities/role.entity'
import { Permission } from './entities/permission.entity'
import { LoginUserDto } from './dto/login-user.dto'

@Injectable()
export class UserService {
  @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])
  }

  async login(loginUserDto: LoginUserDto) {
    const user = await this.entityManager.findOne(User, {
      where: {
        username: loginUserDto.username
      },
      relations: ['roles']
    })

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

    if (user.password !== loginUserDto.password) {
      throw new HttpException('密码错误', HttpStatus.ACCEPTED)
    }

    return user
  }

  async findRolesByIds(roleIds) {
    return this.entityManager.find(Role, {
      where: {
        id: In(roleIds)
      },
      relations: {
        permissions: true
      }
    })
  }

  findOne(id: number) {
    return `This action returns a #${id} user`
  }

  update(id: number, updateUserDto: UpdateUserDto) {
    return `This action updates a #${id} user`
  }

  remove(id: number) {
    return `This action removes a #${id} user`
  }
}

以上初始化的 rbac 权限如图所示:

定义一个 user 的控制器路由来初始化数据,在user/user.controller.ts文件中添加:

typescript 复制代码
@Get('init')
async initData() {
    await this.userService.initData();
    return 'done';
}

直接调用初始化数据接口,然后就可以在数据库中看到初始化的数据了。

使用 jwt 实现登录

关于登录就不展开说了,如果还不明白的可以看下之前的 jwt 文章,这里直接使用 jwt 实现登录。首先定义下登录的路由,在user/user.controller.ts文件中添加:

typescript 复制代码
@Post('login')
async login(@Body() loginUserDto: LoginUserDto) {
    const user = await this.userService.login(loginUserDto);
    const payload = {
        username: user.username,
        sub: user.id,
        roles: user.roles.map(role => role.name),
        permissions: user.roles.map(role => role.permissions.map(permission => permission.name)).flat()
    };
    return {
        access_token: this.jwtService.sign(payload)
    };
}

dto 文件夹下需要对参数进行校验,需要安装class-validatorclass-transformer依赖:

bash 复制代码
npm install --save class-validator class-transformer

然后在user/dto文件夹下添加login-user.dto.ts文件:

typescript 复制代码
import { IsNotEmpty } from 'class-validator'

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

  @IsNotEmpty()
  @Length(1, 50)
  password: string
}

然后在user/user.service.ts文件中添加 login 方法:

typescript 复制代码
async login(loginUserDto: LoginUserDto) {
    const user = await this.entityManager.findOne(User, {
        where: {
            username: loginUserDto.username
        },
        relations: ['roles']
    });

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

    if (user.password !== loginUserDto.password) {
        throw new HttpException('密码错误', HttpStatus.ACCEPTED);
    }

    return user;
}

需要全局开启验证,打开main.ts文件:

typescript 复制代码
async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  // 开启全局验证
  app.useGlobalPipes(new ValidationPipe())
  await app.listen(3000)
}
bootstrap()

需要把 user 的信息放到 jwt,安装@nestjs/jwt依赖:

bash 复制代码
npm install --save @nestjs/jwt

然后在app.module.ts文件中添加:

typescript 复制代码
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'
import { JwtModule } from '@nestjs/jwt'

@Module({
  imports: [
      JwtModule.register({
        global: true,
        secret: 'water',
        signOptions: {
          expiresIn: '1d'
        }
      })
  ]
})

设置为全局模块,然后各个模块都可以使用了。

tsx 复制代码
@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
  }
}

这样就通过 jwt 实现了登录,然后就可以通过 token 来获取用户信息了。但是现在有一个问题就说所有的接口都需要登录鉴权才能访问了,其实有些接口是不需要登录鉴权的,比如登录接口,这里我们就需要对接口进行分类,然后对不同的接口进行不同的鉴权。这里我们就需要使用到 nest 的路由守卫了。

使用路由守卫实现鉴权

使用 cli 创建一个路由守卫:

bash 复制代码
nest g guard login --no-spec --flat

login.guard.ts文件中修改判断逻辑:

typescript 复制代码
import {
  CanActivate,
  ExecutionContext,
  Inject,
  Injectable,
  UnauthorizedException
} from '@nestjs/common'
import { JwtService } from '@nestjs/jwt'
import { Request } from 'express'
import { Observable } from 'rxjs'

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 失效,请重新登录')
    }
  }
}

如果在每一个需要鉴权的路由上加守卫,有点太麻烦了,这里可以直接在全局加上守卫。在 app.module.ts 文件中添加:

typescript 复制代码
providers: [
  {
    provide: APP_GUARD,
    useClass: LoginGuard
  }
]

这样这个守卫就会对所有的路由进行鉴权了,但是这样就会有一个问题,就是登录接口也会被鉴权,这样就会导致登录接口无法访问了,这里我们就需要对登录接口进行排除。先声明一个自定义装饰器,来标识哪些接口需要鉴权。

typescript 复制代码
import { SetMetadata } from '@nestjs/common'

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

然后需要改造下 LoginGuard,来判断是否需要鉴权。

typescript 复制代码
const requireLogin = this.reflector.getAllAndOverride('require-login', [
  context.getClass(),
  context.getHandler()
])

console.log(requireLogin)

if (!requireLogin) {
  return true
}

这样登录鉴权的功能就实现了,但是这里还有一个问题,就是如果用户没有权限访问某个接口,这里就需要对接口进行权限控制了。创建一个权限守卫:

bash 复制代码
nest g guard permission --no-spec --flat

permission.guard.ts文件中添加:

typescript 复制代码
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common'
import { Request } from 'express'
import { Observable } from 'rxjs'
import { UserService } from './user.service'

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

  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    console.log(this.userService)

    return true
  }
}

userservice 中添加一个方法,来查询角色信息:

typescript 复制代码
async findRolesByIds(roleIds: number[]) {
    return this.entityManager.find(Role, {
      where: {
        id: In(roleIds)
      },
      relations: {
        permissions: true
      }
    });
}

然后在permission.guard.ts文件中添加:

typescript 复制代码
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common'
import { Request } from 'express'
import { UserService } from './user/user.service'

@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.findRolesByIds(request.user.roles.map((item) => item.id))

    const permissions: Permission[] = roles.reduce((total, current) => {
      total.push(...current.permissions)
      return total
    }, [])

    console.log(permissions)

    return true
  }
}

这样就可以获取到用户的权限了,然后就可以对接口进行权限控制了。这里我们就需要在自定义装饰器中添加一个权限的标识。

typescript 复制代码
export const RequirePermission = (permissions: string[]) =>
  SetMetadata('require-permission', permissions)

然后在permission.guard.ts文件中添加:

typescript 复制代码
const requirePermission = this.reflector.getAllAndOverride<string[]>('require-permission', [
  context.getClass(),
  context.getHandler()
])

for (let i = 0; i < requiredPermissions.length; i++) {
  const curPermission = requiredPermissions[i]
  const found = permissions.find((item) => item.name === curPermission)
  if (!found) {
    throw new UnauthorizedException('您没有访问该接口的权限')
  }
}

小结

通过以上的实践,我们就可以在 nest 中使用 rbac 进行权限控制了,这里只是简单实践了下。在业务中使用还需要多多练习。

相关推荐
GISer_Jing1 小时前
前端面试通关:Cesium+Three+React优化+TypeScript实战+ECharts性能方案
前端·react.js·面试
落霞的思绪2 小时前
CSS复习
前端·css
咖啡の猫4 小时前
Shell脚本-for循环应用案例
前端·chrome
uzong5 小时前
面试官:Redis中的 16 库同时发送命令,服务端是串行执行还是并行执行
后端·面试·架构
百万蹄蹄向前冲6 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
追逐时光者7 小时前
.NET 使用 MethodTimer 进行运行耗时统计提升代码的整洁性与可维护性!
后端·.net
朝阳5817 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路7 小时前
GeoTools 读取影像元数据
前端
ssshooter7 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
你的人类朋友8 小时前
【Node.js】什么是Node.js
javascript·后端·node.js