授权

授权是指确定用户可执行操作的过程。例如,管理员用户被允许创建、编辑和删除帖子,而非管理员用户仅被授权阅读帖子。

授权与认证是正交且独立的两个概念,但授权需要依赖认证机制。

处理授权有许多不同的方法和策略。具体项目采用的方法取决于其特定的应用需求。本章将介绍几种授权方法,这些方法可适应各种不同的需求。

基础 RBAC 实现

基于角色的访问控制(RBAC)是一种围绕角色和权限定义的政策中立访问控制机制。本节我们将演示如何使用 Nest 守卫实现一个非常基础的 RBAC 机制。

首先,创建一个表示系统角色的 Role 枚举:

@@filename(role.enum)
export enum Role {
  User = 'user',
  Admin = 'admin',
}

info 提示 在更复杂的系统中,您可以将角色存储在数据库中,或从外部认证提供程序获取。

有了这些基础,我们就可以创建一个 @Roles() 装饰器。该装饰器允许指定访问特定资源所需的角色。

@@filename(roles.decorator)
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() 装饰器,可以用它来装饰任何路由处理器。

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

最后,我们创建一个 RolesGuard 类,它将比较当前用户分配的角色与正在处理的路由实际所需的角色。为了访问路由的角色(自定义元数据),我们将使用框架内置并通过 @nestjs/core 包提供的 Reflector 辅助类。

@@filename(roles.guard)
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));
  }
}

info 提示 有关如何在上下文敏感的方式中使用 Reflector 的更多细节,请参考执行上下文章节中的反射与元数据部分。

warning 注意 此示例命名为"basic",因为我们仅在路由处理程序层级检查角色存在性。在实际应用中,您可能会遇到包含多个操作的端点/处理器,其中每个操作都需要特定的权限集合。这种情况下,您必须在业务逻辑中的某处提供角色检查机制,这使得维护变得稍显困难,因为将没有集中化的地方来关联权限与特定操作。

在本示例中,我们假设 request.user 包含用户实例和允许的角色(位于 roles 属性下)。在您的应用中,您可能会在自定义的认证守卫中进行这种关联 - 更多详情请参阅认证章节。

为确保此示例正常工作,您的 User 类必须如下所示:

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

最后,请确保注册 RolesGuard,例如在控制器层级或全局范围内:

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

当权限不足的用户请求端点时,Nest 会自动返回以下响应:

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

info 提示 如需返回不同的错误响应,应抛出特定异常而非返回布尔值。

基于声明的授权

身份创建时可能会被分配一个或多个由受信任方颁发的声明。声明是表示主体能做什么(而非主体是什么)的名称-值对。

要在 Nest 中实现基于声明的授权,可遵循前述 RBAC 章节的相同步骤,但存在一个关键差异:不应检查特定角色,而应比较权限 。每个用户都会被分配一组权限。同样,每个资源/端点需定义访问所需的权限(例如通过专用的 @RequirePermissions() 装饰器)。

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

info 提示 在上例中,Permission(类似于我们在 RBAC 章节展示的 Role)是一个 TypeScript 枚举,包含系统中所有可用的权限。

集成 CASL

CASL 是一个同构授权库,用于限制给定客户端可访问的资源。它设计为可渐进式采用,能够轻松在基于简单声明和完全基于主体及属性的授权之间扩展。

首先安装 @casl/ability 包:

$ npm i @casl/ability

info 提示 在本示例中,我们选择了 CASL,但您可以根据个人偏好和项目需求使用其他库,例如 accesscontrolacl

安装完成后,为了演示 CASL 的机制,我们将定义两个实体类:UserArticle

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

User 类包含两个属性:唯一用户标识符 id,以及表示用户是否具有管理员权限的 isAdmin

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

Article 类具有三个属性:分别是 idisPublishedauthorIdid 是文章的唯一标识符,isPublished 表示文章是否已发布,而 authorId 则是文章作者的 ID。

