Overview

守卫

学习如何在 NestJS 中使用守卫来实现授权和访问控制

守卫是一个用 @Injectable() 装饰器注解的类,它实现了 CanActivate 接口。

守卫有一个单一职责。它们根据运行时存在的某些条件(如权限、角色、ACL 等)来确定给定的请求是否由路由处理程序处理。这通常被称为授权。授权(以及它的表亲身份验证,通常与之协作)在传统的 Express 应用程序中通常由中间件处理。中间件是身份验证的一个很好的选择,因为诸如令牌验证和将属性附加到 request 对象之类的事情与特定的路由上下文(及其元数据)没有强烈的联系。

但是中间件,就其本质而言,是愚蠢的。它不知道在调用 next() 函数后将执行哪个处理程序。另一方面,守卫可以访问 ExecutionContext 实例,因此确切地知道接下来要执行什么。它们的设计,就像异常过滤器、管道和拦截器一样,让你在请求/响应周期中的正确位置插入处理逻辑,并以声明性的方式进行。这有助于保持你的代码 DRY 和声明性。

info 提示 守卫在所有中间件之后执行,但在任何拦截器或管道之前执行。

授权守卫

如前所述,授权是守卫的一个很好的用例,因为特定的路由只有在调用者(通常是特定的经过身份验证的用户)具有足够权限时才应该可用。我们现在要构建的 AuthGuard 假设一个经过身份验证的用户(因此,令牌附加到请求头)。它将提取和验证令牌,并使用提取的信息来确定请求是否可以继续。

@@filename(auth.guard)
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}
@@switch
import { Injectable } from '@nestjs/common';

@Injectable()
export class AuthGuard {
  async canActivate(context) {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}

info 提示 如果你正在寻找如何在应用程序中实现身份验证机制的真实示例,请访问这一章。同样,对于更复杂的授权示例,请查看这个页面

validateRequest() 函数内部的逻辑可以根据需要简单或复杂。这个示例的要点是展示守卫如何适应请求/响应周期。

每个守卫都必须实现一个 canActivate() 函数。此函数应返回一个布尔值,指示是否允许当前请求。它可以同步或异步地返回响应(通过 PromiseObservable)。Nest 使用返回值来控制下一个动作:

  • 如果它返回 true,请求将被处理。
  • 如果它返回 false,Nest 将拒绝请求。

执行上下文

canActivate() 函数接受一个参数,即 ExecutionContext 实例。ExecutionContext 继承自 ArgumentsHost。我们之前在异常过滤器章节中看到了 ArgumentsHost。在上面的示例中,我们只是使用在 ArgumentsHost 上定义的相同辅助方法,这些方法我们之前使用过,来获取对 Request 对象的引用。你可以参考异常过滤器章节的参数主机部分了解更多关于这个主题的信息。

通过扩展 ArgumentsHostExecutionContext 还添加了几个新的辅助方法,提供有关当前执行过程的其他详细信息。这些详细信息有助于构建更通用的守卫,可以在广泛的控制器、方法和执行上下文中工作。在这里了解更多关于 ExecutionContext 的信息。

基于角色的身份验证

让我们构建一个更实用的守卫,只允许具有特定角色的用户访问。我们将从一个基本的守卫模板开始,并在接下来的部分中构建它。现在,它允许所有请求继续:

@@filename(roles.guard)
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}
@@switch
import { Injectable } from '@nestjs/common';

@Injectable()
export class RolesGuard {
  canActivate(context) {
    return true;
  }
}

绑定守卫

像管道和异常过滤器一样,守卫可以是控制器作用域、方法作用域或全局作用域的。下面,我们使用 @UseGuards() 装饰器设置一个控制器作用域的守卫。这个装饰器可以接受一个参数,或者一个逗号分隔的参数列表。这让你可以轻松地用一个声明应用适当的守卫集合。

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

info 提示 @UseGuards() 装饰器从 @nestjs/common 包导入。

上面,我们传递了 RolesGuard 类(而不是实例),将实例化的责任留给框架并启用依赖注入。与管道和异常过滤器一样,我们也可以传递一个就地实例:

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

上面的构造将守卫附加到此控制器声明的每个处理程序。如果我们希望守卫仅应用于单个方法,我们在方法级别应用 @UseGuards() 装饰器。

为了设置全局守卫,使用 Nest 应用程序实例的 useGlobalGuards() 方法:

@@filename()
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());

