예제 코드는 Github에 있습니다:)
NestJs에서 Guard의 사용법을 알아보기.
- ExcutionContext
- custom metadata
NestJs Guard 만들어보기.
가드는 @Injectable() 데코레이터를 사용하며 CanActivate 인터페이스를 구현한 클래스이다.
@Injectable()를 사용한 이유는 인스턴스 대신 타입을 전달하여 사용하고, 인스턴스화에 대한 책임은 프레임워크에 남겨두고 의존성을 주입가능하게 하기 위해서이다. (new를 이용하여 인스턴스를 넣어줄 수도 있으며 자세한 건 아래에서 설명하겠다.)
가드는 단일 책임을 가지며 특정한 상황들(permissions, role, ACLs...)에 따라, 주어진 request가 라우트 핸들러에 의해 처리 여부를 결정한다. Express에서는 주로 미들웨어로 처리를 하였다.
미들웨어는 주로 인증에 관련된 작업을 사용할 때 사용하며 NestJs에서 인가에 관련된 작업은 가드를 통해 이뤄진다.
공식문서에 의하면 미들웨어는 next()가 호출 된 후 어떠한 라우트 핸들러가 실행될 지를 모른다. 하지만 가드는 ExcutionContext를 사용할 수 있기 때문에 다음에 어떠한 라우트 핸들러가 실행되는지 정확하게 알 수 있다.
HINT
Guards are executed after each middleware, but before any interceptor or pipe.
(가드는 미들웨어 이후에 실행되며 인터셉터와 파이프 이전에 실행된다.)
CanActive 인터페이스의 구조는 아래와 같다.
export interface CanActivate {
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean>;
}
canActivate라는 메소드가 있으며 파라미터로 실행 컨텍스트(ExecutionContext)를 받고 있다.
canActivate 메소드의 파라미터로 받는 실행 컨텍스트는 ArgumentHost를 상속받고 있다.
export interface ArgumentsHost {
getArgs<T extends Array<any> = any[]>(): T;
getArgByIndex<T = any>(index: number): T;
switchToRpc(): RpcArgumentsHost;
switchToHttp(): HttpArgumentsHost;
switchToWs(): WsArgumentsHost;
getType<TContext extends string = ContextType>(): TContext;
}
export interface ExecutionContext extends ArgumentsHost {
getClass<T = any>(): Type<T>;
getHandler(): Function;
}
ArgumentsHost는 이전 filter를 사용할 때 보았을 것이다.
ExecutionContext는 ArgumentsHost를 상속 받았기 때문에 각 통신 프로토콜에 맞는 switch 메소드를 통해 Request, Response, next()를 얻어올 수 있다.
여기서 중요하게 보아야 할 점은 ExecutionContext가 가지고 있는 method이다.
getClass는 클라이언트로 요청이 들어왔을 때 처리할 수 있는 라우트 핸들러를 가진 컨트롤러에 대한 정보를 가지고 있으며, getHandler는 클라이언트로 들어온 요청을 처리하는 라우트 핸들러에 대한 정보를 가지고 있다.
그렇기 때문에 미들웨어와 달리 이 후 실행되는 컨트롤러나 라우트 핸들러에 대한 정보를 알 수 있게 되는 것이다.
위에서 작성한 내용들을 통해 커스텀 가드는 쉽게 만들 수 있다.
@Injectable()
export class RoleGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
return true
}
}
위와 같이 작성한 커스텀 가드를 적용하는 방법은 파이프 적용 레벨과 크게 다르지 않다. 핸들러-레벨에 적용하여 예제 코드를 작성해보겠다.
export class AppController {
constructor(private readonly appService: AppService) { }
@Get()
@UseGuards(RoleGuard)
getHello(): string {
return this.appService.getHello();
}
}
위와 같이 @UseGuards 데코레이터를 이용하여 가드를 적용시킬 수 있으며 현재 해당 엔드포인트로 요청을 보내면 RoleGuard에서 true를 반환하고 있어 아무런 일이 일어나지 않을 것이다.
하지만! false값을 반환하게 되면 아래와 같은 메세지를 클라이언트가 받을 수 있다.
{
"statusCode": 403,
"message": "Forbidden resource",
"error": "Forbidden"
}
만약 위와 같은 메세지를 클라이언트에게 전달하는 것이 아니라 커스텀 하고 싶다면 인가를 실패하는 경우 새로운 예외를 throw한 후 예외 필터에서 처리하여 응답을 조작할 수 있다.
위와 같이 가드를 만들 수 있지만 공식문서에 의하면 스마트한 방법은 아니다. 가드의 가장 큰 장점은 위에서도 설명하였듯 ExecutionContext를 사용할 수 있다는 점이다.
다음 어떠한 라우트 핸들러 혹은 컨트롤러가 사용되는지 알 수 있기 때문에 요청이 들어오는 엔드포인트가 Role에 맞게 실행될 수 있도록 할 수 있다. 이 때 등장하는 개념이 custom metadata이다.
custom metadata는 @SetMetadata()를 이용하여 컨트롤러 혹은 라우터 핸들러에 metadata를 정의할 수 있다.
@Get()
@SetMetadata('role', 'admin')
@UseGuards(RoleGuard)
getHello(): string {
return this.appService.getHello();
}
위와 같이 @SetMetadata()를 이용하여 role이라는 키 값으로 admin이라는 metadata를 정의하여 getHello 라우트 핸들러에 부여한 것이다.
하지만 공식문서에 따르면 라우터 핸들러에 직접 @SetMetadata()를 이용하는 것은 좋지 않은 방법이라고 말하고 있다. 그렇기 때문에 커스텀 데코레이터를 만들어 지정해 주도록 하자.
// custom decorator
export const Roles = (role: string) => SetMetadata('role', role);
// route handler
@Get()
@Role('admin')
@UseGuards(RoleGuard)
getHello(): string {
return this.appService.getHello();
}
커스텀 데코레이터를 이용하여 Role를 부여함으로 읽기 좋은 코드가 되었으며 Type을 강제할 수 있게 되었다.
위의 개념들을 전부 합치면 @SetMetadata()(or 위에서 만든 커스텀 데코레이터)를 이용하여 라우트 핸들러에 metadata를 부여하고 ExecutionContext를 이용하여 가드를 통과하여 이후에 실행되는 라우터 핸들러에 대한 정보를 알 수 있었다.
그럼 라우터 핸들러에 부여된 metadata를 어떻게 가져올 수 있을까? NestJs에서는 Reflector를 이용하여 metadata에 접근을 할 수 있다. Reflector는 생성자에서 DI를 받을 수 있다.
@Injectable()
export class RoleGuard implements CanActivate {
constructor(private readonly reflector: Reflector) { }
canActivate(context: ExecutionContext,): boolean {
const role = this.reflector.get<string>('role', context.getHandler());
// do something
}
}
export declare class Reflector {
//...
get<TResult = any, TKey = any>(metadataKey: TKey, target: Type<any> | Function): TResult;
//...
}
Reflector에서 제공하는 get을 이용하여 metadata를 가져올 수 있으며 파라미터로 첫번 째는 키, 두번 째는 타입 혹은 Function을 받는다. 이 때 타입이나 Function에 대한 정보를 ExecutionContext에서 얻어올 수 있게 되는 것이다.