NESTJS를 배워보자(9) - Guards

yoon·2023년 7월 24일
0

NESTJS를 배워보자

목록 보기
9/21
post-thumbnail

Guards

nest의 공식문서를 토대로 작성합니다.

가드는 CanActivate 인터페이스를 구현하는 클래스입니다.

가드는 단일 책임이 있습니다. 이들은 런타임에 존재하는 특정 조건(권한, 역할, ACLs 등)에 따라 특정 요청이 route handler에 의해 처리될지 여부를 결정합니다. 이를 흔히 authorization이라고 합니다. authorization은 일반적으로 기존 Express 애플리케이션의 미들웨어가 처리해 왔습니다. 토큰 유효성 검사나 요청 객체에 프로퍼티를 첨부하는 것과 같은 작업은 특정 경로 컨텍스트(및 메타데이터)와 밀접하게 연결되어 있지 않기 때문에 미들웨어는 인증에 적합한 선택입니다.

하지만 미들웨어는 본질적으로 멍청합니다. next() 함수를 호출한 후 어떤 핸들러가 실행될지 모릅니다. 반면에 가드는 ExecutionContext 인스턴스에 접근할 수 있으므로 다음에 무엇이 실행될지 정확히 알 수 있습니다. 예외 필터, 파이프, 인터셉터와 마찬가지로 요청/응답 주기의 정확한 지점에 처리 로직을 삽입할 수 있도록 설계되었으며 이를 선언적으로 수행할 수 있습니다. 따라서 코드를 간결하고 선언적으로 유지하는 데 도움이 됩니다.

HINT
가드는 모든 미들웨어가 실행된 후, 인터셉터나 파이프가 실행되기 전에 실행됨.

Authorization guard

앞서 언급했듯이 호출자(일반적으로 인증된 특정 사용자)에게 충분한 권한이 있는 경우에만 특정 경로를 사용할 수 있어야 하므로 authorization은 가드의 훌륭한 사용 사례입니다. 지금 작성할 AuthGaurd는 인증된 사용자를 가정합니다(따라서 요청 헤더에 토큰이 첨부됩니다). 토큰을 추출하여 유효성을 검사하고 추출된 정보를 사용하여 요청을 진행할 수 있는지 여부를 결정합니다.

# auth.guard.ts

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

직접 작성해줄 필요는 없습니다. 그냥 이런 형식으로 사용한다 정도로 알고 계시면 됩니다!

HINT
애플리케이션에서 authentication 메커니즘을 구현하는 방법에 대한 실제 예제를 보려면 여기로
보다 정교한 authorization 예제를 보려면 여기로

당연히 나중에 따로 다룰 것입니다.😀

validateRequest() 함수 내부의 로직은 필요에 따라 간단하거나 복잡할 수 있습니다. 이 예제의 요점은 가드가 요청/응답 주기에 어떻게 맞는지 보여주는 것입니다.

모든 가드는 canActivate() 함수를 구현해야 합니다. 이 함수는 현재 요청이 허용되는지 여부를 나타내는 boolean을 반환해야 합니다. 이 함수는 응답을 동기 또는 비동기(Promise 또는 Observable를 통해)로 반환할 수 있습니다. Nest는 반환값을 사용하여 다음 작업을 제어합니다.

  • true를 반환하면 요청이 처리됨.
  • false를 반환하면 요청을 거부함.

Execution context

canActivate() 함수는 단일 인자 ExecutionContext 인스턴스를 받습니다. ExecutionContextArgumentsHost를 상속합니다. 앞서 예외 필터 챕터에서 ArgumentsHost를 살펴봤습니다. 위의 샘플에서는 이전에 사용한 것과 동일한 헬퍼 메소드를 ArgumentsHost에 정의하여 요청 객체에 대한 참조를 가져오고 있습니다.

