Skip to content

授权

授权是指确定用户能够做什么的过程。例如,管理员用户可以创建、编辑和删除文章。非管理员用户只被授权阅读文章。

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

有许多不同的方法和策略来处理授权。任何项目所采用的方法取决于其特定的应用需求。本章介绍了几种可以适应各种不同需求的授权方法。

基本 RBAC 实现

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

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

typescript
// role.enum.ts
export enum Role {
  User = 'user',
  Admin = 'admin',
}

提示

在更复杂的系统中,你可能会将角色存储在数据库中,或从外部认证提供者中获取。

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

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

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

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

typescript
// roles.guard.ts
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));
  }
}

提示

请参阅执行上下文章节的反射和元数据部分,了解更多关于以上下文感知方式使用 Reflector 的详细信息。

注意

此示例被命名为"基本",因为我们只在路由处理器级别检查角色的存在。在实际应用中,你可能有涉及多个操作的端点/处理器,其中每个操作都需要一组特定的权限。在这种情况下,你必须在业务逻辑中的某处提供检查角色的机制,这使得维护变得更加困难,因为没有将权限与特定操作关联的集中位置。

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

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

typescript
class User {
  // ...其他属性
  roles: Role[];
}

最后,确保注册 RolesGuard,例如在控制器级别或全局注册:

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

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

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

提示

如果你想返回不同的错误响应,你应该抛出自己的特定异常,而不是返回布尔值。

基于声明的授权

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

要在 Nest 中实现基于声明的授权,你可以按照我们在上面 RBAC 部分中展示的相同步骤操作,但有一个重要的区别:你应该比较权限而不是检查特定角色。每个用户都将被分配一组权限。同样,每个资源/端点都将定义需要哪些权限才能访问它们(例如,通过专用的 @RequirePermissions() 装饰器)。

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

提示

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

集成 CASL

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

首先,安装 @casl/ability 包:

bash
$ npm i @casl/ability

提示

在此示例中,我们选择了 CASL,但你可以根据你的偏好和项目需求使用任何其他库,如 accesscontrolacl

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

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

User 类由两个属性组成,id 是唯一的用户标识符,isAdmin 表示用户是否具有管理员权限。

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

Article 类有三个属性,分别是 idisPublishedauthorIdid 是唯一的文章标识符,isPublished 表示文章是否已发布,authorId 是撰写文章的用户的 ID。

现在让我们回顾和完善此示例的需求:

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

考虑到这些,我们可以从创建一个 Action 枚举开始,表示用户可以对实体执行的所有可能操作:

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

注意

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

要封装 CASL 库,让我们现在生成 CaslModuleCaslAbilityFactory

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

有了这个,我们可以在 CaslAbilityFactory 上定义 createForUser() 方法。此方法将为给定用户创建 Ability 对象:

typescript
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'); // 对所有内容的读写访问
    } else {
      can(Action.Read, 'all'); // 对所有内容的只读访问
    }

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

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

注意

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

提示

自 CASL v6 起,MongoAbility 作为默认的 ability 类,取代了旧版的 Ability,以更好地支持使用 MongoDB 类语法的条件权限。尽管名称如此,它并不绑定于 MongoDB——它可以与任何类型的数据一起使用,只需将对象与用 Mongo 类语法编写的条件进行比较。

提示

MongoAbilityAbilityBuilderAbilityClassExtractSubjectType 类从 @casl/ability 包中导出。

提示

detectSubjectType 选项让 CASL 了解如何从对象中获取主体类型。更多信息请阅读 CASL 文档

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

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

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

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

完成后,只要 CaslModule 在宿主上下文中被导入,我们就可以使用标准构造函数注入将 CaslAbilityFactory 注入到任何类中:

typescript
constructor(private caslAbilityFactory: CaslAbilityFactory) {}

然后在类中如下使用它。

typescript
const ability = this.caslAbilityFactory.createForUser(user);
if (ability.can(Action.Read, 'all')) {
  // "user" 具有对所有内容的读取权限
}

提示

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

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

typescript
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

提示

虽然 MongoAbilityAbilityBuilder 类都提供了 cancannot 方法,但它们有不同的用途并接受略有不同的参数。

此外,正如我们在需求中指定的,用户应该能够更新自己的文章:

typescript
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 提供者。

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

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

typescript
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() 装饰器。此装饰器允许指定访问特定资源必须满足哪些策略。

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

现在让我们创建一个 PoliciesGuard,它将提取并执行绑定到路由处理器的所有策略处理器。

typescript
@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 值。

最后,要测试此守卫,将其绑定到任何路由处理器,并注册一个内联策略处理器(函数式方法),如下所示:

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

或者,我们可以定义一个实现 IPolicyHandler 接口的类:

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

然后如下使用它:

typescript
@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 方法动态实例化它。

基于 NestJS 官方文档翻译