warning 注意 在混合应用程序的情况下,useGlobalGuards() 方法默认不为网关和微服务设置守卫(有关如何更改此行为的信息,请参阅混合应用程序)。对于"标准"(非混合)微服务应用程序,useGlobalGuards() 确实会全局挂载守卫。

全局守卫在整个应用程序中使用,用于每个控制器和每个路由处理程序。在依赖注入方面,从任何模块外部注册的全局守卫(如上面示例中的 useGlobalGuards())无法注入依赖项,因为这是在任何模块的上下文之外完成的。为了解决这个问题,你可以使用以下构造直接从任何模块设置守卫:

@@filename(app.module)
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
})
export class AppModule {}

info 提示 当使用这种方法为守卫执行依赖注入时,请注意,无论在哪个模块中使用这种构造,守卫实际上都是全局的。应该在哪里完成这个操作?选择定义守卫(上面示例中的 RolesGuard)的模块。另外,useClass 不是处理自定义提供者注册的唯一方法。在这里了解更多。

为每个处理程序设置角色

我们的 RolesGuard 正在工作,但它还不是很智能。我们还没有利用最重要的守卫功能 - 执行上下文。它还不知道角色,或者每个处理程序允许哪些角色。例如,CatsController 可能对不同的路由有不同的权限方案。有些可能只对管理员用户可用,而其他的可能对每个人开放。我们如何以灵活和可重用的方式将角色与路由匹配?

这就是自定义元数据发挥作用的地方(在这里了解更多)。Nest 提供了通过 Reflector.createDecorator 静态方法创建的装饰器或内置的 @SetMetadata() 装饰器将自定义元数据附加到路由处理程序的能力。

例如,让我们使用 Reflector.createDecorator 方法创建一个 @Roles() 装饰器,它将元数据附加到处理程序。Reflector 由框架开箱即用提供,并从 @nestjs/core 包中暴露。

@@filename(roles.decorator)
import { Reflector } from '@nestjs/core';

export const Roles = Reflector.createDecorator<string[]>();

这里的 Roles 装饰器是一个接受 string[] 类型的单个参数的函数。

现在,要使用这个装饰器,我们只需用它注解处理程序:

@@filename(cats.controller)
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
@@switch
@Post()
@Roles(['admin'])
@Bind(Body())
async create(createCatDto) {
  this.catsService.create(createCatDto);
}

在这里,我们将 Roles 装饰器元数据附加到 create() 方法,表明只有具有 admin 角色的用户才应该被允许访问此路由。

或者,我们可以使用内置的 @SetMetadata() 装饰器,而不是使用 Reflector.createDecorator 方法。在这里了解更多。

将所有内容整合在一起

现在让我们回到我们的 RolesGuard 并将其整合在一起。目前,它在所有情况下都简单地返回 true,允许每个请求继续。我们希望基于比较分配给当前用户的角色与正在处理的当前路由所需的实际角色来使返回值有条件。为了访问路由的角色(自定义元数据),我们将再次使用 Reflector 辅助类,如下所示:

@@filename(roles.guard)
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from './roles.decorator';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get(Roles, context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return matchRoles(roles, user.roles);
  }
}
@@switch
import { Injectable, Dependencies } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Roles } from './roles.decorator';

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

  canActivate(context) {
    const roles = this.reflector.get(Roles, context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return matchRoles(roles, user.roles);
  }
}

info 提示 在 Node.js 世界中,将授权用户附加到 request 对象是一种常见做法。因此,在上面的示例代码中,我们假设 request.user 包含用户实例和允许的角色。在你的应用程序中,你可能会在自定义的身份验证守卫(或中间件)中建立这种关联。查看此章节了解有关此主题的更多信息。

warning 警告 matchRoles() 函数内部的逻辑可以根据需要简单或复杂。此示例的要点是展示守卫如何融入请求/响应周期。

有关在上下文敏感的方式中使用 Reflector 的更多详细信息,请参阅执行上下文章节的反射和元数据部分。

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

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

请注意,在幕后,当守卫返回 false 时,框架会抛出 ForbiddenException。如果你想返回不同的错误响应,你应该抛出自己的特定异常。例如:

throw new UnauthorizedException();

守卫抛出的任何异常都将由异常层(全局异常过滤器和应用于当前上下文的任何异常过滤器)处理。

info 提示 如果你正在寻找如何实现授权的真实示例,请查看此章节