[Nest.js] jwt를 사용한 @UseGuard 구현, NestJs Guard 란?

resilient

·

2022. 6. 15. 17:39

728x90
반응형

NestJs를 사용한 프로젝트 내용을 간단하게 정리해보면 GCP IAP를 사용해서 회사 내의 Admin Application들의 접근하는 계정들을 관리하고, 각각 Admin Application 내에서의 권한들을 수정 및 추가, 그리고 모든 변경들을 로깅을 할 수 있는 Application(Admin의 Admin?)을 개발하고 있습니다.

 

여기 프로젝트에서 가장 중요한 부분이라고 할 수 있는 '권한'이라는 단어가 나오는데요. NestJs에서 권한들을 어떻게 받아와서 권한에 따라 요청을 보냈을 때 응답 값을 각각 다르게 보내줄 수 있는지에 대한 방법을 이번 시간에 알아보려고 합니다.

 

0. NestJS에서의 Guard

NestJS 공식문서를 참고해보면 Guard란 특정 상황들(permissions, roles, ACLs...)에 따라서, 주어진 request가 route handler에 의해 handling 될지 말지를 결정합니다. 일반적으로는 authorization 구현에 많이 쓰이죠. 예를 들어 권한에 따라 접근할 수 있는 카테고리가 다르다거나, API를 제한한다거나 하는 방식 등을 구현할 때 사용이 되곤 합니다.

 

MVP를 개발해서 배포했을 때는, 기능 구현이 우선이었기 때문에 Front에서만 validation을 처리를 했었는데 Back에서도 authorization을 적용하기 위해서 Guard를 사용했습니다.

 

Guard는 모든 middleware의 다음에 실행되고, interceptor 나 pipe 이전에 실행됩니다. 따라서 authorization으로 사용하기에 아주 적합하죠. 

 

1. Guard Implements

 

이제 회사 프로젝트에서 사용한 RoleGuard라는 함수를 살펴보겠습니다. 일부러 하드코딩을 해서 직관적으로 코드를 이해할 수 있게 약간 수정했습니다.

 

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  mixin,
  Type,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AdminUserRole } from '../common/common-type';

export enum MemberRoleEnum {
  OrgOwner = 'OrgOwner',
  ProjectOwner = 'ProjectOwner',
  ProjectAdmin = 'ProjectAdmin',
}

export const RoleGuard = (minimumRole: MemberRoleEnum): Type<CanActivate> => {
  @Injectable()
  class RoleGuardMixin implements CanActivate {
    constructor(private reflector: Reflector) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
      const request = context.switchToHttp().getRequest();
      const { user } = context.switchToHttp().getRequest();
      const projectId = request.params['projectId'];
      // enum index validation
      let isValidRole = false;
      await user.roles.filter((role: AdminUserRole) => {
        if (
          (role.projectId === projectId &&
            Object.keys(MemberRoleEnum).indexOf(`${role.role}`) <=
              Object.keys(MemberRoleEnum).indexOf(`${minimumRole}`)) ||
          role.role === 'OrgOwner'
        ) {
          isValidRole = true;
        }
      });
      if (isValidRole) {
        return true;
      } else {
        return false;
      }
    }
  }
  return mixin(RoleGuardMixin);
};

 

먼저 Guard를 구현하기 위해서는 CanActivate라는 인터페이스를 구현해야 합니다. 때문에 모든 Guard는 canActivate() 함수를 implement 해야 하죠. 이 함수는 현재의 request가 실행될 수 있는지 없는지를 나타내는 boolean을 return 해야 해야 하고 return값으로  권한이 있느냐 없느냐를 확인할 수 있게 됩니다. true라면 해당 request가 통과될 것이고, false라면 request는 당연히 통과할 수 없겠죠?

 

1-1. Execution Context

 

async canActivate(context: ExecutionContext): Promise<boolean>

 

