NestJS 노트 (2) : Guards

SimJungUk·2020년 12월 26일
9

앞서

이 글은 NestJS Guards 공식 문서를 기반으로, 번역하며 배우는 의미로 쓰는 글입니다. 유저 인증 서버를 만들던 중 Guards 에 관해서 다루길래, Guards를 먼저 배워야 하겠다 생각하고 시작합니다.

Guards

Guard는 @Injectable() 데코레이터와 annotated 되는 클래스이다. CanActivate interface를 반드시 implement 해야 한다.

Guards는 단일 책임을 가진다. 특정 상황들(permissions, roles, ACLs...)에 따라서, 주어진 request가 route handler에 의해 handle될 지 말지를 결정한다. 이는 보통 authorization 으로 언급된다. ( authorization 구현에 쓰인다는 말인 것 같다.)

Authorization(authentication) 은 전통적인 Express 어플리케이션에서는 미들웨어로 주로 handle 되었다. 미들웨어는 인증에 좋은 선택이다. 토큰 validation과 request object에 프로퍼티를 붙이는 행위(req.user에 인증 정보를 담는 것)는, 특정 route의 context에 크게 연관이 없기 때문이다.

하지만 미들웨어는, dumb다.(멍청하단다.) 미들웨어는 next() function이 call 된 후에 어떤 handler가 실행될지를 전혀 모른다. 그러나 GuardsExecutionContext instacne 를 사용할 수 있고, 다음에 어떤 것이 실행될 지 정확히 알 수 있다. Guards 는 exception filter, pipe, interceptor 와 같은 맥락, 즉 request/response 의 cycle 내에, processing logic 을 정확한 곳에, 명확하게 두기 위해서 디자인 되었다. 이는 당신의 코드를 DRY 하고 declarative 하게 유지하는데 도움이 될 것이다.

Guards 는 모든 middleware의 다음에 실행되고, interceptor 나 pipe 이전에 실행된다.

Authorization Guard

언급했듯이, Authorization은 Guards의 훌륭한 use case이다. 왜냐하면 특정 routes 들은 충분한 permission 이 주어지고 난 후에만 가능해야 하기 때문이다. (해당 유저가 아닐 경우 해당 유저의 정보를 수정할 수 없어야 한다.)

우리는 이제부터 AuthGuard를 만들어 볼 것이다. 토큰을 추출한 후 확인할 것이고, 추출한 정보로부터 해당 request 가 실행될 수 있을 지 없을지 판단할 것이다.

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);
  }
}

실제 서비스를 위한 사례를 찾는다면, NestJS Authorization 공식 문서 를 찾아보자. 당연히 포스팅도 할 것이다.

모든 guard는 canActivate() 함수를 implement해야 한다. 이 함수는 현재의 request가 실행될 수 있는지 없는지를 나타내는 boolean을 리턴해야 한다. true라면 해당 request는 실행될 것이고, false라면 거절 할 것이다.

Execution Context

canActivate() 함수는 하나의 ExecutionContext 인스턴스 argument를 가진다. ExectuionContextArgumentHost로 부터 상속된다. Request 오브젝트를 참조하기 위해서, Exception Filter 편에서 사용했던 ArgumentHost에 정의된 helper method들과 같은 것을 사용하고 있다.

ExecutionContextArugmentHost를 extend 함으로써, 현재 실행되는 프로세스의 다양한 정보를 가져올 수 있는 새로운 helper method들을 가진다.

Role-based authentication

이제 특정 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;
  }
}

Binding Guards

Pipe 나 Exception Filter처럼, guard도 controller-scoped, method-scoped, global-scoped가 될 수 있다. @UseGuards() decorator 로 적용할 수 있다. Exception Filter와 마찬가지로 하나 혹은 여러 개의 콤마로 구분된 argument를 가진다.

@Controller('cats')
@UseGuards(RolesGuard) 
export class CatsController {}
// RoleGuard 대신 new RolesGuard() 를 사용할 수 있다.

이러면 CatsController 내의 모든 핸들러에게 guard 가 붙는다. 하나의 메소드에만 guard를 적용하고 싶다면 해당 메소드에 @UseGuards() 데코레이터를 붙여주면 된다.

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

global guard를 설정하려면, @useGlobalGuards() 메서드를 사용하면 된다.

주의
Hybrid Apps(HTTP Request와, micro service와의 통신 모두를 하는 어플리케이션) 내에서, useGlobalGuards()는 gateways 와 micro services 에게 guard를 설정해주지 않는다.

하지만 이런 방식은, 모든 모듈의 context의 바깥에서 벌어지기 때문에 의존성을 주입할 수 없다. 이 문제를 해결하기 위해서, module에다가 guard를 설정해주는 것이 좋다.

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가 작동하지만, 아직 smart 하지 못하다. 우리는 guard의 가장 큰 특징 - Execution Context 에서 얻을 수 있는 이득을 보고 있지 않다. 아직 roles 에 관해서 모르는 상태이고, 어떤 role이 handler에게 허용되는 지도 모른다.

CatsController에서, 어떤 routes 는 admin 만 사용가능해야 하고, 어떤 routes는 모두가 사용가능해야 한다. 어떡해야 flexible 하고 reusable 하게 role을 배정해줄 수 있을까?

여기서 custom metadata 가 등장한다. Nest는 SetMetadata() 데코레이터를 통해 route handler에 custom metadata 를 붙일 수 있게 해준다. 이 metadata는 smart guard가 결정을 내리기 위해 필요한 role 의 data를 보충해준다.

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

이것은 작동하기는 하지만, routes 에 직접 SetMetadata()를 사용하는 것은 좋지 않다. 대신, 직접 decorator를 만드는 것이 좋다.

//roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

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

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

이 방식은 좀 더 읽기 쉽고 깔끔하며, 강하게 typed 되었다.

Putting it all together

이제 다시 RolesGuard로 가서 묶어보도록 하자. 현재는 모든 case 에 대해 true를 return 하도록 되어있고, 따라서 모든 request가 진행될 것이다. 우리는 현재 user에게 배정된 role을, 현재 route가 실제로 요구하는 role과 비교함으로써, RolesGuard가 return 하는 값을 알맞게 설정해줄 것이다. route의 role(s)에 접근하기 위해서, 우리는 Reflector라는 helper class를 사용한다.

//roles.guard.ts
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);
  }
}

HINT
node.js 생태계에서, 인증된 user를 request object에 붙이는 것이 통상적이다. 그러므로 위의 샘플 코드에서, request.user는 user instance와 배역을 포함하고 있다고 가정한다.

만약 user가 allow 되지 않는다면, Nest 는 자동으로

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

403 응답을 리턴해준다.

guard가 false를 리턴할 때, framework가 ForbiddenException을 throw 한다는 것을 알아야 한다. 만약 다른 error response 를 return하고 싶다면, 특정 exception을 throw 하도록 해야한다. 예를 들어

throw new UnauthorizedException();

guard에 thrown 된 모든 exception은 exceptions layer에서 handle 될 수 있다.

후기

이번 공식 문서는 Authorization 으로 들어가기 전에 필수로 알아둬야 할 Guard 에 대해서 알아보았다. 다음 포스팅은 공식문서의 순서에 따르면 Interceptor가 되어야겠지만, 우선은 AuthorizationAuthentication 부분부터 살펴보겠다. 내가 구현할 부분이기 때문이다. 많은 도움이 되셨으면 좋겠다.

1개의 댓글

comment-user-thumbnail
2021년 10월 29일

정리가 잘되있어서 이해하기가 편했습니다. 감사합니다

답글 달기