NestJS에서 특정 유저만 접근 가능한 API 만들기

정비호·2024년 5월 3일
2

사이드 프로젝트

목록 보기
3/5

메인으로 진행하던 프로젝트가 막바지일때 이야기이다.

발단

우리 서비스의 기획 상 신고 기능이 있어야 했다.
그리고 이에 연동되는 유저에게 정지를 주는 방법은 아직 기획이 안된 상태였는데 여차저차 얘기를 해서 유저를 밴 시킬 수 있고 Admin 권한을 가진 유저만 접근 가능한 API를 별도로 만들기로 했다.

NestJS Guards

참조 링크:

Guards에 대해서 알아보기 전에 밑의 사진을 먼저 보자.
NestJS Request lifecycle

해당 사진은 NestJS의 Request lifecycle으로, 클라이언트로부터 요청이 들어오고 응답으로 나갈때 까지의 생명주기를 나타낸 사진이다.

더 자세한 설명은 별도의 포스트로 작성하도록 하고, 여기선 Middleware, Guards의 아주아주 간단한 특징, 일반적인 사용처와 왜 Guards를 사용할 것인지만 설명하겠다.

Middleware

사진을 보면 알겠지만 요청이 들어 왔을 때 가장 먼저 실행되는 것은 Middleware이다.

보통은

  • 쿠키 파싱
    - 쿠키를 Middleware 에서 파싱해주면 컨트롤러의 라우트 핸들러에서 매번 쿠키 파싱하는 번거로움과 중복 코드를 없앨 수 있음.
  • 인증/인가
    - NestJS에선 Guards를 사용하는 것을 추천
    - 이유는 밑에서
  • Logger

Guards

Middlerware 다음으로 실행된다.
이름부터 대놓고 인증/인가를 처리하는 놈이라고 써있다.
다른건 제치고 Middleware 대신 Guards를 통해 인증 / 인가 처리를 해야 하는 이유를 살펴보면

  • next() 함수를 호출하고 어떤 핸들러가 실행될지 모름.
  • GuardsExecutionContext(실행 컨텍스트) 인스턴스에 접근이 가능함.
    - 이는 다음 실행될 내용을 정확히 알 수 있다는 것.

잘 이해가 안될 수도 있지만 일단 Guards를 통해 Admin access control 로직을 구현을 해보자.

Roles Guards 구현 로직

목차만 보면
"잉? Admin만 접근 가능한 API 만든다며?"
할 수 있는데 단순 Admin을 위한 가드보단 우리가 직접 라우트 핸들러에 데코레이터로 매핑해서 지정해준 Roles에 유저의 role이 속해 있는지를 체크하는게 더 좋을 것 같다.

아무래도 나중에 유저의 권한에 대해서 더 세부적인 기획이 생기면 단순 Admin, User 외에도 더 많은 role이 생길텐데 그때그때마다 Guards를 새로 구현해주는 것은 너무 비효율적 아니겠는가?
물론 NestJS 공식문서에서 예제로 있는 방법이기도 하다.

SetMetadata

일단 해당 API로 요청을 보낸 유저의 Role이 우리가 접근 가능하도록 지정한 Roles에 해당하는지를 알아야 한다.
우리 서비스는 이미 JWT를 이용한 Guard, Passport로 사용자 인증/인가를 해주고 request 객체에 Jwt에서 파싱한 user 객체를 프로퍼티로 붙여주고 있다(토큰 payload에는 user의 id만 담겨있음).
그렇다면 Guards에서 요청을 보낸 유저의 정보는 이미 request 객체를 통해 가져올 수 있으니 우리가 Controller의 각각의 라우트 핸들러에서, 혹은 Controller 레벨에서 지정해준 Roles만 가져오면 된다.
이때 custom metadata가 중요한데 Reflector.createDecorator 혹은 내장된 SetMetadata 데코레이터를 이용하면 된다.
나는 SetMetadata를 이용하겠다.

import { SetMetadata } from '@nestjs/common';
import { ROLES_TOKEN } from '@src/admins/constants/roles.token';
import { UserRole } from '@src/users/constants/user-role.enum';

export const Roles = (...roles: UserRole[]): ClassDecorator & MethodDecorator =>
  SetMetadata(ROLES_TOKEN, roles);

첫번째 인수로 내가 만들어놓은 Symbol인 ROLES_TOKEN을 키값으로 주고 해당 키값으로 가져올 metadataValue를 두번째 인수로 넣어준다.
그리고 어떤 역할을 위한 데코레이터인지 명확하게 보이기 위해 Roles라는 이름으로 새로 만들었다.
그리고

@Roles(UserRole.ADMIN)
export class AdminController {
}

이렇게 컨트롤러에 데코레이터를 달아주면 된다.

내가 컨트롤러에 달아준 이유는 어차피 Admin만 접근 가능한 엔드포인트밖에 없기 때문

이제 본격적으로 Guards 로직을 작성해보자.

RoleClassGuard

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RoleClassGuard implements CanActivate {
  constructor(
    private readonly reflector: Reflector,
    private readonly userService: UserService,
  ) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();

    const { role } = await this.userService.findOneByOrNotFound({
      where: { id: request.user.id },
    });

    const isRoleValid = this.getMetadata(context).includes(role);

    if (!isRoleValid) {
      throw new ForbiddenException('넌 못지나간다.');
    }

    return isRoleValid;
  }

  private getMetadata(context: ExecutionContext) {
    return this.reflector.get<UserRole[]>(ROLES_TOKEN, context.getClass());
  }
}

실제 프로젝트에 적용된 코드에서 아주 조금만 바꿨다.
일단 실행 순서는

  1. 실행 컨텍스트에 접근해서 request 객체를 가져옴.
    1. 이때 이미 Jwt 인증 로직을 처리했기 때문에 user 프로퍼티가 붙어 있음
  2. userService에서 유저를 조회하는 메서드를 호출해서 요청을 보낸 user의 role을 가져옴.
    1. 이게 조금 아쉬운데 그냥 JWTpayload에 role도 추가해줬으면 이 과정을 생략해도 됨.
  3. private 메서드인 getMetadata 메서드에 인수로 context를 넘겨서 호출함.
    1. getMetadata 메서드에서는 주입받은 reflector 클래스의 get 메서드를 호출함. 이 때 첫번째 인수로는 우리가 meatadataValue의 키값으로 등록했던 ROLES_TOKEN을 넣어주고 두번째 인수로는 지금 실행 컨텍스트에 있는 class(Controller)를 넘김.
    2. 그럼 해당 Controller 레벨에 해당 키값으로 등록된 metadata를 찾아서 return.
  4. 그렇게 가져온 metadata(Roles)에 요청을 보낸 user의 role이 포함되어 있는지 비교.
  5. 만약 없다면 false를 반환해서 에러 처리.
    1. 원래대로라면 false를 return하는 로직이 정석.
    2. 코드를 좀 수정해서 제대로 보이진 않지만 나는 여기서 Custom한 error를 던지고 싶었기 때문에 Custom Exception과 별도의 error code를 만들어서 false를 반환하지 않고 직접 에러를 던졌다.
  6. 권한이 있다면 true를 반환해서 통과!

마무리

controller, handler 레벨 별로 Guards를 만들지 않고 RolesGuard 하나만 만들어서 둘 다 사용 가능하도록 코드를 짤 수는 있다.
그냥 최대한 다른거 몰라도 조금이라도 쉽게 이해할 수 있는 방향으로 글을 작성해봤다.

끝!

profile
잘하고 싶은 개발자

0개의 댓글

관련 채용 정보