前言

在之前的文章,我们实现了一个jwt的权限管理,通过一个自定义的Public装饰器声明公共接口,再通过app.module.ts添加一个全局守卫JwtAuthGuard

但是这种方式只能判断这个用户是登录了还是未登录了,没法实现更细化的权限管理,比如一个普通用户,他是无法管理自己的账号的,而管理员缺可以控制普通用户的账号,是禁用还是正常使用。

显然我们需要一个更加细化的权限管理方式,经典就是 RBAC(Role-Based Access Control)角色访问控制设计。

RBAC

在RBAC中是通过不同的角色来实现权限的划分,比如普通用户user只能查看文章列表,而admin管理员是可以增删改查文章的。

我们定义不同的角色(Role),然后用户与角色进行绑定,我们在后续的后端代码实现中,就不需要关心是哪个用户,而只需要关心需要哪个角色即可。

在一些简化的权限管理中,设计上就会如下:

bash
复制代码
用户 ----> 角色 ----> 控制器鉴权

随着业务的增长,不同的业务需要的角色也越来越多,这就导致了角色爆炸,为了避免这种问题,一种基于权限的访问控制PBAC出现了。

我们定义一系列的权限,也就是所谓的Permissions,给不同的角色赋予权限,比如article-view、 article-delete,他们被分配到不同的角色上,这样在一些细微的差异中我们只需要调整角色的权限,而不用去创建新的角色。

bash
复制代码
用户 ----> 角色 ----> 权限 ----> 控制器鉴权

事实上这种设计也仅仅是RBAC中的基本模型,RBAC本身是有分不同的等级的,不同等级对应不同复杂程度的鉴权逻辑。

根据 NIST (美国国家标准与技术研究院)的定义,通常 RBAC 有四个不同等级或模型:

  1. RBAC0(基本 RBAC)

    • 这是最简单的 RBAC 模型,它包括最基本的概念:用户(Users)、角色(Roles)和权限(Permissions)。在这个模型中,用户被分配到角色,角色被分配权限,用户通过自己的角色获得权限。没有角色之间的关系,比如继承关系。
  2. RBAC1(具有角色继承的 RBAC)

    • 在基本的 RBAC 模型上增加了角色继承的概念。角色继承或角色层次结构允许一个角色继承另一个角色的权限,从而简化了权限管理。例如,设有一个“经理”角色继承了“员工”角色,那么“经理”将同时拥有“员工”的所有权限以及一些额外的管理权限。
  3. RBAC2(具有约束的 RBAC)

    • 这个模型在 RBAC0 基础上添加了对如何分配角色的限制规则。这些规则包括分离责任(Separation of Duty,SoD),比如静态分离责任(Static SoD),确保一个用户不能被分配到冲突的角色,以及动态分离责任(Dynamic SoD),确保在一段时间内,用户不能同时执行互相冲突的任务或访问冲突的资源。
  4. RBAC3(完整的 RBAC)

    • 这个模型结合了 RBAC1 和 RBAC2 的特性,提供了一个完整的 RBAC 模型,具有角色继承、角色约束(包括静态和动态的责任分离)等功能。

这些 RBAC 等级模型为系统管理员提供了在不同情况下执行访问控制的灵活性,既可以实现简单的 RBAC 需求,也可以复杂的权限和角色逻辑关系。随着安全需求的增加和组织结构的复杂化,通常会采用更高级别的 RBAC 模型。

在一些角色众多的业务场景中,也会有将不同的角色分门别类的做法,比如论坛中常见的用户组,一些付费的情况下,会分为普通用户组和vip用户组,那么后端的权限要求可能就会要求具体的用户组,而不是角色或者权限了。

教程

prisma中创建角色表并于用户表关联

schema.prisma

typescript
复制代码
// 用户表 model User { id Int @id @default(autoincrement()) @db.UnsignedInt email String password String avatar String? github String? qq String? roles Role[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } // 角色表 model Role { id Int @id @default(autoincrement()) @db.UnsignedInt name String description String? permissions String users User[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }

用户表中roles去关联角色表,角色表中的users去关联用户,他们是一个多对多的关系。

