Node.js/NestJS

[NestJS] 8. 가드

턴태 2022. 9. 30. 10:22

가드

가드는 @Injectable() 데코레이터를 사용하여 CanActivate 인터페이스를 구현하는 클래스입니다.

가드는 단일 기능을 갖고 있습니다. 가드는 런타임에 존재하는 특정 조건(허가, 역할, ACLs 등)에 따라서 주어진 요청이 라우트 핸들러에 의해서 처리될지 처리되지 않을지의 여부를 지정합니다. 이것은 종종 권한 부여라고도 부릅니다. 권한 부여(인증과 함께 작용하는)는 일반적으로 익스프레스 어플리케이션에서 미들웨어에 의해 처리됩니다. 미들웨어는 토큰 유효성 검사와 요청 객체에 대한 프로퍼티에 접근하는 것 같은 작업이 특정 라우트 컨텍스트(+메타데이터)와 함께 강력하게 결합되어 있지 않기 때문에, 인증을 위한 좋은 방법입니다.

 

그러나 미들웨어를 사용하는 것은 본질적으로 좋은 선택은 아닙니다. 미들웨어는 next() 함수를 거쳐서 다음에 어떤 핸들러가 있을지 모르기 때문입니다. 반면에 가드는 ExecutionContext 인스턴스에 대해 접근할 수 있어서 정확히 다음에 어떤 것을 수행할지 알 수 있습니다. 가드는 예외필터, 파이프 그리고 인터셉터와 같이 요청/응답 사이클에서 적합한 지점에 정확히 처리 로직을 넣을 수 있게 해주고, 선언적으로 처리할 수 있도록 고안되었습니다. 이는 코드를 DRY하고 선언적이게 유지해줍니다.

 

더보기

가드는 모든 미들웨어 다음에 수행되지만, 모든 인터셉트 또는 파이프 이전에 수행됩니다.

권한 부여 가드

앞에서 언급했듯이, 권한 부여는 가드의 좋은 사용 사례입니다. 왜냐하면 특정 라우트는 호출자(대개 특정 권한을 부여받은 유저)가 충분한 허가를 부여받은 경우에만 이용할 수 있어야 하기 때문입니다. 여기서 구축할 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);
  }
}

function validateRequest(
  request: any,
): boolean | Promise<boolean> | Observable<boolean> {
  throw new Error('Function not implemented.');
}

validateRequest() 함수 안에 있는 로직은 필요에 따라 간단하거나 정교해질 수 있습니다. 이 예제의 주된 포인트는 어떻게 가드가 요청/응답 사이클에 알맞게 들어가는지를 보여주는 것입니다.

 

모든 가드는 canActivate() 함수를 구현해야 합니다. 이 함수는 반드시 현재 요청이 허용되는지 아닌지 나타내는 불리언 자료형을 반환해야 합니다. 가드는 응답을 동기적으로 혹은 비동기적으로 반환합니다(Promise 혹은 Observable을 통해). Nest는 아래의 행동을 제어하기 위해 반환값을 사용합니다.

  • 만약 가드가 true를 반환하면 요청은 처리됩니다.
  • 만약 가드가 false를 반환하면 Nest는 요청을 거부합니다.

실행 컨텍스트

canActivate() 함수는 ExecutionContext 인스턴스를 단일 인수로 가집니다. ExecutionContextArgumentsHost로부터 상속받습니다. 위의 예제에서, 요청 객체에 대한 정보를 얻기 위해 이전에 사용했었던 ArgumentsHost에 정의된 헬퍼 메서드를 사용하고 있습니다.

 

ArgumentHost를 확장함으로써 ExecutionContext는 또한 현재 실행 처리 상황에 대한 추가적인 세부 사항들을 제공해주는 새로운 헬퍼 메서드를 추가할 수 있습니다. 이런 세부 사항들은 광범위한 컨트롤러, 메서드, 실행 콘텍스트를 거쳐서 함께 작동하는 조금 더 일반적인 가드를 구축하는 데 도움을 줍니다.

 

역할 기반 인증

그러면 특정 역할을 가진 유저에게만 허가를 해주는 조금 더 기능적인 가드를 만들어봅시다. 기본 가드 템플릿으로 시작하며 이후에 기능적인 가드로 발전시키겠습니다.

 

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}

