Logo

가드(Guard)로 NestJS 앱 안전하게 지키기

이번 글에서는 가드(Guard)를 활용하여 NestJS 앱을 위험한 요청으로 부터 효과적으로 보호하는 방법에 대해서 배워보도록 하겠습니다.

가드(Guard)란?

NestJS에서 가드(guard)란 애플리케이션의 최전선에서 말그대로 애플리케이션을 보호하는 역할을 담당하는데요. NestJS로 들어오는 요청은 컨트롤러(controller) 단에 도달하기 전에 반드시 가드를 거쳐가도록 되어 있습니다.

가드를 이용하면 컨트롤러가 요청을 처리하기 전에 안전하지 않은 요청을 효과적으로 차단할 수 있습니다. 따라서 애플리케이션 보안을 위해서 필수적인 사용자 인증이나 접근 제어를 구현하는데 안성맞춤이지요.

Express.js를 써보셨다면 미들웨어(middleware)랑 유사한 역할을 수행한다고 보시면 되겠습니다.

실습 프로젝트 구성

먼저 간단한 실습을 위해서 NestJS 프로젝트가 하나 필요할 것 같은데요. 터미널에서 NestJS CLI 도구의 nest new 명령어를 실행하여 새로운 프로젝트를 구성하도록 하겠습니다.

$ nest new our-nestjs
⚡  We will scaffold your app in a few seconds..

? Which package manager would you ❤️  to use? (Use arrow keys)npm
  yarn
  pnpm

NestJS CLI를 설치하고 NestJS 프로젝트를 구성하는 기본적인 방법은 관련 포스팅을 참고 바랍니다.

가드로 요청 전달

NestJS에서는 가드를 만들기 위해서는 @nestjs/common 모듈에서 제공하는 CanActivate라는 인터페이스를 구현하는 클래스를 생성해야합니다. 그리고 canActivate() 메서드를 안에 가드의 로직을 작성할 수 있는데요.

아주 간단한 예제로 canActivate() 메서드가 항상 true를 반환하도록 구현해보겠습니다.

auth.guard.ts
import { Injectable, CanActivate } from '@nestjs/common';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate() {
    console.log('인증이 성공하였습니다.');
    return true;
  }
}

NestJS는 canActivate() 함수가 true 또는 Promise<true>를 반환했을 때만 해당 요청을 컨트롤러로 전달합니다. 반대로 canActivate() 함수가 false 또는 Promise<false>를 반환할 경우에는 해당 요청이 컨트롤러로 넘어가는 것을 차단합니다.

위 예제에서는 무조건 true를 반환하니 요청이 항상 컨트롤러로 넘어가겠죠?

컨트롤러에 가드 적용

가드는 @nestjs/common 모듈의 @UseGuards데코레이터를 통해서 컨트롤러에 적용해줄 수 있습니다. @UseGuards 데코레이터를 클래스에 붙여주면 해당 컨트롤러 내에 있는 모든 메서드에 적용이 되고, @UseGuards 데코레이터를 특정 메서드를 상대로도 사용할 수도 있습니다.

그럼 위에서 작성한 가드를 AppController에 적용해볼까요?

app.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';import { AppService } from './app.service';
import { AuthGuard } from './auth.guard';
@UseGuards(AuthGuard)@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

이제 터미널에서 curl 명령어로 http://localhost:3000을 찔러보면 Hello World!가 응답되는 것을 확인할 수 있으실 겁니다.

터미널
$ curl http://localhost:3000
Hello World!

가드에서 요청을 차단하지 않았기 때문에 컨트롤러가 요청을 처리해주고 있는 건데요. 로그를 보시면 가드에서 출력한 인증이 성공하였습니다. 메세지가 확인될 것입니다.

로그
[Nest] 39209  - 07/12/2023, 9:45:29 p.m.     LOG [NestApplication] Nest application successfully started +0ms
인증이 성공하였습니다.

터미널 상에서 간편하게 사용할 수 있는 HTTP 클라이언트인 curl 커맨드에 대해서는 관련 포스팅을 참고 바랍니다.

가드로 요청 차단

이번에는 반대로 가드의 canActivate() 메서드가 항상 false를 반환하도록 수정해볼까요?

auth.guard.ts
import { Injectable, CanActivate } from '@nestjs/common';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate() {
    console.log('인증이 실패하였습니다.');    return false;  }
}