ArgumentsHost를 확장함으로써 ExecutionContext는 현재 실행 프로세서에 대한 추가 세부 정보를 제공하는 몇 가지 새로운 헬퍼 메소드도 추가합니다.이러한 세부 정보는 광범위한 컨트롤러, 메소드 및 실행 컨텍스트에서 작동할 수 있는 보다 일반적인 가드를 구축하는 데 유용할 수 있습니다. ExecutionContext에 대해 자세히 보려면 여기로.

Role-based authentication

특정 역할을 가진 사용자에게만 액세스를 허용하는 보다 기능적인 가드를 구축해 보겠습니다. 기본 가드 템플릿으로 시작하여 다음 섹션에서 이를 기반으로 구축할 것입니다. 지금은 모든 요청을 허용합니다.

# roles.guard.ts

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

저는 CLI를 통해 생성했습니다.

$ nest g gu roles

Binding guards

파이프 및 예외 필터와 마찬가지로 가드는 컨트롤러, 메소드, 전역 범위로 지정할 수 있습니다. 아래에서는 @UseGuards() 데코레이터를 사용하여 컨트롤러 범위 가드를 설정했습니다. 이 데코레이터는 단일 인수를 받거나 쉼표로 구분된 인수의 목록을 받습니다. 이를 통해 하나의 선언으로 적절한 가드 집합을 쉽게 적용할 수 있습니다.

@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

HINT
@UseGuards() 데코레이터는 @nestjs/common 패키지에서 import.

위에서 인스턴스 대신 RolesGuard 클래스를 전달하여 인스턴스화에 대한 책임을 프레임워크에 맡기고 의존성 주입을 활성화했습니다. 파이프 및 예외 필터와 마찬가지로 in-place 인스턴스를 전달할 수도 있습니다.

@Controller('cats')
@UseGuards(new RolesGuard())
export class CatsController {}

위 구조는 이 컨트롤러가 선언한 모든 핸들러에 가드를 적용합니다. 가드가 단일 메소드에만 적용되도록 하려면 메소드 수준에서 @UseGuards() 데코레이터를 적용하면 됩니다.

전역 가드를 설정하려면 아래처럼 Nest 애플리케이션 인스턴스의 useGlobalGuard() 메소드를 사용합니다.

# main.ts

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

NOTICE
하이브리드 앱의 경우 useGlobalGuards() 메소드는 기본적으로 게이트웨이 및 마이크로 서비스에 대한 가드를 설정하지 않습니다(이 동작을 변경하는 방법에 대한 자세한 내용은 여기로).
표준 마이크로 서비스 앱의 경우 useGlobalGuards()는 가드를 전역으로 마운트합니다.

전역 가드는 모든 컨트롤러와 모든 route handler 즉 전체 애플리케이션에서 사용됩니다. 종속성 주입과 관련하여 모듈 외부에서 등록된 전역 가드는 모듈의 컨텍스트 외부에서 수행되므로 종속성을 주입할 수 없습니다. 이 문제를 해결하기 위해 다음을 사용하여 모든 모듈에서 직접 가드를 설정할 수 있습니다.

# app.module.ts

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: RolesGuard,
    },
  ],
})
export class AppModule {}

HINT
이 방식을 사용하여 가드에 대한 종속성 주입을 할 때 이 구조가 사용되는 모듈에 관계없이 가드는 실제로 전역임. 이 작업은 가드가 정의된 모듈을 선택하면 됨. 또한 useClass만이 사용자 지정 provider 등록을 처리하는 유일한 방법이 아님. 자세히 보려면 여기로.

Setting roles per handler

RolesGuard가 작동하고 있지만 아직 가장 중요한 가드의 기능인 실행 컨텍스트를 활용하지 못하고 있습니다. 각 핸들러에 허용되는 역할에 대해 알지 못합니다. 예를 들어 CatsController는 경로마다 다른 권한 체계를 가질 수 있습니다. 일부는 관리자 사용자만 사용할 수 있고 다른 일부는 모든 사용자에게 개방되어 있을 수 있습니다. 어떻게 하면 유연하고 재사용 가능한 방식으로 역할을 경로에 부여할 수 있을까요?

