Skip to content

守卫

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

守卫有单一职责。它们根据运行时存在的某些条件(如权限、角色、ACL 等)来决定给定请求是否由路由处理程序处理。这通常被称为授权。授权(以及它的近亲认证,两者通常协同工作)在传统的 Express 应用中通常由中间件处理。中间件是认证的不错选择,因为令牌验证和向 request 对象附加属性等操作与特定路由上下文(及其元数据)没有强关联。

但中间件本质上是"哑"的。它不知道调用 next() 函数后会执行哪个处理程序。另一方面,守卫可以访问 ExecutionContext 实例,因此确切地知道接下来将执行什么。守卫的设计与异常过滤器、管道和拦截器非常相似,让你能够在请求/响应周期的恰当位置插入处理逻辑,并以声明式的方式实现。这有助于保持代码的 DRY 原则和声明式风格。

提示

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

授权守卫

如前所述,授权是守卫的绝佳使用场景,因为特定路由只有在调用者(通常是经过认证的特定用户)具有足够权限时才可访问。我们现在要构建的 AuthGuard 假设用户已经过认证(因此请求头中附有令牌)。它将提取并验证令牌,并使用提取的信息来决定请求是否可以继续。

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

提示

如果你正在寻找如何在应用中实现认证机制的真实示例,请访问此章节。同样,如需更复杂的授权示例,请查看此页面

validateRequest() 函数内部的逻辑可以简单也可以复杂,视需求而定。本示例的重点是展示守卫如何融入请求/响应周期。

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

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

执行上下文

canActivate() 函数接受一个参数,即 ExecutionContext 实例。ExecutionContext 继承自 ArgumentsHost。我们在异常过滤器章节中已经见过 ArgumentsHost。在上面的示例中,我们使用了之前在 ArgumentsHost 上定义的相同辅助方法来获取 Request 对象的引用。你可以回顾异常过滤器章节中的参数宿主部分了解更多内容。

通过继承 ArgumentsHostExecutionContext 还添加了几个新的辅助方法,提供关于当前执行过程的更多详细信息。这些详细信息有助于构建更通用的守卫,使其能够跨越广泛的控制器、方法和执行上下文工作。在此处了解更多关于 ExecutionContext 的内容。

基于角色的认证

让我们构建一个更实用的守卫,只允许具有特定角色的用户访问。我们将从一个基本的守卫模板开始,并在后续章节中逐步完善。目前,它允许所有请求通过:

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

绑定守卫

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

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

提示

@UseGuards() 装饰器从 @nestjs/common 包中导入。

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

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

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

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

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

注意

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

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

typescript
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

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

提示

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

为处理程序设置角色

我们的 RolesGuard 可以工作,但还不够智能。我们尚未利用最重要的守卫特性——执行上下文。它还不知道角色,也不知道每个处理程序允许哪些角色。例如,CatsController 可能对不同的路由有不同的权限方案。有些可能只对管理员用户开放,而其他的可能对所有人开放。我们如何以灵活且可复用的方式将角色与路由匹配?

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

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

typescript
import { Reflector } from '@nestjs/core';

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

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

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

typescript
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

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

整合在一起

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

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

提示

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

警告

matchRoles() 函数内部的逻辑可以简单也可以复杂,视需求而定。本示例的重点是展示守卫如何融入请求/响应周期。

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

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

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

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

typescript
throw new UnauthorizedException();

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

提示

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

基于 NestJS 官方文档翻译