이제 NestJS 앱을 재구동 후에 서버에 다시 요청을 보내보면 이번에는 403 Forbidden 오류가 응답될텐데요.

터미널
$ curl http://localhost:3000
{"message":"Forbidden resource","error":"Forbidden","statusCode":403}

이 것은 가드가 요청을 차단했기 때문에 요청이 컨트롤러에 도달하지 못했다는 뜻입니다. 로그를 보시면 가드에서 출력한 인증이 실패하였습니다. 메세지가 확인될 것입니다.

로그
[Nest] 39542  - 07/12/2023, 9:53:38 p.m.     LOG [NestApplication] Nest application successfully started +1ms
인증이 실패하였습니다.

가드로 인증 구현

가드의 canActivate() 메서드에는 인자로 ExecutionContext가 넘어오는데요. 이 것을 이용하여 요청 경로, 요청 헤더, 요청 쿼리, 요청 바디 등을 읽을 수 있습니다. 이를 통해 가드는 요청이 안전한지 검사하여 컨트롤러로 전달할지 차단할지 판단할 수 있습니다.

그럼 가드를 활용해서 API 서버에서 자주 볼 수 있는 Bearer 토큰 기반 인증을 살짝 흉내내볼까요?

auth.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  BadRequestException,
} from "@nestjs/common";

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();
    const authorization = request.headers.authorization;
    console.log(authorization);
    if (authorization) {
      const [scheme, token] = authorization.split(" ");
      console.log([scheme, token]);
      return scheme.toLowerCase() === "bearer" && token === "1234";
    }
    throw new BadRequestException();
  }
}

최대한 간단한 예제를 위해서 Bearer 토큰이 1234일 때만 요청이 가드를 통과하도록 구현해보았습니다. (말도 안되죠? 😅) 그리고 Authorization 헤더로 넘어온 값이 Bearer 토큰의 형식에 맞지 않을 때는 BadRequestException 예외를 던지도록 하였는데요. 이를 통해 클라이언트는 인증이 실패한 것과 입력값이 틀린 것을 구분할 수 있습니다.

실제 애플리케이션에션에서는 사용하시는 인증 매커니즘에 따라 다양한 방식으로 구현이 될 것입니다. 예를 들어, Bearer 토큰으로 DB를 조회할 수도 있고, OAuth를 사용하고 있다면 원격 전송할 수도 있고, JWT 토큰이라면 디코딩한 후 서명을 검증해야 할 것입니다.

Bearer 토큰에 대해서 더 궁금하신 분들은 관련 포스팅을 참고 바랍니다.

인증 가드 테스트

이제 터미널에서 실제로 NestJS 앱을 호출하면서 간단한 테스트를 진행해볼까요?

우선 Authorization 헤더에 Bearer 1111 넘겨서 호출해보니 403 Forbidden이 응답되네요. 가드의 canActivate() 메서드에서 false를 반환하여 컨트롤러에 요청이 도달하지 못했다는 뜻입니다.

터미널
$ curl -H "Authorization: Bearer 1111" http://localhost:3000
{"message":"Forbidden resource","error":"Forbidden","statusCode":403}

이번에는 Authorization 헤더에 Bearer 1234 넘겨서 호출해보니 정상적으로 응답이 옵니다. 가드의 canActivate() 메서드에서 true를 반환하여 컨트롤러가 요청을 처리해주었다는 뜻입니다.

터미널
$ curl -H "Authorization: Bearer 1234" http://localhost:3000
Hello World!

만약에 아예 Authorization 헤더가 없이 호출하면 어떻게 될까요? 이 때는 400 Bad Request 응답이 되는데요. 가드의 canActivate() 메서드에서 BadRequestException 예외가 발생했기 때문입니다.

터미널
$ curl http://localhost:3000
{"message":"Bad Request","statusCode":400}

전체 코드

실습 프로젝트의 코드는 아래에서 직접 확인하고 실행해볼 수 있습니다.

마치면서

지금까지 간단한 실습을 통해서 NestJS 앱을 보호하기 위해서 가드를 어떻게 사용하는지 살펴보았습니다. NestJS로 개발하고 계신 애플리케이션의 보안을 강화하시는데 본 글이 도움이 되었으면 좋겠습니다.

NestJS에 관련된 다른 포스팅은 관련 태그를 참고 바라겠습니다.