바로 여기에 사용자 지정 메타데이터가 등장합니다. Nest는 @SetMetadata() 데코레이터를 통해 route handler에 사용자 지정 메타데이터를 첨부할 수 있는 기능을 제공합니다. 이 메타데이터는 스마트 가드가 의사 결정을 내리는 데 필요한 누락된 역할 데이터를 제공합니다. SetMetadata() 사용법을 살펴보겠습니다.

# cats.controller.ts

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

위 구조에서는 roles 메타데이터를 create() 메소드에 첨부했습니다. 이렇게 해도 작동하지만 @SetMetadata()를 경로에 직접 사용하는 것은 좋은 방법이 아닙니다. 대신 아래와 같이 자체 데코레이터를 생성합니다.

# roles.decorator.ts

import { SetMetadata } from '@nestjs/common';

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

이 방식은 훨씬 더 깔끔하고 가독성 높습니다. 이제 사용자 정의 @Roles() 데코레이터가 생겼으므로 이를 사용하여 create() 메소드에 데코레이션을 사용할 수 있습니다.

# cats.controller.ts

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

Putting it all together

이제 이것을 RolesGuard와 연결합니다. 현재는 모든 경우에 참을 반환하여 모든 요청이 진행되도록 허용합니다. 현재 사용자에게 할당된 역할과 현재 처리 중인 경로에 필요한 실제 역할을 비교하여 반환값을 조건부로 만들 것입니다. 경로의 역할에 액세스하기 위해 프레임워크에서 기본 제공되고 @nestjs/core 패키지에서 노출되는 Reflector 헬퍼 클래스를 사용할 것입니다.

# 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에서는 권한이 부여된 사용자를 요청 객체에 첨부하는 것이 일반적임. 따라서 위의 샘플 코드에서는 request.user에 사용자 인스턴스와 허용된 역할이 포함되어 있다고 가정. 앱에서는 사용자 지정 인증 가드에서 이러한 연결을 만듦.

WARNING
matchRoles() 함수 내부의 로직은 필요에 따라 단순하거나 복잡하게 만들 수 있음. 이 예제의 요점은 가드가 요청/응답 주기에 어떻게 작동하는지 보여주는 것임.

아마 위 그대로 작성하셨다면 에러가 발생할 것입니다. 첫 번째로 matchRoles()를 직접 작성하셔야 합니다.
두 번째로는 matchRoles() 작성한다 해도 여기서는 request.user에 사용자의 roles가 포함되어 있다고 가정해서 저희는 아무것도 넘기지 않았기에 user.rolesundefined로 나옵니다.

즉 그냥 아 이렇게 사용하는구나 정도로 알아두시면 됩니다. 나중에 더 자세하게 다루니까요.

저는 그냥 return에 true, false 각각 넣어보고 cats.cotroller.ts에서 @Roles() 데코레이터에 이것저것 넣어보고 로직 작성해서 결과 확인해봤습니다.

제 코드입니다. 😎
https://github.com/cxzaqq/cxzaqq-velog/tree/2.8-guards

상황에 맞는 방식으로 Reflector를 활용하는 방법에 대한 자세한 내용은 여기로.

만약 권한이 부족한 사용자가 엔드포인트를 요청하면 Nest는 자동으로 다음과 같은 응답을 반환합니다.

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

백그라운드에서 가드가 false를 반환하면 프레임워크는 ForbiddenException을 던집니다. 다른 오류 응답을 반환하려면 고유한 예외를 던져야 합니다. 예:

throw new UnauthorizedException();

가드가 던진 모든 예외는 예외 계층에서 처리됩니다.

HINT
authorization을 구현하는 방법에 대한 실제 사례를 찾는다면 여기로

고생하셨습니다!
다음 글에서 만나요~~😀


저도 아직 배우는 단계입니다. 지적 감사히 받겠습니다. 함께 열심히 공부해요!!

profile
백엔드 개발자 지망생

1개의 댓글

comment-user-thumbnail
2023년 7월 24일

글 잘 봤습니다.

답글 달기