04-08 03:45
Notice
Recent Posts
관리 메뉴

독산구너

NestJS Guard, JWT 기반 권한 레벨 관리 본문

NestJS

NestJS Guard, JWT 기반 권한 레벨 관리

독산구너 2024. 11. 24. 17:20

글의 목적

인턴 과정에서, NestJS를 사용하여 사용자 레벨을 나누고 레벨별로 API 호출 권한을 설정했습니다. 이때 사용한 Guard, jwt, 데코레이터 생성 방식에 대해 써보고자 합니다.

 

요구사항

  1. 워크스페이스 내 멤버는 4개의 레벨로 나눠집니다. 레벨 1이 가장 높으며, 가장 높은 권한을 가지고 있습니다. 레벨 숫자가 커질수록 적은 권한을 가집니다.
  2. Guard를 통해 API를 호출한 멤버가 해당 권한을 가지고 있는지 확인해야 합니다. 이때 DB를 조회하지 않습니다 (사용자가 api 호출 권한이 있는지 guard에서 db를 조회해 확인할 수는 있으나, controller의 메서드가 호출되기도 전에 db에 접근하는 것이 리소스 낭비이고 알맞지 않다고 생각했습니다)

 

구현 방법

  1. JWT 페이로드에 해당 멤버의 level을 추가해 발급합니다.
  2. JWT Guard에서 토큰을 검증하고 level을 추출해 Request Body에 주입합니다.
  3. @MinimumLevel(type of MemberLevel) 데코레이터를 설정해 컨트롤러 메서드마다 필요한 최소 레벨을 설정합니다.
  4. RolesGuard에서 MinimumLevel과 body의 level을 비교해 해당 메서드 호출 권한을 가졌는지 확인합니다.

 

1. JWT 페이로드에 해당 멤버의 level을 추가해 발급합니다.

해당 어플리케이션은 하나의 유저가 여러 워크스페이스에 가입이 가능합니다. 워크스페이스별로 권한이 다르게 설정될 수 있기 때문에, 워크스페이스를 전환할 때마다 Access Token을 재발급하도록 했습니다.

 

먼저 MemberLevel 이넘을 생성합니다.

export enum MemberLevel {
  UNASSIGNED = 100,
  LEVEL_1 = 1,
  LEVEL_2 = 2,
  LEVEL_3 = 3,
  LEVEL_4 = 4
}

숫자가 작을수록 권한이 커지게 됩니다.

 

그리고 다음 메서드를 통해 JWT에 접근하고자 하는 WorkspaceId, Level을 주입합니다.

DB 조회를 통해 해당 워크스페이스의 멤버 권한을 찾고, 이를 주입합니다.

async getAccessPayload(memberId: string, workspaceId: string) {
    if (workspaceId === undefined) {
      return {
        memberId: memberId,
        workspaceId: DEFAULT_WORKSPACE_ID, level:
        MemberLevel.UNASSIGNED
      };
    } else {
      const dataSource = await this.dataBaseService.getTenantDataSource(workspaceId);
      const memberWorkspaceEntity = await dataSource.manager.findOne(MemberEntity,
        { where: { uuid: memberId } }
      );
      if (!memberWorkspaceEntity) {
        throw new ServiceException(ExceptionCode.NOT_AUTHORIZED);
      }

      return {
        memberId: memberId,
        workspaceId: workspaceId,
        level: memberWorkspaceEntity.accessLevel
      };
    }
  }

jwt payload는 다음과 같이 설정됩니다.

{
  "memberId": "member-90cd9162-8ed2-4845-b477-1d5754beddbb",
  "workspaceId": "workspace-7540925c-b8c2-4c38-8c5c-f6c5673ae072",
  "level": 1,
  "iat": 1731648985,
  "exp": 1732253785
}

 