가드 적용

파이프와 예외 필터와 같이 가드는 컨트롤러 스코프, 메서드 스코프, 또는 전역 스코프로 사용될 수 있습니다. 아래의 코드에서는 @UseGuards() 데코레이터를 사용하여 컨트롤러 스코프 가드를 세팅했습니다.이 데코레이터는 단일한 인수를 가질 수도, 콤마로 구분된 인수 리스트를 가질 수도 있습니다. 이 데코레이터는 한 번의 선언으로 적절한 가드 세트를 쉽게 적용할 수 있습니다.

 

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

위에서 인스턴스 대신 RolesGuard 타입을 전달하여 인스턴스화에 대한 일은 프레임워크에 남기고 의존성 주입을 활성화하였습니다. 파이프와 예외 필터처럼 내장 인스턴스를 전달할 수도 있습니다.

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

위의 설계는 가드를 컨트롤러에 선언된 모든 핸들러에 적용하였습니다. 만약 오직 한 가지 메서드에만 적용하고 싶다면, @UseGuards() 데코레이터를 메서드 단계에 적용하면 됩니다.

 

전역 가드를 세팅하고 싶다면, Nest 어플리케이션 인스턴스의 useGlobalGuards() 메서드를 사용하세요.

 

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

전역 가드는 모든 컨트롤러와 라우트 핸들러에 대해 전체 어플리케이션에서 사용됩니다. 의존성 주입 측면에서, useGlobalGaurds()와 같이 모든 모듈 외부로부터 등록된 전역 가드는 모든 모듈의 컨텍스트 외부에서 실행되기 때문에 의존성을 주입할 수 없습니다. 이러한 문제를 해결하기 위해서는 모든 모듈에 대해 아래와 같은 설정으로 가드를 직접 설치할 수 있습니다.

 

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

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

핸들러마다 역할 설정하기

우리의 RolesGaurd는 열심히 일하고 있지만, 그리 똑똑하진 않습니다. 가장 중요한 가드의 기능인 실행 컨텍스트의 장점을 아직 이용하고 있지 않기 때문입니다. 아직 역할에 대해 알지 못합니다. 또한, 어떤 역할이 각각 핸들러에 적용되는지도 모릅니다. 예를 들어, CatsController는 각기 다른 라우트에 대하여 개별적인 허가 스키마를 갖고 있습니다. 몇몇은 오직 어드민 유저만 사용하며, 몇몇 스키마는 모두가 사용할 수 있습니다. 어떻게 유연하고 재사용할 수 있는 방향으로 라우트에 역할을 매치시킬 수 있을까요?

 

여기서 사용자 정의 메타데이터가 그 역할을 합니다. Nest는 @SetMetadata() 데코레이터를 통해 라우트 핸들러에 대하여 사용자 정의 메타데이터를 연결할 수 있는 기능을 제공합니다. 이 메타데이터는 조금 더 똑똑한 가드가 결정을 내릴 때 사용하는 역할 데이터를 제공해줍니다. 그러면 @SetMetadata() 에 대해서 알아봅시다.

 

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

위의 코드 설계에서, roles 메타데이터(roles 가 '키'이고, ['admin']이 특정 값)를 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);
}

종합

이제 다시 돌아가서 우리의 RolesGaurd와 함께 연결해봅시다. 현재 이 가드는 단순히 모든 경우에 true를 반환하여 모든 요청이 처리되도록 합니다. 우리는, 현재 처리되고 있는 라우트가 필요로 하는 실제 역할과 현재 유저가 할당받은 역할을 비교하는 것을 기반으로 반환되는 값을 조건에 따라 다르게 하고 싶습니다. 라우트의 역할에 접근하기 위해서 Reflector 헬퍼 클래스를 사용하겠습니다.

 

import { CanActivate, ExecutionContext, Injectable } 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 this.matchRoles(roles, user.roles);
  }
  matchRoles(roles: string[], userRoles: string): boolean {
    return roles.some((role) => role === userRoles);
  }
}

가드가 false를 반환할 때, 프레임워크는 ForbiddenException 예외를 발생시킵니다. 다른 에러 응답을 반환하고 싶다면, 사용자가 직접 특정 예외를 전달할 수도 있습니다.

throw new UnauthorizedException();