폴라 사용자는 카뎃/멘토/보컬(스태프)로 나눌 수 있다.
각 사용자의 역할에 따라 사용 가능한 기능이 다르기 때문에, 역할에 따라 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
는 인터페이스이다. (Nest 코드)
가드는 CanActivate
인터페이스의 canActivate
함수를 구현해야 한다고 한다.
canActivate
함수는 현재 요청의 처리를 허용할지 말지 알려주는 값을 리턴한다.
그러니까 나의 경우, 사용자의 역할과 api의 요구 역할을 비교한 후 처리를 계속 허용할 거면 true, 아니면 false를 리턴하면 된다.
그럼 사용자의 역할과 api의 요구 역할은 어떻게 알아낼까?
https://docs.nestjs.com/fundamentals/execution-context
Nest는 다양한 실행 환경(HTTP 서버 기반, 마이크로서비스, 웹소켓)에서 작동하는 프로그램을 쉽게 만들 수 있도록 지원하는 여러 클래스를 제공한다.
ExecutionContext
는 ArgumentsHost
를 상속받는다.
ArgumentsHost
클래스는 핸들러로 전달되는 매개변수를 검색하는 메서드를 제공한다. 매개변수를 검색할 적절한 실행 환경(HTTP, RPC, 웹소켓)을 선택할 수 있다.
프레임워크는 보통 호스트 매개변수로 참조되는 ArgumentsHost
의 인스턴스를 액세스할 수 있는 위치에 제공한다.
(ex. 예외 필터의 catch() 메서드는 ArgumentsHost
인스턴스와 함께 호출됨)
ArgumentsHost
는 핸들러의 인수에 대한 추상화 역할을 한다.
Express를 사용한다고 가정하면, 호스트 개체는 Express의 [ Request, Response, Next ] 배열을 캡슐화한다.
GraphQL을 사용하는 경우, [ Root, Args, Context, Info ] 배열을 포함한다.
// 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
는 ArgumentsHost
를 상속받는다고 했다.
따라서 핸들로의 인수에 대한 정보를 얻을 수 있음 + 호출하려는 핸들러 메서드에 대한 레퍼런스를 얻을 수 있고 해당 핸들러가 속한 컨트롤러 클래스의 타입 또한 얻을 수 있다.
예를 들어 HTTP 환경에서 처리된 요청이 CatsController
의 create()
메서드에 바인딩된 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마다 허용하는 역할을 지정해두면 되는데, 그러려면 메타데이터에 대하여 알아야 한다.
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
를 주입해보자.
@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를 완성했다.