2. AccessToken을 검증하는 JwtGuard를 생성합니다. 여기서 level을 추출해 body에 주입합니다.

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  handleRequest(err, user, info, context: ExecutionContext) {
    if (err || !user) {
      // 인증 실패 시 UnauthorizedException 예외를 던집니다.
      throw err || new ServiceException(ExceptionCode.TOKEN_INVALID);
    }

    const request = context.switchToHttp().getRequest();

    // validate 메서드에서 반환된 user 객체에서 memberId를 추출해 request.body에 추가
    if (user && user.memberId) {
      request.body.memberId = user.memberId;
      request.body.workspaceId = user.workspaceId;
      request.body.level = user.level;
    }

    return user;
  }
}

 

 

3. @MinimumLevel(type of MemberLevel) 데코레이터를 설정해 컨트롤러 메서드마다 필요한 최소 레벨을 설정합니다.

데코레이터를 생성합니다.

export const MinimumLevel = (role: MemberLevel) => SetMetadata('authLevels', role);

이 데코레이터는 MemberLevel을 받아 해당 메서드의 메타데이터 'authLevels'를 설정합니다.

 

컨트롤러 메서드에서는 다음과 같이 사용합니다.

  @ApiOperation({ summary: '프로젝트 삭제' })
  @MinimumLevel(MemberLevel.LEVEL_1)
  @UseGuards(JwtAuthGuard, RolesGuard)
  @Post('delete')
  delete(@Body() body: DeleteProjectResponseDto) {
    return this.projectService.delete(tenantQueryRunnerManager, projectCollection, body.projectId);
  }

프로젝트 삭제는 권한이 가장 강력한 MemberLevel.LEVEL_1 이 설정되어야 하겠습니다.

 

 

4. RolesGuard에서 MinimumLevel과 body의 level을 비교해 해당 메서드 호출 권한을 가졌는지 확인합니다.

RolesGuard를 하나 더 생성해, 해당 메서드의 메타데이터 'authLevels'와 requestbody에 주입된 level을 비교합니다.

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {
  }

  canActivate(context: ExecutionContext) {
    const requiredRole = this.reflector.getAllAndOverride<MemberLevel>('authLevels', [
      context.getHandler(),
      context.getClass()
    ]);
    if (!requiredRole) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const memberLevel = MemberLevel[request.body.level as keyof typeof MemberLevel];

    // 사용자의 레벨이 최소 레벨 이하라면 접근 허용
    if (memberLevel > requiredRole) {
      throw new ServiceException(ExceptionCode.NOT_AUTHORIZED);
    }

    return true;
  }
}

 

만약 레벨 1 권한을 가진 멤버라면, @MinimumLevel 에서 1, 2, 3, 4,. ... 등 1 이상으로 설정될 시 모두 호출이 가능합니다.

레벨 3 권한을 가진 멤버라면, @MinimumLevel 에서 3이상으로 설정된 api만 호출 가능하고, 1, 2로 설정된 메서드의 경우 Unauthorized 예외를 던지게 됩니다.

 

 

이 방법을 통해서

  1. Jwt에 level을 주입해 level(권한)이 외부에서 수정되어도 Guard를 통해 유효성을 검증할 수 있게 되었습니다.
  2. 모든 요청에 대해 db조회 없이 해당 멤버의 권한레벨을 확인할 수 있게 되었습니다.
  3. service계층에서 멤버권한을 확인하는 절차를 분리해, 비지니스 로직만을 수행하도록 했습니다.
  4. 데코레이터를 사용해, 간단히 메타데이터를 설정하고 권한검증을 추가할 수 있었습니다.

 

단점은?

  1.  jwt 페이로드의 크기가 커져 네트워크 통신에 영향이 있을 순 있지만, 단순히 숫자타입의 level을 추가한 것이기 때문에 큰 문제는 없습니다.
  2. 실시간 권한변경이 어렵습니다. 권한이 변경되면 다시 refresh token 과정을 거쳐야 하며, 이는 클라이언트에서 요청해야 합니다.
  3. Level 권한이 노출됩니다. 토큰에 level이 포함되기 때문에, 해당 멤버가 어떤 워크스페이스에서 어떤 권한을 가지고 있는지 쉽게 노출할 수 있습니다.
  4. 권한이 단순히 level별로 나뉘어 숫자가 낮을수록 권한이 높다는 규칙에서 벗어나 세분화되면, 추가적인 설계가 필요해집니다.