NestJS Overview - Guards

Min Su Kwon·2021년 10월 31일
0

가드는 @Injectable() 데코레이터가 달린 클래스로, CanActivate 인터페이스를 implement 해야한다.

가드는 하나의 책임을 가지고 있다. 그건 바로 현재 들어온 요청이 라우트 핸들러에 의해 핸들링 되어야 할지 말지를 특정 조건에 따라서 런타임에 결정하는 책임이다. 이는 사람들이 주로 말하는 "authorization" 역할을 뜻하며, 주로 Express 애플리케이션에서 미들웨어에 의해 핸들링되어 왔다. 미들웨어가 이 책임을 가져가는 것은 괜찮은 선택으로, 토큰 검증이나 request 객체에 프로퍼티를 추가로 붙이는 등의 행동이 특정 라우트 컨텍스트에 연결되어 있지 않기 때문에 더더욱 그렇다.

하지만 미들웨어는 기본적으로 어떤 핸들러가 next() 콜백 함수 이후에 호출될지 모른다. 반면에, 가드는 ExecutionContext 인스턴스에 접근할 수 있으므로, 당므으로 실행될 것이 무엇인지 정확히 알고 있다. 가드는 예외 필터, 파이프, 인터셉터등과 같이 요청/응답 사이클의 적절한 시점에 원하는 로직을 선언적으로 끼워넣을 수 있게 해준다.

가드는 미들웨어 다음, 인터셉터/파이프 전에 실행된다.

Authorization guard

위에 언급한 것처럼 authorizaiton이 가드를 사용하기 아주 좋은 유즈 케이스다. 특정 라우트들이 충분한 권한을 가진 경우에만 접근 가능해야하는 경우를 커버할 수 있기 때문이다. 이제부터 만들어볼 AuthGuard 가드는 사용자가 authenticate되었는지 확인해줄 것이다. 이를 위해 요청에 포함된 토큰을 추출하고 검증해서 요청 사이클이 계속 진행되어야하는지 확인해줄 것이다.

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() 함수를 구현해야한다. 이 함수는 boolean 값을 반환하며, 현재 요청이 허용되는지 또는 안되는지를 알려준다. 응답은 sync/async 어떤 방식으로든 반환될 수 있으며, 네스트는 이 값에 따라서 다음 액션을 정한다.

  • true → 요청이 계속해서 처리됨
  • false → 요청을 거절

Execution context

canActivate() 함수는 ExecutionContext 인스턴스를 하나의 인자로 받는다. ExecutionContext 객체는 ArgumentsHost를 상속 받는다. 앞서 ArgumentsHost를 사용했던 방식대로, 헬퍼 함수를 사용해서 Request 객체 레퍼런스를 받아온다.

ArgumentsHostExecutionContext를 extend 하면 현재 실행 프로세스와 관련된 추가적인 디테일을 제공하는 몇가지 헬퍼 메서드들을 사용할 수 있게된다. 이를 이용해서 다양한 컨트롤러/메서드/실행 컨텍스트에서 동작하는 제네릭한 가드를 만들 수 있다.

Role-based authentication

좀 더 구체적인 권한을 가지고 있는 사용자만 접근할 수 있는 가드를 만들어보자. 우선은 간단한 가드 템플릿으로 시작하며, 이어질 섹션에서 좀 더 구체화한다. 지금으로써는 모든 요청을 통과시킨다.

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

파이프나 예외 필터처럼, 가드는 컨트롤러/메서드/전역 스코프 모두 적용 가능하다. 컨트롤러 스코프의 가드는 @UseGuards() 데코레이터를 통해서 바인딩 할 수 있다. 이 데코레이터는 콤마로 구분된 매개변수의 리스트를 하나의 인자로 받는다. 이를 통해서 알맞은 가드들을 하나의 선언을 통해 간단하게 적용할 수 있다.

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

위에서, RolesGuard 타입만 넘겼는데, 이는 네스트 프레임워크에게 인스턴스화 + 의존성 주입 책임을 넘기는 것을 의미한다. 파이프나 예외 필터처럼 직접 인스턴스를 넘길수도 있다.

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

위처럼 코드를 작성해놓으면, 해당 가드를 컨트롤러에 선언된 모든 핸들러에 적용한다. 가드가 하나의 메서드에만 적용되길 원한다면, 마찬가지로 @UseGuards() 데코레이터를 메서드 레벨에 적용하면된다.

