跳转至

授权

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

授权是正交的,并且独立于身份验证。 但是,授权需要一种身份验证机制。

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

基于 RBAC 实现

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

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

1
2
3
4
export enum Role {
  User = 'user',
  Admin = 'admin',
}

Hint

在更复杂的系统中,可以将角色存储在数据库中,或者从外部身份验证提供器获取角色。

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

1
2
3
4
5
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);
1
2
3
4
import { SetMetadata } from '@nestjs/common';

export const ROLES_KEY = 'roles';
export const Roles = (...roles) => SetMetadata(ROLES_KEY, roles);

现在我们有了一个自定义的@Roles()装饰器,我们可以用它装饰任何路由处理程序。

1
2
3
4
5
@Post()
@Roles(Role.Admin)
create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
1
2
3
4
5
6
@Post()
@Roles(Role.Admin)
@Bind(Body())
create(createCatDto) {
  this.catsService.create(createCatDto);
}

最后,我们创建一个RolesGuard类,它将把分配给当前用户的角色与正在处理的当前路由所需要的实际角色进行比较。 为了访问路由的角色(自定义元数据),我们将使用Reflectorhelper 类,它是由框架提供的,从@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));
  }
}
import { Injectable, Dependencies } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

@Injectable()
@Dependencies(Reflector)
export class RolesGuard {
  constructor(reflector) {
    this.reflector = reflector;
  }

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

Hint

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

Warning

这个例子被命名为"basic ",因为我们只在路由处理程序级别检查角色的存在。

在现实世界的应用程序中,您可能有一些涉及多个操作的端点/处理程序,其中每个操作都需要一组特定的权限。 在这种情况下,您必须提供一种机制来检查业务逻辑中的角色,这使维护变得有些困难,因为没有集中的地方将权限与特定的操作关联起来。

在这个例子中,我们假设request.user包含用户实例和允许的角色(在roles属性下)。 在你的应用程序中,你可能会在你的自定义的 身份验证保护 中建立这种关联-请参阅authentication章节了解更多细节。

为了确保这个例子能够正常工作,你的 User 类必须如下所示:

1
2
3
4
class User {
  // ...other properties
  roles: Role[];
}

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

1
2
3
4
5
6
providers: [
  {
    provide: APP_GUARD,
    useClass: RolesGuard,
  },
],

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

1
2
3
4
5
{
  "statusCode": 403,
  "message": "Forbidden resource",
  "error": "Forbidden"
}

Hint

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

声明式授权

创建标识时,可以将其分配给受信任方发出的一个或多个声明。 claim 是一个名称-值对,它表示主语可以做什么,而不是主语是什么。

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

1
2
3
4
5
@Post()
@RequirePermissions(Permission.CREATE_CAT)
create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
1
2
3
4
5
6
@Post()
@RequirePermissions(Permission.CREATE_CAT)
@Bind(Body())
create(createCatDto) {
  this.catsService.create(createCatDto);
}

Hint

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

整合 CASL

CASL是一个同构的授权库,它限制了给定的客户端可以访问哪些资源。 它被设计成可增量采用的,并且可以轻松地在基于简单声明、全功能主题和基于属性的授权之间进行伸缩。

首先,安装@casl/ability包:

$ npm i @casl/ability

Hint

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

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

1
2
3
4
class User {
  id: number;
  isAdmin: boolean;
}

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

1
2
3
4
5
class Article {
  id: number;
  isPublished: boolean;
  authorId: number;
}

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

现在让我们回顾并精炼这个例子中的需求:

  • 管理员可以管理(创建/读取/更新/删除)所有实体
  • 用户对所有内容都具有只读访问权限
  • 用户可以更新他们的文章 (article.authorId === userId)
  • 无法删除已发布的项目(article.isPublished === true)

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

1
2
3
4
5
6
7
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 = 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>,
    });
  }
}

Warning

all是 CASL 中一个特殊的关键字,代表任何主题

Hint

AbilityAbilityBuilderAbilityClassExtractSubjectType类从@casl/ Ability包中导出。

Hint

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

在上面的例子中,我们使用AbilityBuilder类创建了Ability实例。 正如你可能猜到的,can 和 cannot 接受相同的参数,但有不同的含义,can 允许对指定的主题做一个动作,而 cannot 禁止。 两者都可以接受最多 4 个参数。 要了解关于这些函数的更多信息,请访问官方CASL 文档

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

1
2
3
4
5
6
7
8
import { Module } from '@nestjs/common';
import { CaslAbilityFactory } from './casl-ability.factory';

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

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

constructor(private caslAbilityFactory: CaslAbilityFactory) {}

然后像下面这样在类中使用它。

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

Hint

有关能力类的更多信息,请参阅官方CASL 文档

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

1
2
3
4
5
6
7
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

Hint

虽然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 包,只是为了演示目的,但不需要使用这个库。 此外,我们将使用我们在前一节中创建的CaslAbilityFactory提供程序。

首先,让我们充实需求。 目标是提供一种机制,允许为每个路由处理程序指定策略检查。 我们将同时支持对象和函数(用于更简单的检查和那些更喜欢函数式代码的人)。

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

1
2
3
4
5
6
7
8
9
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()装饰器。 这个装饰器允许指定访问特定资源必须满足哪些策略。

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

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

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

Hint

在这个例子中,我们假设request。User包含用户实例。

在你的应用程序中,你可能会在你的自定义的**身份验证保护中建立这种关联-请参阅authentication章节了解更多细节。

让我们来分析一下这个例子。 policyHandlers是一个通过@CheckPolicies()装饰器分配给该方法的处理程序数组。 接下来,我们使用CaslAbilityFactory#create方法来构造Ability对象,允许我们验证用户是否有足够的权限来执行特定的操作。 我们将这个对象传递给策略处理程序,它要么是一个函数,要么是实现了IPolicyHandler的类的实例,暴露了返回布尔值的handle()方法。 最后,我们使用Array#every方法来确保每个处理器都返回true值。

最后,为了测试这个保护,将它绑定到任何路由处理程序,并注册一个内联策略处理程序(函数式方法),如下所示:

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

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

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

使用方法如下:

1
2
3
4
5
6
@Get()
@UseGuards(PoliciesGuard)
@CheckPolicies(new ReadArticlePolicyHandler())
findAll() {
  return this.articlesService.findAll();
}

Warning

因为我们必须使用new关键字就地实例化策略处理程序,所以ReadArticlePolicyHandler类不能使用依赖注入。

这可以通过ModuleRef#get方法来解决(详见此处). 基本上,你必须允许传递一个Type<IPolicyHandler>,而不是通过@CheckPolicies()装饰器注册函数和实例。 然后,在你的守卫内部,你可以使用类型引用moduleRef.get(YOUR_HANDLER_TYPE)来检索一个实例,或者甚至使用ModuleRef#create方法来动态实例化它。