这么配置,prisma会自己单独创建一个表用于管理这个关系,这个是不需要我们操心的。

角色表中:

  1. name,具体的名称,用于判断的条件。
  2. description,描述信息。
  3. permissions,权限信息,暂时不做内容处理,以后扩展可用。

模型数据配置完毕后,我们运行命令:prisma migrate dev 生成新的迁移文件。

我们还可以自己写一个脚本去填充一下用户和角色,示例:

typescript
复制代码
// helper.ts import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); /** 创建数据 */ export async function create(count = 1, callback: (prisma: PrismaClient) => Promise<any>) { for (let i = 0; i < count; i++) { await callback(prisma); } } // role.ts import { create } from "../helper"; /** 生成角色数据 */ const roles = [ { name: "user", description: "普通用户", permissions: "all" }, { name: "admin", description: "管理员", permissions: "all" } ]; export function createRole() { return create(1, (prisma) => { return prisma.role.createMany({ data: roles }); }); } // user.ts import { create } from "../helper"; import { Random } from "mockjs"; import { hash } from "../../src/utils/md5"; /** 生成用户数据 */ export function createUser() { return create(10, async (prisma) => { const adminRole = await prisma.role.findFirst({ where: { name: "admin" } }); if (!adminRole) throw new Error("没有找到角色"); return prisma.user.create({ data: { email: Random.email(), password: hash("123456", process.env.MD5_SALT), avatar: Random.image("200x200"), github: Random.url(), qq: Random.integer(1000000000, 9999999999).toString(), roles: { connect: [{ id: adminRole.id }] } } }); }); }

因为是角色是已经创建好的,所以在创建用户时,通过connect进行关联,否则是通过create,具体自己看prisma文档。

这个生成数据的方法会被seed.ts文件引入并运行,而seed.ts文件会被配置在packag.json中的:

json
复制代码
{ "prisma": { "seed": "ts-node prisma/seeds/index.ts" }, }

然后我们运行命令:prisma migrate reset 会对数据库进行重置,并运行seed脚本,seed脚本里的函数运行就会填充数据了。

现在数据有了,继续下一步

创建一个角色要求装饰器

我们怎么知道访问这个api是需要什么样的角色?显然我们需要有一个地方能给配置,在nestjs中,我们自然是通过装饰器去实现这个功能,这个和jwt鉴权差不多。

bash
复制代码
nest g d utils/decorators/role --no-spec

cli命令生成一个role.decorator.ts文件。

typescript
复制代码
import { SetMetadata } from "@nestjs/common"; /** 角色枚举 */ export enum RoleEnum { /** 普通用户 */ USER = "user", /** 管理员 */ ADMIN = "admin" } export const ROLES_KEY = "roles"; export const Roles = (...roles: RoleEnum[]) => SetMetadata(ROLES_KEY, roles);

通过SetMetadata我们给需要装饰的对象,元数据上添加数据。

使用的时候:

typescript
复制代码
import { Controller } from "@nestjs/common"; import { Roles, RoleEnum } from "src/utils/decorators"; @Controller("upload") @Roles(RoleEnum.ADMIN) export class UploadController { }

这样就表示整个控制器都需要角色必须是RoleEnum.ADMIN

当然我们也可以给具体某个方法装饰:

typescript
复制代码
import { Controller, Post } from "@nestjs/common"; import { Roles, RoleEnum } from "src/utils/decorators"; @Controller("upload") @Roles(RoleEnum.ADMIN) export class UploadController { @Post("/up") @Roles(RoleEnum.ADMIN) up() {} }

这样就表示/up需要角色必须是RoleEnum.ADMIN

jwt校验时将角色数据链表查询出来

在之前的文章中我们已经实现了一个jwt的校验(passport),它会在验证通过后,会将用户的信息赋值到 request.user,而这个用户的数据也是我们自己写的查询代码,所以改动下这里。

找到jwt.strategy.ts这个文件,这个是用于实现passport-jwt的文件,在validate方法中加入include链表操作:

