likes
comments
collection
share

NestJS14-授权

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

授权

授权指的是进程来决定用户能做些什么。例如,允许管理员用户进行创建,编辑,和删除帖子。没有管理权限的用户只能被授权阅读帖子。

授权与身份验证是正交且独立的。然而,授权需要一个身份验证机制。

有许多不同的方法和策略来处理授权。任何项目采取的方法都取决于其特定的应用需求。本章节展示一些授权方法,这些方法能够适用各种不同的需求。

基本RBAC实现

首先,让我们创建一个Role枚举用来表示系统中的角色

export enum Role {
  User = 'user',
  Admin = 'admin',
}

在更加复杂的系统中,您可能会用数据库来存储角色,或者由外部验证提供

有了这个,我们能够创建@Roles()装饰器。这个装饰器允许角色们访问特定的资源。

import { SetMetadata } from '@nestjs/common';
import { Role } from '../enums/role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

现在我们有了自定义的@Roles()装饰器,我们可以使用它来装饰任何路由。

@Post()
@Roles(Role.Admin)
create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

最后,我们创建RolesGuard类用来比较当前实际的用户角色和当前路由所分配的角色。为了访问路由上的角色(custom metadata),我们将要使用Reflector帮助类,这是由框架直接提供的,并从@nestjs/core包中暴露出来的。

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles) {
      return true;
    }
    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some((role) => user.roles?.includes(role));
  }
}

请参考“执行上下文”章节中的“Reflection and metadata(反射和元数据)”部分,以更具上下文敏感性的方式使用 Reflector 的详细信息。

这里的例子叫“基本”我们只验证在路由层面上的当前的角色。在真实世界应用中,您或许有许多操作的端点,他们每一个都有不同的权限。在这种情况下,在这种情况下,您将需要在业务逻辑中的某个地方提供一个检查角色的机制,这使得其维护变得有些困难,因为没有一个集中的地方将权限与特定操作关联起来。

为了确定这个例子能正常工作,您的User类看上去需要像下面这样:

class User {
  // ...other properties
  roles: Role[];
}

后面,请确认注册了RoleGuard,例如,在控制器层面或者全局:

providers: [
  {
    provide: APP_GUARD,
    useClass: RolesGuard,
  },
],

当用户在请求端点时权限不足,Nest会自动返回下面的内容:

{
  "statusCode": 403,
  "message": "Forbidden resource",
  "error": "Forbidden"
}

如果您想要返回不同的异常结果,您应该抛出您自己的异常而不是返回一个布尔值。

基于声明的授权

当创建身份时,它可能会被分配一个或多个由受信任的方发出的声明。声明是一个名称-值对,它代表主题可以做什么,而不是主题是什么。

为了在NestJS中实现基于声明的授权,您可以根据上面RBAC章节的同样的步骤来配置,只有一点不一样:替代特定的角色检查,您应该比较声明许可(permissions)。每个用户应该被分配了一些许可。同样的,每个资源/端点应该定义什么许可(例如,通过@RequrePermissions()装饰器)可以访问他们。

@Post()
@RequirePermissions(Permission.CREATE_CAT)
create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

在上面的例子中,在您的系统中,许可(和上面RBAC中的角色类似)是一个TypeScript枚举它包含了所有可获得的许可

集成 CASL

CASL是一个同构授权库,它限制哪个客户端允许访问资源。它旨在逐步采用,并且可以轻松地在简单的基于声明的授权与完全特性的基于主题和属性的授权之间进行扩展。

为了开始,我们需要先安装@casl/ability库:

$ npm i @casl/ability

在这个例子中,我们选择CASL,但是您可以任何其他的库,像accesscontrol或者acl,取决于您的技术喜好和项目需要

一旦安装完成,为了说明CASL的机制,我们将要定义2个实体类:UserArticle

class User {
  id: number;
  isAdmin: boolean;
}

User类由2个属性组成,id,User的唯一标识,和isAdmin,指这个用户是否有管理员权限。

class Article {
  id: number;
  isPublished: boolean;
  authorId: number;
}

