授权 Authorization

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

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

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

基本 RBAC 实现

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

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

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

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

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

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

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

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

ts
roles.guard
ts
import { CanActivate, ExecutionContext, Injectable } 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 的更多详细信息,请参阅执行上下文章节的 反射和元数据 部分。

通知

此示例名为**basic**,因为我们只检查路由处理程序级别的角色是否存在。在实际应用中,您可能拥有涉及多个操作的端点/处理程序,其中每个操作都需要一组特定的权限。在这种情况下,您必须提供一种机制来检查业务逻辑中某个位置的角色,这使得维护起来有些困难,因为没有将权限与特定操作相关联的集中位置。

在此示例中,我们假设 request.user 包含用户实例和允许的角色(在 roles 属性下)。在您的应用中,您可能会在自定义身份验证保护中建立该关联 - 有关更多详细信息,请参阅身份验证一章。

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

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

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

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

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

json
{
  "statusCode": 403,
  "message": "Forbidden resource",
  "error": "Forbidden"
}
ts
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

ts
class User {
  id: number
  isAdmin: boolean
}

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

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

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

现在让我们回顾并完善此示例的要求:

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

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

ts
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 对象:

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

all 是 CASL 中表示任何主题的特殊关键字。

提示

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

提示

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

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

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

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

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

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

ts
constructor(private caslAbilityFactory: CaslAbilityFactory) {}

然后在类中使用它,如下所示。

ts
const ability = this.caslAbilityFactory.createForUser(user)
if (ability.can(Action.Read, 'all')) {
  // "user" has read access to everything
}
提示

在官方 CASL 文档 中了解有关 Ability 类的更多信息。

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

ts
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
提示

尽管 AbilityAbilityBuilder 类都提供了 cancannot 方法,但它们的用途不同,接受的参数也略有不同。

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

ts
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 提供程序。

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

让我们首先定义策略处理程序的接口:

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

ts
export const CHECK_POLICIES_KEY = 'check_policy'
export function CheckPolicies(...handlers: PolicyHandler[]) {
  return SetMetadata(CHECK_POLICIES_KEY, handlers)
}

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

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

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

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

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

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

使用方法如下:

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

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