typescript
复制代码
import { Strategy, ExtractJwt } from "passport-jwt"; import type { StrategyOptions } from "passport-jwt"; import { PassportStrategy } from "@nestjs/passport"; import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PrismaService } from "src/prisma/prisma.service"; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, "jwt") { constructor( private readonly config: ConfigService, private readonly prisma: PrismaService ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: config.get("TOKEN_SECRET") } as StrategyOptions); } /** token解析成功后通过sub(id)查询用户并返回 */ async validate({ sub }) { return await this.prisma.user.findUnique({ where: { id: sub }, // 加入这个 include: { roles: true } }); } }

此时原来的用户数据变为如下:

json
复制代码
{ id: 1, email: 'd.lcwb@phk.ve', password: 'bf165bf3aba4ccd6a311463e44d34312', avatar: 'http://dummyimage.com/200x200', github: 'nntp://vsmeicjhlm.co/twjfvrpbox', qq: '5071331581', createdAt: 2024-01-12T09:22:17.182Z, updatedAt: 2024-01-12T09:22:17.182Z, roles: [ { id: 2, name: 'admin', description: '管理员', permissions: 'all', createdAt: 2024-01-12T09:22:17.175Z, updatedAt: 2024-01-12T09:22:17.175Z } ] }

它会有一个roles字段,是一个数组,里面就是对应的角色数据了。

实现一个角色守卫

bash
复制代码
nest g gu utils/guards/role --no-spec

生成了一个role.guard.ts文件。

typescript
复制代码
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; import { Observable } from "rxjs"; import { Reflector } from "@nestjs/core"; import { RoleEnum, ROLES_KEY } from "@/utils/decorators"; @Injectable() export class RoleGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> { const requiredRoles = this.reflector.getAllAndOverride<RoleEnum[]>(ROLES_KEY, [ context.getHandler(), context.getClass() ]); if (!requiredRoles) { return true; } const { user } = context.switchToHttp().getRequest(); const userRoles: string[] = []; if (Array.isArray(user.roles)) { userRoles.push(...user.roles.map((role) => role.name)); } return requiredRoles.some((role) => userRoles.includes(role)); } }

通过getAllAndOverride得到要求的角色数组。

context.switchToHttp().getRequest()得到request对象,从上面取出user属性,然后判断这个用户的roles角色数组是否有符合要求的。

然后返回布尔值,如果返回的是布尔值,默认的报错信息是:Forbidden resource,错误码403,如果你希望自定义错误信息,就自己throw抛出nestjs预设的错误对象,传入对应的错误信息即可。

激活角色守卫

由于在角色守卫中我们使用了jwt守卫提供的user数据,所以我们一定要保证jwt的守卫优先级在我们前面,所以注册的时候注意顺序。

app.module.ts

typescript
复制代码
import { Module } from "@nestjs/common"; import { JwtModule } from "@nestjs/jwt"; import { JwtStrategy } from "./utils/passport"; import { JwtAuthGuard, RoleGuard } from "./utils/guards"; import { APP_GUARD} from "@nestjs/core"; @Module({ imports: [ JwtModule.registerAsync({ global: true, inject: [ConfigService], useFactory: (configService: ConfigService) => { return { secret: configService.get("TOKEN_SECRET"), signOptions: { expiresIn: "30d" } }; } }) ], controllers: [], providers: [ JwtStrategy, { provide: APP_GUARD, useClass: JwtAuthGuard }, { provide: APP_GUARD, useClass: RoleGuard }, ] }) export class AppModule {}

至此,我们就可以通过使用@Role()装饰器来实现角色要求,从而避免单一jwt权限的问题了。

官方文档

事实上官方也是有对应文档的,大家可以自己参考一下:权限(Authorization)

分类: Nest.js 标签: 权限NestjsRBACPUAC角色校验Role

评论

全部评论 4

  1. alterass
    alterass
    Google Chrome Windows 10
    这个注册登录是假的啊
    1. 木灵鱼儿
      木灵鱼儿
      FireFox Windows 10
      @alterass 你被骗了
  2. 暴龙战士
    暴龙战士
    Google Chrome Windows 10
    wc吊,基本上一模一样
    1. 木灵鱼儿
      木灵鱼儿
      FireFox Windows 10
      @暴龙战士啥一模一样

目录