Article类有3个属性,idisPublishedauthorIdid是一个唯一标识符,isPublished指文章是不是已经发布了,authorId指哪个用户的ID,这个用户写了这篇文章。

现在让为了这个例子我们复习和提炼我们的需求

  • 管理员可以管理(增删改查)所有的实体
  • 用户只有读的权限
  • 用户可以更新他们自己的文章(article.authorId === userId
  • 文章一旦被发布了就不能被删除(article.isPublished === true)

牢记这一点,我们可以首先创建一个表示用户可以对实体执行的所有可能操作的Action枚举。

export enum Action {
  Manage = 'manage',
  Create = 'create',
  Read = 'read',
  Update = 'update',
  Delete = 'delete',
}

在CASL中 manage是一个特殊的关键字,相当于“任何(any)”行为。

为了概括CASL库,现在让我们生成CaslModuleCaslAbilityFactory

$ nest g module casl
$ nest g class casl/casl-ability.factory

在这里面,我们能够在CaslAbilityFactory中定义createForUser()方法,这个方法将为给定的用户创建Ability对象。

type Subjects = InferSubjects<typeof Article | typeof User> | 'all';

export type AppAbility = Ability<[Action, Subjects]>;

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: User) {
    const { can, cannot, build } = new AbilityBuilder<
      Ability<[Action, Subjects]>
    >(Ability as AbilityClass<AppAbility>);

    if (user.isAdmin) {
      can(Action.Manage, 'all'); // read-write access to everything
    } else {
      can(Action.Read, 'all'); // read-only access to everything
    }

    can(Action.Update, Article, { authorId: user.id });
    cannot(Action.Delete, Article, { isPublished: true });

    return build({
      // Read https://casl.js.org/v5/en/guide/subject-type-detection#use-classes-as-subject-types for details
      detectSubjectType: (item) =>
        item.constructor as ExtractSubjectType<Subjects>,
    });
  }
}

在CASL中all是一个特殊的关键字,表示“任何主题”

AbilityAbilityBuilderAbilityClass, 和 ExtractSubjectType 类都是从 @casl/ability 库中导出的。

detectSubjectType 选项让CASL明白如何去获取对象的主题类型。要了解更多的详细信息请阅读 CASL documentation

在上面的例子中,我们使用AbilityBuilder创建了Ability类实例。和您想的一样,cancannot接受同样的参数但是有不同的意义,can允许特定的主题去做自己的行动,cannot则是禁止。他们都接受4个参数。想要学习更多关于这些方法的内容,请看官网文档

最后,确认在CaslModule中的providersexports数组中添加了CaslAbilityFactory

import { Module } from '@nestjs/common';
import { CaslAbilityFactory } from './casl-ability.factory';

@Module({
  providers: [CaslAbilityFactory],
  exports: [CaslAbilityFactory],
})
export class CaslModule {}

有了这个,只要在主宿主上下文中导入了CaslModule,我们就可以使用标准的构造函数注入方式将CaslAbilityFactory注入到任何类中。

constructor(private caslAbilityFactory: CaslAbilityFactory) {}

然后就能在类中使用它了:

const ability = this.caslAbilityFactory.createForUser(user);
if (ability.can(Action.Read, 'all')) {
  // "user" has read access to everything
}

学习更多关于Ability类相关的知识,请查看官方文档

例如,假设我们有一个不是管理员的用户。在这种情况下,这个用户应该能够读文章,但是禁止创建新的文章或者删除一个已经存在的文章。

const user = new User();
user.isAdmin = false;

const ability = this.caslAbilityFactory.createForUser(user);
ability.can(Action.Read, Article); // true
ability.can(Action.Delete, Article); // false
ability.can(Action.Create, Article); // false

虽然AbilityAbilityBuilder类都提供cancannot方法,但是他们有不同的目的和接受稍微不同的参数。

当然,根据我们的需求,这个用户应该能更新他自己的文章。

const user = new User();
user.id = 1;

const article = new Article();
article.authorId = user.id;

