Skip to main content

授权(Authorization)

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

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

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

基本的 RBAC 实现

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

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

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

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

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

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() 装饰器,我们可以用它来装饰任何路由处理程序。

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

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

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

有关 Reflection 和 metadata 部分中使用 Reflector 的详细信息

note

此示例被命名为"basic",因为我们仅在路由处理程序级别检查角色的存在。 在实际应用程序中,您可能会有涉及多个操作的端点/处理程序,其中每个操作都需要一组特定的权限。 在这种情况下,您将不得不提供一种机制,在您的业务逻辑的某个地方检查角色,这将使其难以维护, 因为没有一个集中的地方将权限与特定操作相关联。

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

为确保此示例有效,您的 User 类必须如下所示:

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

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

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

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

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

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

基于声明的授权

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

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

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

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

集成 CASL

CASL 是一个同构的授权库,限制了给定客户端可以访问的资源。 它被设计为可以逐步采用,并且可以轻松在基于简单声明的主体和基于主体和属性的完全特色授权之间进行缩放。

首先安装 @casl/ability 包:

npm i @casl/ability
note

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

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

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

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

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

Article 类有三个属性,分别是 id(唯一的文章标识符)、isPublished 表示文章是否已发布, 以及 authorId,该属性是写文章的用户的 ID。

现在让我们审查并完善对此示例的要求:

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

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

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

manage 是 CASL 中的一个特殊关键字,代表“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'); // 读写访问所有内容
} 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>,
});
}
}
note

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

tip

AbilityAbilityBuilderAbilityClassExtractSubjectType 类从 @casl/ability 包导出

note

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

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

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

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

然后在类中使用它:

const ability = this.caslAbilityFactory.createForUser(user);
if (ability.can(Action.Read, 'all')) {
// “user”对所有内容具有读取访问权限
}
note

在官方 CASL 文档中了解有关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
note

尽管 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

在这一部分,我们将演示如何构建一个更为复杂的守卫(guard), 该守卫检查用户是否满足可以在方法级别配置的特定授权策略(也可以扩展为尊重在类级别配置的策略)。 在此示例中,我们将仅出于说明目的使用 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);
}
}
note

在此示例中,我们假设 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();
}
note

由于我们必须使用 new 关键字在原地实例化策略处理程序,ReadArticlePolicyHandler 类无法使用依赖注入。 这可以通过使用 ModuleRef#get 方法解决(在此阅读更多)。 基本上,不要通过 @CheckPolicies() 装饰器注册函数和实例,而是允许传递 Type<IPolicyHandler>。 然后,在守卫中,您可以使用类型引用检索实例:moduleRef.get(YOUR_HANDLER_TYPE), 甚至可以使用 ModuleRef#create 方法动态实例化它。