Guards
Guards
guard는 CanActivate interface를 implement하는 @Injectable() decorator이 붙은 class이다.
//Middleware
// 역할: 요청과 응답 사이의 모든 것을 처리. 주로 로깅, 인증, 요청 변환에 사용
// 적용: 모든 경로에 대해 전역적으로 적용되거나 특정 경로에 대해 적용될 수 있다.
// 순서: 요청 수신 직후 실행된다.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log('Request...');
next();
}
}
//Guard
// 역할: 요청이 컨트롤러 핸들러로 들어가기 전에 특정 조건(주로 인증/권한)을 확인하고, 접근을 허용할지 결정한다.
// 적용: 전역, 컨트롤러, 또는 특정 핸들러에 대해 적용될 수 있다.
// 순서: 미들웨어 이후에 실행된다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
return validateRequest(request);
}
}
//Pipe
// 역할: 요청 데이터를 변환하고 검증한다.
// 적용: 특정 매개변수, 컨트롤러 메서드, 또는 전역적으로 적용될 수 있다.
// 순서: 가드 이후, 컨트롤러 핸들러에 전달되기 전에 실행된다.
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
transform(value: string, metadata: ArgumentMetadata): number {
const val = parseInt(value, 10);
if (isNaN(val)) {
throw new BadRequestException('Validation failed');
}
return val;
}
}
Guard는 single responsibility를 가진다. 이들은 주어진 request가 route handler에 다뤄질 것인지 아닐 지를 결정한다. (permission, role, ACLs. etc에 의거하여) 이는 종종 authorization, authentication으로 사용된다. Authorization(authentication)은 전통적인 Express application에서는 middleware에 의해 다뤄진다. middleware는 authentication에서는 괜찮은 선택이다. token 검증이나 request object에 속성을 붙이는 작업은 특정 rotue context(및 그 메타데이터)와 강하게 연결되어 있지 않기 때문이다.
허나 middleware은 멍청하다. next() function 호출 이후에 어떤 handler가 실행됬는지 알지 모른다. 반면에 Guard는 ExecutionContext instance에 접근한다. 즉, 바로 다음에 무엇이 실행될지 정확하게 안다. exception filter, pipe, 그리고 interceptor과 같이 guard는 request/response cycle의 정확한 지점에서 처리 로직을 선언적으로 삽입할 수 있도록 설계되었다. 이것은 code를 DRY하고 선언적으로 만들어 준다.
ExecutionContext: API 요청이 서버에 도달하면, NestJS는 이 요청에 대한 정보를 ExecutionContext 객체에 캡슐화한다.
이 컨텍스트 객체는 요청의 세부 사항을 포함하며, 이를 통해 Guard, Interceptor, Pipe, Exeception Filter 등이 요청을 처리할 수 있다.
1. 현재 실행 컨텍스트 정보 제공
- 요청 객체, 응답 객체, 컨트롤러 및 핸들러 메서드에 대한 정보를 제공한다.
- Guard, Interceptor, Filter, Pipe 등이 이 정보를 활용하여 특정 로직을 실행할 수 있다.
2. 상황별 분기 처리
- HTTP, WebSocket, RPC(Remote Porcedure Call) 등 다양한 프로토콜에 대해 적절한 컨텍스트를 제공한다.
- 각 프로토콜에 맞는 요청 처리를 구현할 수 있다.
<팁!>
Guards는 모든 middleware 이후에, 그리고 intercepter or pipe 이전에 실행된다.
Authorization guard
언급한대로, authorization은 Guard를 쓰는 대표적인 시스템이다. 왜냐하면 caller(보통 authenticated user)가 충분한 permission을 가질 때 특정한 route를 호출할 수 있어야 하기 때문이다. AuthGuard authenticated user를 짐작한다.(request header에 token이 붙어있는) Guard는 token을 추출하고 검증할 것이다. 그리고 추출된 정보를 사용하여 request가 처리될지 말지를 결정한다.
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
// AuthGuard가 CanActivate를 구현하여 가드로써 동작할 수 있다.
export class AuthGuard implements CanActivate {
// canActivate 메서드는 요청이 특정 경로에 접근할 수 있는지 여부를 결정한다.
// context: 현재 요청의 실행 컨텍스트를 나타내는 ExecutionContext 객체
// boolean, Promise<boolean>, Observable<boolean> 중 하나를 반환하여 요청이 허용될지 여부를 결정한다.
canActivate(
// 현재 요청의 실행 컨텍스트를 나타내는 ExecutionContext 객체
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
// 주어진 요청 객체의 유효성을 검증하는 역할을 한다.
// 이 함수는 사용자 정의 함수이며, 주로 요청이 적절한 인증 및 권한을 가지고 있는지 확인하는 데 사용된다.
// e.g. JWT 토큰의 유효성 검사. 사용자 권한 확인 등을 포함할 수 있다.
return validateRequest(request);
}
}
실행 컨텍스트
API 요청이 서버에 도달하면, NestJS는 이 요청에 대한 정보를 ExecutionContext 객체에 캡슐화한다. 이 Context 객체는 요청의 세부 사항을포함하며, 이를 통해 Uard, Interceptor, Pipe, Exception Filter 등이 요청을 처리할 수 있다.
<팁!>
만일 이러한 검증 시스템이 어떤 메커니즘으로 돌아가는지 알고 싶다면, 이 챕터를 찾아가라. 마찬가지로 정교한 authorization example를 확인하고 싶다면 이 챕터를 참고하라.
validateRequest() function의 내부 로직은 simple하거나 정교할 수 있다. 이번 예제의 핵심 포인트는 어떻게 guard가 reqeust/response cycle에서 동작하는가 이다.
모든 guard는 반드시 canActivate() 함수를 구현해야 한다. 이 함수는 현재 request가 허용할지 말지를 나타내는 boolean값을 return 해야한다. 그리고 synchronously 또는 asynchronously 한지에 대한 response를 반환할 수 있다.(Promise 또는 Observable을 통해) Nest는 next action을 제어하기 위해 return value를 사용해야 한다.
- 만일 true를 반환하면 request가 처리되야 함
- 만일 false를 반환하면 request를 거절해야함
Execution context
canActivate() function은 single argument, ExecutionContext instance를 받는다. ExecutionContext는 ArgumentsHost를 상속받는다. 우리는 ArgumentsHost는 이전 filter chapter에서 다루었다. 위의 sample에서, ArgumentsHost에 정의된 동일한 helper method를 사용하여 request 객체에 대한 참조를 얻고 있다. exception filters의 Arguments host section에서 더 자세히 알 수 있다.
ArgumentsHost를 확장함으로써, ExecutionContext 또한 몇 개의 새로운 helper methods를 추가할 수 있다. 이들은 현재 실행중인 process에 대한 추가 detail들을 제공한다. 이러한 detail들은 좀더 generic한 guard를 설계하는데 유용하다. guard들은 controllers, methods 그리고 execution context에 걸쳐서 사용된다. ExecutionContext에 대한 자세한 설명
ArgumentsHost
- 다양한 유형의 핸들러(HTTP, RPC, WebSocket 등)에 대해 인수를 가져오는 데 사용된다.
- 파이프, 인터셉터, 예외 필터 등에서 인수를 다루는 데 사용된다.
주요 메서드
1. getArgs(): 인수 배열을 반환한다.
2. getArgByIndex(index: number): 특정 인수를 반환한다.
3. switchToHttp(): HTTP 컨텍스트로 전환한다.
4. swtichToRpc(): RPC 컨텍스트로 전환한다.
5. switchToWs(): WebSocket 컨텍스트로 전환한다.
ExecutionContext
- ArgumentsHost를 확장하여 실행 컨텍스트에 대한 더 많은 정보를 제공한다.
- 현재 실행 중인 컨텍스트(컨트롤러, 핸들러 등)에 대한 추가 메타데이터를 제공한다.
- 주로 가드와 인터셉터에서 컨트롤러와 핸들러 수준의 정보를 다루는데 사용
주요 메서드
1. getClass(): 현재 실행 중인 클래스(컨트롤러)를 반환한다.
2. getHandler(): 현재 실행 중인 핸들러(메서드)를 반환한다.
3. getType(): 현재 실행 중인 컨텍스트 타입을 반환한다.
Role-based authentication
이제 좀더 기능적인 특정한 규칙을 통해 허락된 유저만 가능토록하는 기능적인 guard를 설계해보자. 일단 먼저 basic guard template로 시작한다. 아래의 코드는 all request가 처리되도록 허용한다.
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;
}
}
Binding guards
pipe나 exception filter와 같이, guard는 controller-scoped, global-scoped가 될 수 있다. 아래에서는 @UseGuards() decorator를 사용하여 controller-scoped guard를 설정했다. 이 decorator는 single argument, 혹은 comma-separated list of arguments가 받는다. 이는 한번의 선언(declaration)으로 적절한 guard set이 적용되도록 한다.
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}
<팁!>
@nestjs/common package를 통해 @UseGuards()를 import한다.
위에서 RolesGuard class를 전달한다.(instance가 아닌), 이는 framework에게 instance화의 책임을 넘기고 DI를 가능케 한다.
pipe와 exception filter와 비슷하게, instance를 넘겨줄 수 있다.
@Controller('cats')
@UseGuards(new RolesGuard())
export class CatsController {}
위의 구조는 controller에 선언된 모든 handler는 guard가 붙여진다. 만일 single method에 guard가 붙여지길 원한다면, 우리는 @UseGuards() decorator를 method level에서 적용할 수 있다.
global guard를 설정하기 위해서, Nest application instance의 useGlobalGuards() method를 사용할 수 있다.
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());
hybrid App의 경우 useGlobalGuards() method는 gateway나 micro service에 guard() 설정하지 못한다.(만일 바꾸고 싶다면 Hybird application 참고) "standard"(non-hybird) microservice app의 경우, useGlobalGuard()는 guard를 global하게 마운트된다.
Global guard의 경우 전체 application, 모든 controller, 모든 route handler에 사용된다. Dependency injection 관점에서, module(위의 코드 처럼 useGlobalGuards())의 바깥에서 global guards는 dependency를 inject하지 못한다. 왜냐하면 이는 모듈의 바깥에서 이루어졌기 때문이다. 이러한 문제를 해결하기 위해, 다음과 같은 방식으로 module에서 바로 guard를 설정하면 된다.
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
<팁!>
guard에 대해 이러한 방식으로 dependency injection을 사용할 때, 이러한 구성이 사용되는 모듈에 관계없이 실제로 전역적이라는 점에서 유의해야한다. 어디서 이 작업을 수행할까? guard(위의 예제에서 정의된 RolesGuard)를 정의한 module을 선택해라. 또는 useClass는custom provider 등록을 처리하는 유일한 방법이 아니다. 자세히
Setting roles per handler
앞의 RolesGuard는 잘 동작한다. 하지만 그렇게 smart하지는 않다. 아직 guard의 기능 들중 가장 중요한 것을 보지 않았다.(execution context). 아직 역할이나 각 핸들러에 허용된 역할에 대해 알지 못한다. 예를 들어 CatsController의 경우 다양한 route에 대해 permisstion scheme가 다르다. 일부는 오직 admin user에게만 가능할 수도 있고, 다른 것들은 everyone에게 허용될 수 있다. 어떻게 다양한 route에 대해 각각에 role를 유연하고 재사용가능하게 맞출 수 있을까?
여기서 custom metadata가 중요한 역할을 한다. 자세히 Nest는 Reflector.createDecorator static method를 통해 만들어진 decorator나 built-in @SetMetadata() decorator를 통해 custom metadata를 route handler에 붙이는 능력을 제공한다.
예를 들어, metadata를 handler에 붙여주는 Reflector.createDecorator method를 통해 @Roles() decorator를 만들었다고 가정하자. Reflector는 @nestjs/core package에서 framework에 의해 제공된다.
import { Reflector } from '@nestjs/core';
export const Roles = Reflector.createDecorator<string[]>();
Roles decorator는 string[] type 하나를 인자로 받는 함수이다.
이 decorator를 이용하기 위해, 간단히 handler에 붙인다.
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
Roles decorator metadata를 create() method에 붙인다. 결국 admin이 있는 user만 이 route에 접근할 수 있다.
대신 Reflector#createDecorator method를 붙이면, 내장된 @SetMetadata() decorator를 사용할 수 있다. 자세히
Putting it all together
앞으로 돌아가 RolesGuard를 다시 묶어준다. 현재, request에 대해 모두 true를 반환한다. 현재 사용자가 할당받은 역할을 현재 처리 중인 route에 필요한 실제 역할과 비교하여 반환 값을 조건부로 만들고자 한다. route의 역할(custom metadata)에 접근하기 위해서, Reflector helper class를 다시 사용해야 한다.
// roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
// SetMetadata(메타데이터 키, 메타데이터 값)
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
// roles.guard.ts
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 {
// context.getHandler()를 통해 현재 핸들러의 메타데이터를 가져온다.
// 메타데이터 키 'roles'에 설정된 역할들을 가져와서, 현재 요청을 보낸 사용자의 역할과 비교
const roles = this.reflector.get('roles', context.getHandler());
// 역할이 정의되어 있지않다면, 모두 허용한다는 의미
if (!roles) {
return true;
}
// api를 요청하는 유저의 roles과 현재 api에 지정된 roles를 비교
const request = context.switchToHttp().getRequest();
const user = request.user;
return matchRoles(roles, user.roles);
}
}
// admin.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { RolesGuard } from './roles.guard';
import { Roles } from './roles.decorator';
@Controller('admin')
@UseGuards(RolesGuard)
export class AdminController {
@Get()
@Roles('admin') // 'admin' 역할이 필요함을 나타내는 데코레이터
findAll() {
return 'This is an admin route';
}
}
<팁!>
node.js에서, authorized user를 request object에 붙이는 것은 흔한 관행이다. 즉, 위의 코드에서, request.user는 user instance와 허용된 역할을 가지고 있다고 가정하고 가져온다. app 내에서, custom authentication guard(or middleware)에서 이러한 연관을 만들 가능성이 높다. 다음 챕터에서 자세히
<주의사항!>
matchRoles() 함수 내의 logic은 simple하거나 sophisticated 할 수 있다. 이 example의 포인트는 어떻게 guard가 request/response cycle에서 보여지는가 이다.
Reflection and metadata을 참고해라.
endpoint에서 만일 user가 불충분한 권한으로 request 한다면, nest는 자동으로 다음과 같은 response를 보낸다.
{
"statusCode": 403,
"message": "Forbidden resource",
"error": "Forbidden"
}
만일 guard가 false를 반환한다면, framework는 ForbiddenException을 던진다. 만일 다른 error response를 원한다면, 다음과 같이 구체적인 exception을 던져야 한다.
throw new UnauthorizedException();
guard에 의해 발생한 exception은 exception layer에서 다뤄진다.
<팁!>
만일 실제 예시와, 어떻게 authorization이 구현되는지 보고 싶다면, 자세히