NestJS 守卫
目录
守卫
守卫是一个使用 @Injectable()
装饰器标记的类,实现了 CanActivate
接口。
守卫类似一个门卫,职责单一但至关重要:它根据运行时的条件(比如权限、角色、访问控制列表等),决定一个请求能否被路由处理程序接手。这种行为通常被称为授权。授权和认证(身份验证)经常一起用。在传统的 Express 应用中,认证通常由中间件来处理,因为像令牌验证或给请求对象附加属性这类操作,与特定路由的上下文(及其元数据)关联不大。
但中间件天生有点 “笨拙”。它不知道调用 next()
函数后会执行哪个处理程序。而守卫则不同,它们可以访问 ExecutionContext
实例,从而精确知道接下来会执行什么。守卫的设计与异常过滤器、管道和拦截器类似,允许你在请求/响应周期的恰当位置插入处理逻辑,并且以声明式的方式实现。这让你的代码更符合 DRY(Don't Repeat Yourself)原则,也更具可读性和声明性。
提示:守卫在所有中间件之后执行,但在任何拦截器或管道之前。
核心要点
守卫的核心是 CanActivate
接口的 canActivate
方法,它返回一个布尔值(或其 Promise/Observable 形式),决定请求是否继续流向路由处理程序。返回 true
表示放行,false
则表示拒绝(通常抛出 ForbiddenException
或类似异常)。
- 权限控制:检查用户角色是否满足路由要求(如仅管理员访问)。
- 动态访问控制:根据运行时数据(如数据库中的 ACL)决定访问权限。
- API 密钥验证:验证请求是否携带有效的 API 密钥。
- 条件路由:根据请求头、查询参数或用户状态动态决定是否处理请求。
常见陷阱
- 误用中间件代替守卫:中间件适合全局逻辑(如日志记录、认证),但守卫更适合与路由上下文相关的精细化控制。不要把守卫的职责交给中间件,否则代码会显得零散。
- 忽略 ExecutionContext:守卫可以通过
ExecutionContext
获取请求、路由元数据等信息,忽略它可能导致逻辑不完整。 - 异常处理不当:守卫拒绝请求时,建议抛出明确的 HTTP 异常(如
ForbiddenException
),否则客户端可能收到不清晰的错误信息。 - 异步逻辑处理不当:如果
canActivate
返回 Promise,需确保正确处理异步逻辑,避免意外阻塞。
真实开发建议
- 模块化守卫:将守卫逻辑拆分为多个小守卫,各自处理单一职责(如角色检查、令牌验证),然后通过
@UseGuards()
组合使用。 - 结合依赖注入:守卫是可注入的,可以注入服务(如
UserService
)来查询数据库或验证逻辑。 - 日志记录:在守卫中记录拒绝请求的原因,便于调试和审计。
- 全局守卫 vs 局部守卫:全局守卫适合通用逻辑(如 API 密钥验证),局部守卫适合特定路由的权限控制。
代码示例
以下是一个简单的角色守卫示例,检查用户是否为管理员:
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user; // 假设认证中间件已将用户对象附加到请求上
if (!user || !user.roles.includes('admin')) {
throw new ForbiddenException('Only AscendOnly: true');
}
return true; // 用户是管理员,允许请求继续
}
}
使用方式:
// 在控制器中应用守卫
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AdminGuard } from './admin.guard';
@Controller('protected')
export class ProtectedController {
@Get()
@UseGuards(AdminGuard)
getProtectedResource() {
return { message: 'This is a protected resource' };
}
}
代码解释:
- @Injectable():标记
AdminGuard
为可注入的服务,允许 NestJS 的依赖注入系统管理其生命周期。 - CanActivate:接口要求实现
canActivate
方法,返回布尔值决定请求是否通过。 - ExecutionContext:提供请求上下文,
switchToHttp().getRequest()
获取 HTTP 请求对象。 - ForbiddenException:当用户不是管理员时,抛出 403 错误,清晰传达权限不足。
- @UseGuards(AdminGuard):将守卫应用到特定路由,只有通过守卫检查的请求才会执行
getProtectedResource
方法。
授权守卫
授权守卫是一个典型的应用场景,因为只有当请求的调用者(通常是经过身份验证的用户)具备足够权限时,特定的路由才应该被允许访问。 我们将构建一个 AuthGuard
,它假设请求中已附带一个经过身份验证的用户(例如,通过请求头中的令牌)。这个守卫会提取并验证令牌,然后根据提取的信息决定是否允许请求继续。
以下是 AuthGuard
的代码实现:
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);
}
}
提示: NestJS 应用中实现完整的身份验证机制,可以参考身份验证。对于更复杂的授权示例,可以查看授权。
validateRequest()
函数的逻辑可以根据需求简单或复杂。 这个例子的核心是展示守卫如何无缝融入请求/响应周期。validateRequest()
可以包含从解析 JWT 令牌到查询数据库的任何逻辑,具体取决于你的业务需求。
每个守卫都必须实现 canActivate()
函数。 此函数返回一个布尔值,指示当前请求是否被允许。它可以是同步的(直接返回 boolean
),也可以是异步的(返回 Promise<boolean>
或 Observable<boolean>
)。NestJS 根据返回值决定下一步动作:
- 返回
true
:请求被放行,继续交给路由处理程序。 - 返回
false
:NestJS 拒绝请求,通常抛出 HTTP 403(Forbidden)异常。
核心要点
AuthGuard
的核心在于 canActivate
方法,它通过 ExecutionContext
获取请求对象,并调用 validateRequest
来检查请求的合法性。validateRequest
是你自定义逻辑的入口,可以用来验证令牌、检查用户权限或执行其他动态检查。
常见陷阱
- 过度复杂的守卫逻辑:
validateRequest
应保持单一职责,避免将过多业务逻辑塞入守卫。复杂的逻辑可以交给服务层处理。
代码示例
以下是一个更完整的 AuthGuard
,包含 JWT 令牌验证的实现:
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { Observable } from 'rxjs';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('Missing or invalid token');
}
try {
const payload = await this.jwtService.verifyAsync(token);
request.user = payload; // 将用户信息附加到请求对象
return true;
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
private extractTokenFromHeader(request: any): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : null;
}
}
使用方式:
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
@Controller('protected')
export class ProtectedController {
@Get()
@UseGuards(AuthGuard)
getProtectedResource() {
return { message: 'This is a protected resource' };
}
}
执行上下文
canActivate()
函数接受一个参数,即 ExecutionContext
实例。ExecutionContext
继承自 ArgumentsHost
,就像在异常过滤器章节中介绍的那样,为守卫提供了请求的完整上下文信息。在前面的示例中,我们使用了 ArgumentsHost
提供的辅助方法(如 switchToHttp().getRequest()
)来获取 Request
对象的引用。
通过扩展 ArgumentsHost
,ExecutionContext
增加了一些新的辅助方法,提供当前执行过程的更多细节,让它能看到控制器、方法或执行上下文的元数据。这使得守卫可以更灵活地适配不同的场景,比如针对特定控制器或方法应用不同逻辑。更多关于 ExecutionContext
的信息,可以查看执行上下文。
基于角色的身份验证
我们将构建一个更强大的守卫,限制只有特定角色的用户才能访问,先一个基础模板开始,目前它允许所有请求通过,接下来会逐步完善它:
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; // 目前允许所有请求通过
}
}
核心要点
ExecutionContext
的作用:它提供了请求的完整上下文,包括 HTTP 请求、控制器、方法和元数据。守卫可以通过context.getClass()
和context.getHandler()
获取当前控制器类和方法,进而实现动态的权限控制。
使用场景
- 限制管理员路由:只允许
admin
角色访问管理面板。 - 多角色支持:支持多种角色(如
user
、editor
、admin
)访问不同资源。 - 动态元数据:结合 NestJS 的
@SetMetadata
装饰器,动态设置路由所需的角色。 - 分层权限:根据用户角色和上下文(如特定控制器或方法)实现细粒度权限控制。
绑定守卫
守卫可以像管道和异常过滤器一样,灵活地应用于控制器范围、方法范围或全局作用域。 通过使用 @UseGuards()
装饰器,我们可以为控制器或特定方法设置守卫,这个装饰器支持传入单个守卫类或一组守卫(用逗号分隔),让开发者能以声明式的方式轻松应用多重保护机制。
import { Controller, UseGuards } from '@nestjs/common';
import { RolesGuard } from './roles.guard';
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}
提示:@UseGuards()
装饰器从 @nestjs/common
包导入。
在上面的例子中,我们传递了 RolesGuard
类(而非实例),将实例化的责任交给 NestJS 框架,从而支持依赖注入,与管道和异常过滤器类似,我们也可以直接传递一个守卫实例:
import { Controller, UseGuards } from '@nestjs/common';
import { RolesGuard } from './roles.guard';
@Controller('cats')
@UseGuards(new RolesGuard())
export class CatsController {}
这种方式会将守卫绑定到控制器声明的所有路由处理程序,如果只需要针对单个方法应用守卫,可以在方法级别使用 @UseGuards()
装饰器。
全局守卫的设置通过 Nest 应用实例的 useGlobalGuards()
方法实现:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { RolesGuard } from './roles.guard';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());
await app.listen(3000);
}
bootstrap();
注意:对于混合应用,useGlobalGuards()
默认不会为网关或微服务设置守卫。对于“标准”(非混合)微服务应用,useGlobalGuards()
会全局应用守卫。
全局守卫适用于整个应用,覆盖每个控制器和路由处理程序,但需要注意,使用 useGlobalGuards()
注册的全局守卫无法注入依赖,因为它是在模块上下文之外定义的。为了解决这个问题,可以通过模块的方式设置全局守卫:
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { RolesGuard } from './roles.guard';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
提示:使用 APP_GUARD
注册全局守卫时,守卫会成为全局生效,无论它在哪个模块中定义。建议选择定义守卫的模块(如上例中的 RolesGuard
所在模块)来完成注册。
代码示例
结合角色元数据的控制器级别守卫应用:
import { Controller, Get, UseGuards, SetMetadata } from '@nestjs/common';
import { RolesGuard } from './roles.guard';
// 自定义角色装饰器
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {
@Get()
@Roles('admin', 'editor') // 仅允许 admin 或 editor 角色访问
getCats() {
return { message: 'List of cats' };
}
@Get('create')
@Roles('admin') // 仅允许 admin 角色访问
createCat() {
return { message: 'Create a cat' };
}
}
@Roles:自定义装饰器,通过 SetMetadata
设置路由所需的角色,供 RolesGuard
读取。
为每个处理程序设置角色
我们的 RolesGuard
已经能工作,但还不够聪明,缺乏对执行上下文的充分利用。 它目前不知道每个路由所需的角色,也无法根据路由动态调整权限。例如,CatsController
可能需要为不同路由设置不同的权限:有些路由只对管理员开放,有些对所有用户开放。如何以灵活且可重用的方式将角色与路由匹配呢?
答案在于自定义元数据。 NestJS 提供了两种方式为路由处理程序附加元数据:通过 Reflector.createDecorator
创建自定义装饰器,或使用内置的 @SetMetadata()
装饰器。这就像给每个路由贴上一个“权限标签”,让守卫能快速识别所需角色。
我们使用 Reflector.createDecorator
创建一个 @Roles()
装饰器,用于附加角色元数据,Reflector
是 NestJS 提供的内置工具,从 @nestjs/core
导入,简化了元数据的操作。
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();
Roles
装饰器是一个接受 string[]
类型参数的函数。它用于标记路由所需的角色列表。
在控制器中使用 @Roles()
装饰器:
import { Controller, Post, Body } from '@nestjs/common';
import { Roles } from './roles.decorator';
import { CreateCatDto } from './create-cat.dto';
@Controller('cats')
export class CatsController {
@Post()
@Roles(['admin']) // 仅允许 admin 角色访问
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
}
说明:这里通过 @Roles(['admin'])
为 create
方法设置了元数据,表示只有 admin
角色的用户可以访问此路由。
替代方法:可以使用内置的 @SetMetadata()
装饰器实现相同效果。
将守卫与角色绑定
当前 RolesGuard
总是返回 true
,允许所有请求通过。 我们需要让它根据路由的角色元数据和用户的实际角色进行条件判断。通过 Reflector
读取路由的元数据,并与 request.user
中的角色对比,决定是否放行。
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } 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 requiredRoles = this.reflector.get(Roles, context.getHandler());
if (!requiredRoles) {
return true; // 无角色要求,允许通过
}
// 获取请求中的用户信息
const request = context.switchToHttp().getRequest();
const user = request.user;
// 验证用户角色
if (!user || !user.roles || !this.matchRoles(requiredRoles, user.roles)) {
throw new ForbiddenException('Forbidden resource');
}
return true;
}
private matchRoles(requiredRoles: string[], userRoles: string[]): boolean {
return requiredRoles.some(role => userRoles.includes(role));
}
}
提示:在 Node.js 开发中,通常通过认证中间件(如 Passport 或 JWT)将用户信息附加到 request.user
上,包含用户的角色信息。
警告:matchRoles()
的逻辑可以根据需求调整,比如支持复杂角色层级或优先级。
当权限不足时,NestJS 默认返回以下响应:
{
"statusCode": 403,
"message": "Forbidden resource",
"error": "Forbidden"
}
自定义异常:如果想返回不同的错误响应,可以在守卫中抛出特定异常,例如:
throw new UnauthorizedException('Invalid role');
异常处理:守卫抛出的任何异常都会被异常层(全局异常过滤器或当前上下文的异常过滤器)处理。