Nestjs 实现角色权限管理RBAC以及拓展的PBAC
前言
在之前的文章,我们实现了一个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 有四个不同等级或模型:
RBAC0(基本 RBAC):
- 这是最简单的 RBAC 模型,它包括最基本的概念:用户(Users)、角色(Roles)和权限(Permissions)。在这个模型中,用户被分配到角色,角色被分配权限,用户通过自己的角色获得权限。没有角色之间的关系,比如继承关系。
RBAC1(具有角色继承的 RBAC):
- 在基本的 RBAC 模型上增加了角色继承的概念。角色继承或角色层次结构允许一个角色继承另一个角色的权限,从而简化了权限管理。例如,设有一个“经理”角色继承了“员工”角色,那么“经理”将同时拥有“员工”的所有权限以及一些额外的管理权限。
RBAC2(具有约束的 RBAC):
- 这个模型在 RBAC0 基础上添加了对如何分配角色的限制规则。这些规则包括分离责任(Separation of Duty,SoD),比如静态分离责任(Static SoD),确保一个用户不能被分配到冲突的角色,以及动态分离责任(Dynamic SoD),确保在一段时间内,用户不能同时执行互相冲突的任务或访问冲突的资源。
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会自己单独创建一个表用于管理这个关系,这个是不需要我们操心的。
角色表中:
name
,具体的名称,用于判断的条件。description
,描述信息。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)
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
全部评论 4
alterass
Google Chrome Windows 10木灵鱼儿
FireFox Windows 10暴龙战士
Google Chrome Windows 10木灵鱼儿
FireFox Windows 10