canActivate() 함수는 하나의 ExecutionContext 인스턴스 argument를 가지게 됩니다.  ExectuionContext는 ArgumentHost로부터 상속되고 Request 오브젝트를 참조하기 위해서, Exception Filter에서 사용했던 ArgumentHost에 정의된 helper method들을 사용하고 있죠. ExecutionContext는 ArugmentHost를 extend 함으로써, 현재 실행되는 프로세스의 다양한 정보를 가져올 수 있는 새로운 method들을 가지게 됩니다. Guard구현에서 아주 중요한 부분 중 하나라고 할 수 있습니다.

 

이제 위 코드를 설명해보겠습니다.

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

먼저 ExecutionContext타입의 context에서 function들을 이용해서 http에 담겨있는 request값을 가져옵니다.

 

request안에는 현재 로그인되어있는 user라는 object가 있는데요. user안에는 gcip를 통과하면서 만들어진 jwt가 들어있습니다. jwt를 파싱 해서 안에 들어있는 roles를 가져오게 되죠.

 

이후 로그인한 user가 가지고 있는 roles와 위에서 만든 RoleGuard로 authorization를 진행할 Enum타입의 minimumrole을 비교해서 서로의 Enum Index를 비교해서 user가 가지고 있는 roles가 minimumrole보다 권한이 높은 지를 체크해서 boolean 타입을 return 하게 되는 것이죠.

 

2. Binding Guards

 

위에서 만든 RoleGuard를 어떻게 사용할까요? 아래와 같이 @UseGaurds라는 decorator를 사용합니다. NestJS에서의 decorator는 Spring에서 @annotation()이라고 생각하시면 됩니다.

 

Guard를 사용해서 authorization 체크를 하려고 하는 Controller에 decorator를 사용해주면 됩니다. 아래 코드 같은 경우에는 로그인한 user의 role이 ProjectOwner권한 이상일 경우에만 request를 보낼 수 있도록 하는 것이죠.

  @UseGuards(RoleGuard(MemberRoleEnum.ProjectOwner))
  @UseGuards(IapJwtAuthGuard)
  @Patch('/:projectId')
  async updateProject(
    @Param('projectId') id: string,
    @Body() updateProjectDto: UpdateProjectDto,
  ) {
    return this.projectService.updateProject(id, updateProjectDto);
  }
}

 

3.  Request속 jwt를 Guard에서 사용하기 위해서는?

 

2번 Binding Guards에서 @UseGuards(IapJwtAuthGuard)라는 decorator가 붙어 있는데요. RoleGuard는 위에서 만들어서 사용하는 것이고.. 그렇다면 이 decorator는 어떤 역할을 할까요?

 

 const { user } = context.switchToHttp().getRequest();

 

1번 과정에서 RoleGuard를 만들 때 user정보를 가져왔는데요. 로그인한 user 정보를 어떻게 가져올까요? 

 

현재 프로젝트에서는 GCIP를 사용해서 role을 가지고 다니기 때문에 jwt안에 customClaim을 이용해서 roles를 넣어서 jwt를 들고 다닙니다. 그렇다면 이 jwt를 어떻게 받아서 처리할까요? 바로 IapJwtAuthGuard 라는 Guard를 구현해서 GCIP에서 받은 jwt가 맞는지를 체크를 해서 request에 user정보를 넣어주는 방법으로 구현했습니다.

 

4. jwt authorization를 구현한 IapJwtAuthGuard

 

import { CollectionReference } from '@google-cloud/firestore';
import {
  ExecutionContext,
  Inject,
  NotAcceptableException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AdminUserDocument } from '../../admin-users/documents/admin-users.document';
import { Verifier } from '../gcp-iap/gcp-iap.verifier.service';

