Security

授权

学习如何在NestJS应用中实现授权机制,包括基于角色的访问控制(RBAC)、基于声明的授权和守卫

授权

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

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

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

基本RBAC实现

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

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

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

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

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

@@filename(roles.decorator)
import { SetMetadata } from '@nestjs/common';
import { Role } from './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类,它将比较分配给当前用户的角色与当前正在处理的路由所需的实际角色。为了访问路由的角色(自定义元数据),我们将使用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));
  }
}

提示 请参考反射和元数据部分的守卫章节,了解更多关于以上下文敏感的方式使用Reflector的详细信息。

注意 这个例子被命名为"基本",因为我们只是检查用户对象上是否存在角色。在真实世界的应用程序中,您可能有更复杂的RBAC实现,涉及权限、操作、资源、上下文等。在这种情况下,您可能想要使用专门的库,如CASL,我们将在下一节中介绍。

在这个例子中,我们假设request.user包含用户实例和允许的角色(在roles属性下)。在您的应用程序中,您可能会在自定义认证守卫中建立这种关联 - 有关更多详细信息,请参见认证章节。

为了确保这个例子有效,您的User类必须如下所示:

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

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

@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

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

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

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

基于声明的授权

当创建身份时,可能会为其分配一个或多个声明。声明是一个名称值对,表示主体是什么,而不是主体可以做什么。

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

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

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

与CASL集成

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

首先,安装@casl/ability包:

$ npm i @casl/ability

提示 在这个例子中,我们选择了CASL,但您可以使用任何其他库,如accesscontrolacl,这取决于您的偏好和项目需求。

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

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

现在,让我们回顾并完善我们的需求:

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

有了这个想法,我们可以开始创建一个Ability类,表示用户在系统中可以做什么:

import { Ability, AbilityBuilder, AbilityClass, ExtractSubjectType, InferSubjects } from '@casl/ability';

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

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

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

    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中的一个特殊关键字,表示"任何主体"。

在上面的例子中,我们还没有定义Action枚举,所以让我们创建它:

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

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

现在,让我们创建PoliciesGuard,它将促进CASL授权。这个守卫将是通用的和可重用的。在这个例子中,我们将使用CaslAbilityFactory,但这个类不是必需的。另外,我们将使用IPolicyHandler接口,我们稍后会定义它。

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory } from './casl-ability.factory';
import { PolicyHandler } from './policy-handler.interface';
import { CHECK_POLICIES_KEY } from './policies.decorator';

@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对象,允许我们验证用户是否有足够的权限执行特定操作。我们将这个对象传递给策略处理程序,这是一个实现PolicyHandler接口的函数或类的实例,具有返回布尔值的handle()方法。最后,我们使用Array#every方法确保每个处理程序都返回true值。

最后,让我们定义PolicyHandler接口:

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

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

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

export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback;

之后,让我们创建@CheckPolicies()装饰器:

import { SetMetadata } from '@nestjs/common';
import { PolicyHandler } from './policy-handler.interface';

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

现在,让我们创建第一个策略处理程序。假设我们想要禁止用户删除文章。相应的策略处理程序如下所示:

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

或者,我们可以定义一个函数:

export const deleteArticlePolicyHandler = (ability: AppAbility) =>
  ability.can(Action.Delete, Article);

最后,让我们测试我们的PoliciesGuard。为了简化,我们将使用基于函数的处理程序:

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

或者,我们可以使用基于类的处理程序:

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

警告 由于我们必须使用new关键字实例化策略处理程序,ReadArticlePolicyHandler类不能使用依赖注入。这可以通过ModuleRef#get方法解决(阅读这里了解更多)。基本上,您必须通过@CheckPolicies()装饰器传递类型而不是实例,并在您的守卫中添加额外的逻辑来实例化类而不是调用函数。

最后,为了确保PoliciesGuard正确实例化,不要忘记将CaslAbilityFactory注册为提供者:

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

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

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

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

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

高级:实现字段级权限

这是一个相对高级的功能。在这个例子中,我们假设您有一个返回用户集合的端点,但您想要根据返回的用户对象限制哪些字段:

@Get()
findAll(): User[] {
  return [{
    id: 1,
    firstName: 'John',
    lastName: 'Doe',
    email: 'john.doe@example.com',
    phone: '123-456-7890',
  }];
}

对于基于字段的权限,我们无法依赖基本的CASL能力检查,因为我们不知道用户是否可以读取/访问特定字段,直到我们实际想要返回它。

为了实现这个功能,我们将创建一个拦截器,分析响应并根据用户的能力删除禁止的字段。

首先,让我们在CaslAbilityFactory中添加字段级检查:

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

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

    // 字段级权限
    cannot(Action.Read, User, ['email', 'phone'], { id: { $ne: user.id } });

    return build({
      detectSubjectType: (item) =>
        item.constructor as ExtractSubjectType<Subjects>,
    });
  }
}

警告 在这个例子中,我们使用了$ne操作符。这需要您安装并注册@casl/mongoose包或等效的包,具体取决于您使用的数据库。

现在,让我们创建一个专用的拦截器,它将自动删除用户无权访问的字段:

@Injectable()
export class FieldsInterceptor implements NestInterceptor {
  constructor(private caslAbilityFactory: CaslAbilityFactory) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map((data) => {
        const request = context.switchToHttp().getRequest();
        const ability = this.caslAbilityFactory.createForUser(request.user);

        return data.map(item => this.filterForbiddenFields(item, ability));
      }),
    );
  }

  private filterForbiddenFields(item: any, ability: AppAbility) {
    const filteredItem = { ...item };
    Object.keys(item).forEach(key => {
      if (!ability.can(Action.Read, item, key)) {
        delete filteredItem[key];
      }
    });
    return filteredItem;
  }
}

最后,确保在您想要启用字段级权限的端点上使用这个拦截器:

@Get()
@UseInterceptors(FieldsInterceptor)
findAll(): User[] {
  return this.usersService.findAll();
}

现在,当非管理员用户访问这个端点时,响应将如下所示:

[
  {
    "id": 1,
    "firstName": "John",
    "lastName": "Doe"
  }
]

提示 这只是一个基本实现。根据您的需求,您可能想要创建一个更复杂的拦截器,处理嵌套对象、数组等。

示例

您可以在这里找到本章代码的完整版本。