전역 가드를 선언하려면, useGlobalGuards() 메서드를 네스트 인스턴스에 사용한다.

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

전역 가드는 애플리케이션 전체 컨트롤러/라우트 핸들러에 적용된다. 전역 가드가 모듈 밖에서 적용되기 때문에 의존성 주입을 적용할 수 없는데, 아무 모듈에서 아래와 같은 형식으로 코드를 작성하면, 모듈 내에서 전역 가드를 적용할 수 있다.

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

현재로써는RolesGuard가 가드의 가장 중요 기능인 실행 컨텍스트 기능을 활용하고 있지 않다. 역할에 대해서 잘 모르고, 어떤 핸들러가 어떤 역할을 필요로 하는지 모른다. 어떤 핸들러는 관리자 권한을 가진 사용자에게만, 또 다른 핸들러는 모든 사용자에게 열려 있을 수 있다. 어떻게 하면 사용자 역할과 라우트를 유연하고 재사용가능한 방법으로 매칭할 수 있을까?

이때 커스텀 메타데이터가 유용해진다. 네스트는 @SetMetadata() 데코레이터를 통해서 커스텀 메타데이터를 라우트 핸들러에 붙일 수 있게 해준다. 이 메타데이터가 사용자의 role 데이터를 제공해줄 수 있다.

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

위와 같은 코드를 통해서 roles 메타데이터를 create() 메서드에 붙였다. 이 방법은 유효하지만, @SetMetadata 데코레이터를 라우트 핸들러에 다이렉트로 사용하는 것은 추천되지 않는다. 대신, 아래와 같은 방법으로 커스텀 데코레이터를 만드는 편이 좋다.

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

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

위 접근법이 훨씬 깔끔하고 가독성이 좋으며, 강하게 타이핑된다. 이제 @Roles() 커스텀 데코레이터가 있으니, 이 데코레티어를 create() 메서드에 붙일 수 있다.

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

Putting it all together

이제 RolesGuard 가드 클래스로 돌아간다. 현재는 모든 케이스에 true를 반환하고 있어서, 어떤 요청도 막지 않는다. 이제 현재 사용자의 role에 따라서 조건부로 결과를 반환하고자 한다. 라우트의 role 커스텀 메타데이터에 접근하기 위해서, Reflector 헬퍼 클래스를 사용할 것이다.

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 환경에서 authorize된 유저 데이터를 request 객체에 붙이는 것은 흔한 일이다. 따라서 위 코드에서는 request.user 객체에 사용자 인스턴스와 허용된 roles가 있다고 가정한다. 실활용을 위해서는 이 user 객체를 붙이는 과정을 어딘가에(authentication 가드 또는 미들웨어) 포함시켜야한다.

matchRoles() 함수는 필요에 따라 간단할 수도 있고 복잡할 수도 있다.

Reflector를 좀 더 문맥에 민감한 쪽으로 사용하고 싶다면 다음 문서를 살펴본다.

충분한 권한을 가지지 않는 사용자가 요청을 보내면, 네스트가 자동으로 다음과 같은 응답을 반환한다.

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

실제로는 가드가 false 값을 반환하면, 프레임워크가 ForbiddenException을 던지게 된다. 다른 에러 응답을 반환하고 싶으면, 직접 다른 예외를 던져줘야한다.

throw new UnauthorizedException();

가드가 던진 예외는 모두 예외 레이어에서 핸들링 될 것이다.

느낀 점

대부분의 경우에 반드시 필요로 하는 authorization과 관련된 로직을 넣을 수 있는 좋은 위치인 것 같다. 미들웨어 이후, 파이프/인터셉터 전에 실행되기 때문에 위치도 적절하고 적용법도 데코레이터를 이용해 원하는 스코프에 편하게 적용할 수 있어 간단하다.

커스텀 메타데이터를 활용한 추가적인 권한 설정이나 조건 분기도 가능해서, 유연한 구조를 가지고 있는 것 같다. 앞으로 적극적으로 활용해야 하지 않을까 생각이 든다.

profile
이제 막 커리어를 시작한 소프트웨어 엔지니어입니다. 배운 것을 정리하면서 조금 더 깊이 이해하려는 습관을 들이려고 합니다. 피드백은 언제나 환영입니다.

0개의 댓글