const ability = this.caslAbilityFactory.createForUser(user);
ability.can(Action.Update, article); // true

article.authorId = 2;
ability.can(Action.Update, article); // false

正如您看到的那样,Ability实例用一种易懂的方式允许我们来检查行为。同样,AbilityBuilder允许我们用相似的方式定义行为。为了了解更多信息,请看官方文档。

进阶:实现PoliciesGuard

在这个部分,我们将展示如何构建一个稍微复杂一些的守卫,该守卫检查用户是否满足可以在方法级别配置的特定授权策略(您也可以扩展它以尊重在类级别配置的策略)。在这个例子中,我将使用CASL库仅仅为了说明目的,但是使用这个库不是必须的。当然,我将使用在前几个部分创建的CaslAbilityFactoryprovider。

首先,让我们充实需求。目的是提供一个机制在每一个路由上能够允许特定的验证策略。我将支持对象和方法(针对更简单的检查以及那些更喜欢函数式编程风格的人)

让我们开始定义策略的接口

import { AppAbility } from '../casl/casl-ability.factory';

interface IPolicyHandler {
  handle(ability: AppAbility): boolean;
}

type PolicyHandlerCallback = (ability: AppAbility) => boolean;

export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;

之前提到,我提供2种可能的方式来定义策略,对象(一个类的实例它实现了IPolicyHandler接口)和一个方法(它是PolicyHandlerCallback类型)

有个这个,我能创建@CheckPolicies()装饰器。这个装饰器允许特定的策略能够访问特定的资源。

export const CHECK_POLICIES_KEY = 'check_policy';
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
  SetMetadata(CHECK_POLICIES_KEY, handlers);

现在,让我们创建一个PoliciesGuard它能针对路由执行限制和执行所有的策略。

@Injectable()
export class PoliciesGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private caslAbilityFactory: CaslAbilityFactory,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const policyHandlers =
      this.reflector.get<PolicyHandler[]>(
        CHECK_POLICIES_KEY,
        context.getHandler(),
      ) || [];

    const { user } = context.switchToHttp().getRequest();
    const ability = this.caslAbilityFactory.createForUser(user);

    return policyHandlers.every((handler) =>
      this.execPolicyHandler(handler, ability),
    );
  }

  private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) {
    if (typeof handler === 'function') {
      return handler(ability);
    }
    return handler.handle(ability);
  }
}

让我分析这个例子,policyHandlers是通过@CheckPolicies()被分配的数组句柄。接着,我使用构造了Ability对象的CaslAbilityFactory#create方法,允许我验证用户是否有适当的许可来执行这个操作。我传递这个对象去策略句柄,他可以是一个方法或者一个实现了IPolicyHandler接口的类的实例,暴露了一个返回布尔值的handle()方法。最后,我使用Array#every方法来确认每个句柄返回了true

最终,为了测试这个守卫,将它绑定到任意路由上,并注册一个策略:

@Get()
@UseGuards(PoliciesGuard)
@CheckPolicies((ability: AppAbility) => ability.can(Action.Read, Article))
findAll() {
  return this.articlesService.findAll();
}

或,我能定义一个实现了IPolicyHandler接口的类:

export class ReadArticlePolicyHandler implements IPolicyHandler {
  handle(ability: AppAbility) {
    return ability.can(Action.Read, Article);
  }
}

然后像下面这样使用

@Get()
@UseGuards(PoliciesGuard)
@CheckPolicies(new ReadArticlePolicyHandler())
findAll() {
  return this.articlesService.findAll();
}

由于我们必须使用new关键字在原地实例化策略处理器,因此ReadArticlePolicyHandler类不能使用依赖注入。这可以通过ModuleRef#get方法解决(在此阅读更多信息)。基本上,与其通过@CheckPolicies()装饰器注册函数和实例,不如允许传递一个Type<IPolicyHandler>。然后,在你的守卫内部,你可以使用类型引用来检索一个实例:moduleRef.get(YOUR_HANDLER_TYPE),或者甚至可以使用ModuleRef#create方法动态地实例化它。

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