[Polar 제작기] 롤가드를 만들어보자 (Execution context)

nakkim·2022년 9월 20일
0

Polar 제작기

목록 보기
3/5

폴라 사용자는 카뎃/멘토/보컬(스태프)로 나눌 수 있다.
각 사용자의 역할에 따라 사용 가능한 기능이 다르기 때문에, 역할에 따라 api 호출을 막아야 할 수도 있다.
(카뎃이 보고서 제출 api에 접근하는 경우 등)

Nest에서는 가드를 이용해서 이런 기능을 구현할 수 있다.

아래는 NestJS docs의 Role Guard 예제코드이다.

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, CanActivate라는 것을 발견할 수 있다.
두 가지에 대해 알아보면서 코드를 확장해보자.


CanActivate

CanActivate는 인터페이스이다. (Nest 코드)

가드는 CanActivate 인터페이스의 canActivate 함수를 구현해야 한다고 한다.
canActivate 함수는 현재 요청의 처리를 허용할지 말지 알려주는 값을 리턴한다.
그러니까 나의 경우, 사용자의 역할과 api의 요구 역할을 비교한 후 처리를 계속 허용할 거면 true, 아니면 false를 리턴하면 된다.

그럼 사용자의 역할과 api의 요구 역할은 어떻게 알아낼까?


ExecutionContext

https://docs.nestjs.com/fundamentals/execution-context

Nest는 다양한 실행 환경(HTTP 서버 기반, 마이크로서비스, 웹소켓)에서 작동하는 프로그램을 쉽게 만들 수 있도록 지원하는 여러 클래스를 제공한다.

ExecutionContextArgumentsHost를 상속받는다.

ArgumentsHost 클래스

ArgumentsHost 클래스는 핸들러로 전달되는 매개변수를 검색하는 메서드를 제공한다. 매개변수를 검색할 적절한 실행 환경(HTTP, RPC, 웹소켓)을 선택할 수 있다.
프레임워크는 보통 호스트 매개변수로 참조되는 ArgumentsHost의 인스턴스를 액세스할 수 있는 위치에 제공한다.
(ex. 예외 필터의 catch() 메서드는 ArgumentsHost 인스턴스와 함께 호출됨)

ArgumentsHost는 핸들러의 인수에 대한 추상화 역할을 한다.
Express를 사용한다고 가정하면, 호스트 개체는 Express의 [ Request, Response, Next ] 배열을 캡슐화한다.
GraphQL을 사용하는 경우, [ Root, Args, Context, Info ] 배열을 포함한다.

Request 객체를 얻는 방법

// host: ArgumentsHost
const [req, res, next] = host.getArgs();
const request = host.getArgByIndex(0);

위 두 방법은 특정 실행 컨텍스트와 엮여있기 때문에 권장하지 않는다.
그럼 어떻게 해야하는가?
적합한 실행 컨텍스트로 전환해서 얻으면 된다.

const httpHost = host.switchToHttp();  // HttpArgumentsHost 객체 반환
// Express type assertion -> Express typed 객체 반환
const request = httpHost.getRequest<Request>();
const response = httpHost.getResponse<Response>();

ExecutionContext 클래스

ExecutionContextArgumentsHost를 상속받는다고 했다.
따라서 핸들로의 인수에 대한 정보를 얻을 수 있음 + 호출하려는 핸들러 메서드에 대한 레퍼런스를 얻을 수 있고 해당 핸들러가 속한 컨트롤러 클래스의 타입 또한 얻을 수 있다.
예를 들어 HTTP 환경에서 처리된 요청이 CatsControllercreate() 메서드에 바인딩된 POST 요청인 경우 getHandler()create() 메서드에 대한 참조를 반환하고 getClass()CatsController 타입(not instance)을 반환한다.


이까지 알아보고 나면 ExecutionContext가 왜 필요한지 알 수 있다.
나는 jwt를 이용하여 요청의 user 프로퍼티에 사용자 정보를 추가하기 때문에, ExecutionContext를 이용하여 사용자 정보를 획득할 수 있다.
예제 코드에 Request 객체에서 유저 정보를 획득하는 코드를 추가해보자.

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return true;
  }
}

이제 해야할 일은, 사용자의 역할에 따라 api 호출을 거절/허락하는 것이다.
어떻게 해야할까?
api마다 허용하는 역할을 지정해두면 되는데, 그러려면 메타데이터에 대하여 알아야 한다.


Metadata

https://docs.nestjs.com/fundamentals/execution-context#reflection-and-metadata

@SetMetadata 데코레이터를 이용하면 클래스/함수에 메타데이터를 할당할 수 있다.
이용 방법은 간단하다.

@SetMetadata('roles', ['admin'])
create() {}

이러면 create() 함수에 값이 admin인 roles 메타데이터를 추가할 수 있다.

가독성을 위해 커스텀 데코레이터를 만들어보자.

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

---
  
@Roles('admin')
create() {}

이제 코드가 좀 더 아름다워졌다..

이 데코레이터가 어떻게 동작하는지 궁금해서 Nest 깃헙을 찾아봤다.

export const SetMetadata = <K = string, V = any>(
  metadataKey: K,
  metadataValue: V,
): CustomDecorator<K> => {
  const decoratorFactory = (target: object, key?: any, descriptor?: any) => {
    if (descriptor) {
      Reflect.defineMetadata(metadataKey, metadataValue, descriptor.value);
      return descriptor;
    }
    Reflect.defineMetadata(metadataKey, metadataValue, target);
    return target;
  };
  decoratorFactory.KEY = metadataKey;
  return decoratorFactory;

Reflect를 이용해서 메타데이터를 추가해주는 것을 알 수 있다.
그럼 Reflect는 어떻게 사용하는가?

Reflector

메타데이터에 접근하려면 Reflector 클래스를 이용해야 한다. (일반적인 방법으로 주입 가능)
사용을 위해 우리 코드에 Reflector를 주입해보자.

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
  
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    return true;
  }
}

https://github.com/nestjs/nest/blob/a80df520bc0f5847eece8d1fd0d5a6f10b3ab6a6/packages/core/services/reflector.service.ts
Nest 깃헙을 뒤져서 reflector 코드를 찾아봤다.
get()을 이용하면 Reflect에 등록했던 메타데이터를 가져오는 것을 볼 수 있다.

다시 코드를 수정해보자.

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
  
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const handler = context.getHandler();  // 메타데이터를 찾을 핸들러 함수
    const roles = this.reflector.get<string[]>('roles', handler);
    return true;
  }
}

이렇게 우리는 유저 정보와 핸들러가 허용하는 역할 모두를 얻었다.


이제 해야할 일은 유저의 역할이 핸들러가 허용하는 역할에 포함되는지 확인하는 것이다.
해당 로직은 알아서 잘 딱 깔끔하게 추가하면 된다.

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
  
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    const user = request.user;  // JwtGuard를 거쳐 오기 때문에 undefined일 확률 없음
    const roles: string[] = this.reflector.get<string[]>('roles', context.getHandler());
    
    if (!roles) return true;  // api가 역할을 제한하지 않음(roles 메타데이터 없음)
    if (!roles.includes(user.role)) {
      throw new ForbiddenException('접근 권한이 없습니다.');
    }
    return true;
  }
}

이로써 아름다운 RolesGuard를 완성했다.

profile
nakkim.hashnode.dev로 이사합니다

0개의 댓글