NestJS 공식문서 번역 겸 공부하는 글 입니다.
의역 및 오역이 있을 수 있습니다.
https://docs.nestjs.com/guards

Guards

Guards

guard는 @Injectable() 데코레이터로 주석된 클래스이다. 모든 guard는 CanActivate 인터페이스를 구현해야 한다.

guards

guard는 단일 책임을 갖는다. guard는 런타임 시, 조건(권한, 역할, ACL 등)에 따라 request가 route handler에 의해 수행될지 아닐지를 결정한다. 이것은 종종 authorization을 가리킨다. 전통적인 express 애플리케이션에서 authorization(혹은 authentication)은 미들웨어에서 처리된다. 토큰의 유효성을 검사하고, request 오브젝트에 프로퍼티를 추가할 수 있기때문에 미들웨어에서 인증을 구현하는 것은 좋은 선택이다.

그러나 미들웨어는 멍청하다. 미들웨어는 next() 함수의 호출로 어떤 handler가 수행될지 모른다. 반면, guard는 ExecutionContext 인스턴스에 접근해, 다음에 무엇이 수행될지 알 수 있다. exception filter, interceptor, pipe와 유사하게 디자인되어, request/response cycle에서 정확한 지점에 끼어들어 가공로직을 수행한다. 이것은 코드를 더욱 DRY하게 해줄것이다.

[hint] guard는 각각의 미들웨어 다음에 실행되며, interceptor나 pipe 이전에 실행된다.

Authorization guard

앞서 말했듯, 특정 router들은 충분한 권한을 가진자가 호출했을 때에만 작동해야 하므로, authorization은 guard의 매우 주요한 이용 사례이다. AuthGuard는 인증된 유저를 가려낸다(request header에 첨부된 토큰을 확인). 이것은 토큰의 유효성을 확인하고, request가 진행될지 아닐지를 결정할 것이다.

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

[hint] 여러가지 authentication에 대한 구현 메커니즘에 대해 알고 싶으면 여기를 방문해라. 또한 authorization에 대한 예제들을 보고싶아면 여기를 방문해라.

validationRequset() 내부의 로직은 간단하거나 필요에 따라 복잡할 수 있다. 이 예제의 주요 포인트는 guard가 request/response cycle에 얼마나 적합한지 보여주기 위함이다.

모든 guard는 canActivate() 함수를 구현해야한다. 이 함수는 boolean 함수를 리턴하고, 이는 현재 request가 수락될지 아닐지를 가리킨다. 또한 동기/비동기로 작동할 수 있다. Nest는 리턴값을 다음 액션을 컨틀롤하기 위해 사용한다.

  • true를 리턴하면, request는 계속 진행된다.
  • false를 리턴하면, request를 거절한다.

Execution context

canActivate() 함수는 ExecutionContext 인스턴스 하나를 argument로 받는다. ExecutionContext는 ArgumentsHost를 상속받는다. ArgumentsHost는 exception filter 챕터에서 보았다. request 오브젝트를 참조하기 위해, ArgumentsHost에 정의된 helper method들을 사용하고 있다.

ExecutionContext는 현재 실행중인 프로세스에 대한 정보를 가져오기위해 몇개의 helper method가 추가되었다. 이러한 세부사항은 controller, methods, execution context를 걸쳐 제네릭 guard를 구축하는데 도움이 된다. ExecutionContext에 대해 더 알고싶다면 이곳을 참고하자.

Role-based authentication

특정 role을 가진 사용자만 허용하는 gaurd를 만들어보자. 기본 guard template에서 시작하여, 이후의 섹션에서 내용을 추가할 것이다.

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

Binding guards

exception filter, pipe처럼 guard도 controller-scope, method-scope, global-scope일 수 있다. 아래에서 @UseGaurd() 데코레이터를 사용하여, controller-scope guard를 세팅하였다. 이 데코레이터는 단일 또는 여러개의 인자를 받을 수 있다.

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

위에서 RolesGuard 클래스를 넘겨주었고, 프레임워크에서 이를 인스턴스화 한다. pipe나 exception filter와 마찬가지로 인스턴스를 바로 넘겨줄 수도 있다.

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

위와 같은 구성은 해당 controller의 모든 handler에 guard가 적용된다. 특정 method에만 적용하고 싶다면, method level에서 @UseGaurds() 데코레이터를 사용하면 된다.

global guard를 세팅하기 위해서는, useGlobalGuards() method를 사용한다.

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

하이브리드 앱에서는 useGlobalGuards() 메서드가 동작하지 않는다.

global guard는 전체 애플리케이션에서 모든 controller와 모든 route handler에 적용된다. 이 경우 모듈 외부에서 등록되기 때문에 의존성 주입에 의한 것이 아니다. 이를 해결하기 위해서, 아래처럼 모듈에 직접 등록할 수도 있다.

// app.module.ts

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

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

Setting roles per handler

RolesGuards가 작동은 하지만, 아직 그렇게 똑똑하지 않다. 아직 우리는 guard의 가장 중요한 특성인 execution context를 사용하지 않았다. CatsController에서 각각의 route는 서로 다른 permission scheme을 갖고 있다. 어떤것은 admin user에게만 유효하며, 또 어떤것은 모든 유저에게 유효하다. 어떻게 route마다 role을 매칭하고, 재사용가능하게 만들 수 있을까?

바로 custom metadata가 필요한 때이다(자세한 내용은 이곳에서 확인). Nest는 @SetMetadata() 데코레이터를 통해 route handler에 custom metadata를 붙일 수 있다. 이 메타데이터는 guard에게 role을 제공하여 guard의 의사결정에 도움을 준다.

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

위의 예제에서, create() method에 roles 메타데이터를 붙였다(roles는 key이며 [‘admin’]은 value). 이것은 동작하지만, @SetMetadata()의 좋은 사용법은 아니다. 대신 custom decorator를 만드는 것이 좋다.

// roles.decorator.ts

import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

위의 예제가 더욱 깔끔하고 가독성도 좋으며, 타입이 부여되어 있다. 이제 custom @Roles() 데코레이터를 create() method에 적용해보자.

// cats.controller.ts

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

Putting it all together

이제 우리의 RolesGuard로 돌아가보자. 지금은 모든 경우에 true를 반환할 것이다. 현재의 route에서 요구하는 role과 현재 유저에게 부여된 role을 비교하여 리턴값을 결정할 것이다. route에 부여된 role(custom metadata)에 접근하기 위해, Reflector helper class를 사용한다.

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

더 자세한 정보는 Execution Context 챕터의 Reflection and metadata 섹션을 참고하라.

권한을 만족하지 못하는 유저가 엔드포인트로 request를 보낼 경우, Nest는 자동으로 아래의 response를 보낼 것이다.

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

guard는 false를 리턴하고, 프레임워크는 ForbiddenException을 throw 하였다. 만약 다른 response를 보내고 싶은 경우, 아래처럼 원하는 예외를 throw 하면 된다.

throw new UnauthorizedException();

guard에 의해 throw된 모든 예외는 exception layer에서 처리된다.