前言

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

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

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

RBAC

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

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

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

用户 ----> 角色 ----> 控制器鉴权

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

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

用户 ----> 角色 ----> 权限 ----> 控制器鉴权

事实上这种设计也仅仅是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

// 用户表
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 生成新的迁移文件。

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

// 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中的:

{
  "prisma": {
        "seed": "ts-node prisma/seeds/index.ts"
    },
}

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

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

创建一个角色要求装饰器

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

nest g d utils/decorators/role --no-spec

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

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我们给需要装饰的对象,元数据上添加数据。

使用的时候:

import { Controller } from "@nestjs/common";
import { Roles, RoleEnum } from "src/utils/decorators";

@Controller("upload")
@Roles(RoleEnum.ADMIN)
export class UploadController {

}

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

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

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链表操作:

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
            }
        });
    }
}

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

{
  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字段,是一个数组,里面就是对应的角色数据了。

实现一个角色守卫

nest g gu utils/guards/role --no-spec 

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

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

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

评论

全部评论 2

  1. 暴龙战士
    暴龙战士
    Google Chrome Windows 10
    wc吊,基本上一模一样
    1. 木灵鱼儿
      木灵鱼儿
      FireFox Windows 10
      @暴龙战士啥一模一样

目录