Nest.js Guards
Guards 是一个用 @Injectable()
装饰器注解的类,它实现了 CanActivate
接口。
Guards 有一个单一的责任。它们根据运行时存在的某些条件(如权限、角色、ACL 等)确定给定请求是否将由路由处理程序处理。这通常称为授权。授权通常由传统 Express 应用程序中的中间件处理。中间件是身份验证的最佳选择,因为诸如令牌验证和将属性附加到请求对象之类的事情与特定的路由上下文(及其元数据)没有紧密的联系。
但是,就其本质而言,中间件是愚蠢的。它不知道调用 next() 函数后将执行哪个处理程序。另一方面,Guard 可以访问 ExecutionContext
实例,因此可以准确地知道接下来要执行什么。它们的设计与异常过滤器、管道和拦截器非常相似,可让我们在请求/响应周期的正确位置插入处理逻辑,并以声明方式执行此操作。这有助于使我们的代码保持 DRY 和声明性。
提示
: Guards在每个中间件之后执行,但在任何拦截器或管道之前。
授权 Guard
如前所述,授权是 Guards 的一个很好的用例,因为只有当调用者(通常是特定的经过身份验证的用户)具有足够的权限时,特定的路由才应该可用。 我们现在将构建的 AuthGuard 假定一个经过身份验证的用户(因此,一个令牌附加到请求标头)。 它将提取并验证令牌,并使用提取的信息来确定请求是否可以继续。
auth.guard.ts
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()
函数内部的逻辑可以根据需要简单或复杂。 这个例子的主要目的是展示 Guard 如何适应请求/响应周期。
每个 Guard 都必须实现一个 canActivate() 函数。 这个函数应该返回一个布尔值,指示当前请求是否被允许。 它可以同步或异步(通过 Promise 或 Observable)返回响应。 Nest 使用返回值来控制下一个动作:
- 如果返回 true,则处理请求。
- 如果返回 false,Nest 将拒绝该请求。
执行上下文
canActivate()
函数采用单个参数,即 ExecutionContext
实例。 ExecutionContext 继承自 ArgumentsHost。 我们之前在异常过滤器章节中看到了 ArgumentsHost。 在上面的示例中,我们只是使用我们之前使用的在 ArgumentsHost 上定义的相同帮助方法来获取对 Request 对象的引用。 有关此主题的更多信息,可以参考异常过滤器一章的参数主机部分。
通过扩展 ArgumentsHost,ExecutionContext 还添加了几个新的帮助方法,这些方法提供了有关当前执行过程的更多详细信息。 这些细节有助于构建更通用的 Guard ,这些 guards 可以在广泛的控制器、方法和执行上下文中工作。
基于角色的身份验证
让我们构建一个功能更强大的 guard ,它只允许具有特定角色的用户访问。 我们将从一个基本的 Guard 模板开始,并在接下来的部分中以它为基础。 目前,它允许所有请求继续进行:
roles.guard.ts
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; } }
绑定 Guard
像管道和异常过滤器一样,Guard 可以是控制器范围的、方法范围的或全局范围的。 下面,我们使用 @UseGuards()
装饰器设置了一个控制器范围的 Guard。 这个装饰器可以接受一个参数,或者一个逗号分隔的参数列表。 这使我们可以通过一个声明轻松地应用适当的 guards 集和。
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}
提示
: @UseGuards() 装饰器是从 @nestjs/common 包中导入的。
上面,我们传递了 RolesGuard
类型(而不是实例),将实例化的责任留给了框架并启用了依赖注入。 与管道和异常过滤器一样,我们也可以传递一个就地实例:
@Controller('cats')
@UseGuards(new RolesGuard())
export class CatsController {}
上面的结构将 Guard 附加到该控制器声明的每个处理程序上。 如果我们希望 Guard 只应用于单个方法,我们在方法级别应用 @UseGuards()
装饰器。
为了设置全局 Guard,需要使用 Nest 应用程序实例的 useGlobalGuards()
方法:
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());
注意
:在混合应用程序的情况下,useGlobalGuards() 方法默认不会为网关和微服务设置保护。 对于“标准”(非混合)微服务应用程序,useGlobalGuards() 确实会在全局范围内安装 Guard。
全局 Guard 用于整个应用程序,用于每个控制器和每个路由处理程序。 在依赖注入方面,从任何模块外部注册的全局Guard(使用上面示例中的 useGlobalGuards() )不能注入依赖项,因为这是在任何模型的上下文之外完成的。 为了解决这个问题,我们可以使用以下结构直接从任何模型设置防护:
app.module.ts
import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; @Module({ providers: [ { provide: APP_GUARD, useClass: RolesGuard, }, ], }) export class AppModule {}
提示
:当使用这种方法为 Guard 执行依赖注入时,请注意,无论使用此构造的模块如何,guard 实际上都是全局的。 这应该在哪里完成? 选择定义保护的模型(上例中的 RolesGuard)。 此外,useClass 不是处理自定义提供程序注册的唯一方法。
为每个处理程序设置角色
我们的 RolesGuard 正在工作,但还不是很智能。 我们还没有利用最重要的保护特性——执行上下文。 它还不知道角色,或者每个处理程序允许哪些角色。 例如,CatsController 可以对不同的路由有不同的权限方案。 有些可能仅对管理员用户可用,而其他可能对所有人开放。 我们如何以灵活且可重用的方式将角色与路由匹配?
这就是自定义元数据发挥作用的地方。 Nest 提供了通过 @SetMetadata()
装饰器将自定义元数据附加到路由处理程序的能力。 该元数据提供了我们缺少的角色数据,智能 Guard 需要这些数据来做出决定。 让我们看一下使用 @SetMetadata():
cats.controller.ts
@Post() @SetMetadata('roles', ['admin']) async create(@Body() createCatDto: CreateCatDto) { this.catsService.create(createCatDto); }
提示
:@SetMetadata() 装饰器是从 @nestjs/common 包中导入的。
通过上面的构造,我们将角色元数据(角色是 key,而 ['admin'] 是特定value)附加到 create() 方法。 虽然这可行,但在路由中直接使用 @SetMetadata() 并不是一个好习惯。 相反,创建自己的装饰器,如下所示:
roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
这种方法更干净、更易读,并且是强类型的。 现在我们有了一个自定义的 @Roles() 装饰器,我们可以用它来装饰 create() 方法。
cats.controller.ts
@Post() @Roles('admin') async create(@Body() createCatDto: CreateCatDto) { this.catsService.create(createCatDto); }
让我们再次回到 RolesGuard 。 它只是在所有情况下返回 true,到目前为止允许请求继续。我们希望根据分配给当前用户的角色与正在处理的当前路由所需的实际角色之间的比较来设置返回值的条件。 为了访问路由的角色(自定义元数据),我们将使用在 @nestjs/core 中提供的 Reflector 帮助类。
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 roles = this.reflector.get<string[]>('roles', context.getHandler()); if (!roles) { return true; } const request = context.switchToHttp().getRequest(); const user = request.user; return matchRoles(roles, user.roles); } }
提示
:在 node.js 世界中,将授权用户附加到请求对象是常见的做法。 因此,在上面的示例代码中,我们假设 request.user 包含用户实例和允许的角色。 在应用程序中,我们可能会在自定义身份验证保护(或中间件)中建立这种关联。
警告
: matchRoles() 函数内部的逻辑可以根据需要简单或复杂。 这个例子的主要目的是展示 Guard 如何适应请求/响应周期。
有关以上下文相关方式使用反射器的更多详细信息,请参阅执行上下文一章的反射和元数据部分。
当权限不足的用户请求端点时,Nest 会自动返回以下响应:
{
"statusCode": 403,
"message": "Forbidden resource",
"error": "Forbidden"
}
请注意,当 Guard 返回 false 时,框架会抛出 ForbiddenException
。 如果你想返回一个不同的错误响应,应该抛出自己的特定异常。 例如:
throw new UnauthorizedException();
Guard 抛出的任何异常都将由异常层(全局异常过滤器和应用于当前上下文的任何异常过滤器)处理。