export class IapJwtAuthGuard extends AuthGuard('jwt') {
  constructor(
    @Inject(AdminUserDocument.collectionName)
    private adminUserCollection: CollectionReference<AdminUserDocument>,
  ) {
    super();
  }
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const verifier = new Verifier({
      projectId:
        process.env.APP_ENV
      projectNumber:process.env.APP_ENV
      backendServiceId: null,
    });
    try {
      const request = context.switchToHttp().getRequest();
      const iapJwt = request.headers['x-goog-iap-jwt-assertion'];
      const ticket = await verifier.verify(iapJwt);
      const userUid = ticket.payload.gcip.user_id;
      const adminUserSnapshot = await this.adminUserCollection
        .doc(`${userUid}`)
        .get();
      const adminUserInfo = adminUserSnapshot.data();
      const userRoles = adminUserInfo.roles;
      request.user = {
        uid: ticket.payload.gcip.user_id,
        roles: userRoles,
      };
      return true;
    } catch (error) {
      throw new NotAcceptableException(error);
    }
  }
}

 

IapJwtAuthGuard라는 Guard도 위에 RoleGuard와 비슷하게 canActivate함수를 구현해주었고 arg로 ExecutionContext를 받아서 request를 받고 request의 header안에 있는 jwt를 파싱 해서 가져온 뒤, 아래 verifier라는 class안에 verify라는 function을 통해서 ticket (gcip에서 받아온 jwt라는 사실을 알 수 있는 ticket)을 받아옵니다.

 

이후에는 ticket안에 들어있는 userUid를 가져온 뒤, Firestore에서 해당 userUid의 정보를 가져와서 request에 roles를 그대로 넣어주면 이 request.user.roles정보를 위에 RoleGuard에서 사용할 수 있게 되는 것이죠.

 

verifier라는 module을 만들어서 인증을 진행해주었고, verifier라는 module은 아래 코드로 구현했습니다. 한 줄 한 줄 읽어보시면 코드가 꽤나 직관적이기 때문에 쉽게 이해하실 수 있을 겁니다.

 

import { OAuth2Client } from 'google-auth-library';
import { debug } from 'util';

export class Verifier {
  private readonly expectedAudience;
  private readonly oAuth2Client;
  constructor({ projectNumber, projectId, backendServiceId }) {
    this.expectedAudience = null;
    if (projectNumber && projectId) {
      // Expected Audience for App Engine.
      this.expectedAudience = `/projects/${projectNumber}/apps/${projectId}`;
    } else if (projectNumber && backendServiceId) {
      // Expected Audience for Compute Engine
      this.expectedAudience = `/projects/${projectNumber}/global/backendServices/${backendServiceId}`;
    } else {
      throw new Error('invalid argument');
    }
    this.oAuth2Client = new OAuth2Client();
    debug('initialized successfully');
  }
  async verify(iapJwt) {
    if (typeof iapJwt !== 'string') {
      debug(`auth failed(iapJwt is invalid: '${iapJwt}')`);
      throw new Error('iapJwt must be string');
    }
    try {
      // Verify the id_token, and access the claims.
      const response = await this.oAuth2Client.getIapPublicKeys();
      const ticket = await this.oAuth2Client.verifySignedJwtWithCertsAsync(
        iapJwt,
        response.pubkeys,
        this.expectedAudience,
        ['https://cloud.google.com/iap'],
      );
      debug('auth success!');
      debug(`ticket: ${ticket}`);
      return ticket;
    } catch (e) {
      throw e;
    }
  }
}

 

 

5. 정리

 

이번 시간에는 NestJS에서 authorization을 어떻게 구현하는지를 알아보았고 그 과정에서 Guard를 살펴봤습니다.

 

jwt를 가져와서 Guard로 만들고, Guard를 거친 jwt에서 필요한 데이터를 request에 담아서 해당 데이터를 RoleGuard에서 사용하는 방법을 구현해보면서 NestJS에서 authorization처리를 하는 방법을 조금이나마 이해한 것 같습니다.

 

현재 있는 팀이 SecOps이기 때문에 authorization과 친해져야 하기 때문에 깊고 많은 학습이 필요하다고 생각이 들었네요.

 

다음 시간에는 인증(Authentication), 권한 부여(Authorization)의 차이점에 대해서 알아보겠습니다.

 

감사합니다.

 

반응형