现在让我们回顾并完善这个示例的需求:

  • 管理员可以管理(创建/读取/更新/删除)所有实体
  • 用户对所有内容拥有只读权限
  • 用户可以更新自己的文章(article.authorId === userId
  • 已发布的文章无法被删除(article.isPublished === true

考虑到这一点,我们可以先创建一个 Action 枚举,用来表示用户可以对实体执行的所有操作:

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

warning 注意 manage 是 CASL 中的一个特殊关键字,表示"任何"操作。

为了封装 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 = MongoAbility<[Action, Subjects]>;

@Injectable()
export class CaslAbilityFactory {
  createForUser(user: User) {
    const { can, cannot, build } = new AbilityBuilder(createMongoAbility);

    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/v6/en/guide/subject-type-detection#use-classes-as-subject-types for details
      detectSubjectType: (item) =>
        item.constructor as ExtractSubjectType<Subjects>,
    });
  }
}

warning 注意 all 是 CASL 中的一个特殊关键字,表示"任意主体"。

info 说明 从 CASL v6 开始,MongoAbility 作为默认的能力类,取代了旧版的 Ability,以更好地支持使用类 MongoDB 语法的基于条件的权限。尽管名称如此,它并不局限于 MongoDB——通过与采用类 Mongo 语法编写的条件进行对象比较,它可以适用于任何类型的数据。

info 说明 MongoAbilityAbilityBuilderAbilityClassExtractSubjectType 类都是从 @casl/ability 包中导出的。

info 提示 detectSubjectType 选项让 CASL 能够理解如何从对象中获取主体类型。更多详情请参阅 CASL 文档

在上面的示例中,我们使用 AbilityBuilder 类创建了 MongoAbility 实例。如您所料,cancannot 接受相同的参数但含义不同:can 允许对指定主体执行操作,而 cannot 则禁止。两者最多可接受 4 个参数。要了解这些函数的更多信息,请访问官方 CASL 文档

最后,请确保将 CaslAbilityFactory 添加到 CaslModule 模块定义的 providersexports 数组中:

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
}

info 提示 在官方 CASL 文档 中了解更多关于 MongoAbility 类的信息。

例如,假设我们有一个非管理员的用户。在这种情况下,该用户应该能够阅读文章,但创建新文章或删除现有文章的操作应被禁止。

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

info 提示 尽管 MongoAbilityAbilityBuilder 类都提供了 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

如您所见,MongoAbility 实例让我们能以相当可读的方式检查权限。同样地,AbilityBuilder 也允许我们以类似方式定义权限(并指定各种条件)。更多示例请参阅官方文档。

进阶:实现 PoliciesGuard

本节将演示如何构建一个更为复杂的守卫,用于检查用户是否符合可在方法级别配置的特定授权策略 (您也可以扩展它以支持类级别配置的策略)。本示例仅出于说明目的使用 CASL 包,但并非必须使用该库。同时,我们将使用先前章节创建的 CaslAbilityFactory 提供者。

首先,让我们明确需求。目标是提供一种机制,允许为每个路由处理程序指定策略检查。我们将同时支持对象和函数(适用于简单检查及偏好函数式代码风格的开发者)。

让我们从定义策略处理程序的接口开始:

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

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

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

export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;

如前所述,我们提供了两种定义策略处理程序的方式:对象(实现 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);
  }
}

提示 在本示例中,我们假设 request.user 包含用户实例。在实际应用中,您可能会在自定义的认证守卫中进行这种关联 - 更多细节请参阅认证章节。

让我们分解这个示例。policyHandlers 是通过 @CheckPolicies() 装饰器分配给该方法的处理器数组。接着,我们使用 CaslAbilityFactory#create 方法构建 Ability 对象,该对象允许我们验证用户是否具有执行特定操作的足够权限。我们将此对象传递给策略处理器,该处理器可以是一个函数,也可以是实现了 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();
}

warning 注意 由于我们必须使用 new 关键字就地实例化策略处理器,ReadArticlePolicyHandler 类无法使用依赖注入。这可以通过 ModuleRef#get 方法解决(详见 此处 )。本质上,不是通过 @CheckPolicies() 装饰器注册函数和实例,而是需要允许传递 Type<IPolicyHandler>。然后在守卫内部,可以通过类型引用获取实例: moduleRef.get(YOUR_HANDLER_TYPE) 或者甚至使用 ModuleRef#create 方法动态实例化它。