[NestJS] 간단한 Guard 커스터마이즈

Gon Kim·2022년 11월 29일
0

해당 글을 읽기 전에
[NestJS] ArgumentHost and Excecution Context, Reflectioin and MetaData를 읽고 오길 강력하게 추천한다. 가능하면 passport를 사용하는 방법에 대해 간단히 숙지하고 와도 좋다.

1) Guards?

A guard is a class annotated with the @Injectable()
decorator, which implements the CanActivate
interface.

injectable이니 provider이다. canActivate라는 인터페이스의 구현체

  • request가 다뤄지기 전에 먼저 핸들러에 의해 다뤄질 자격이 있는지 확인하는 역할! authorization을 주로 담당하는 미들웨어
  • epxress 미들웨어는 next() 함수가 뭐가올지 모르지만, guard는 ExecutionContext라는 인스턴스에 접근할 수 있고, guard 후에 어떤게 실행되어야할지 아는 친구라고 한다.

2) 구현

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

말했듯이 guards는 CanActivate의 구현체!

CanActivate의 스펙에는 canActivate라는 꼭 구현해야할 함수가 있다.

canActivate

이 친구는 boolean을 반환한다. 현재 요청이 처리되어야할 친구인지 아닌지 확인해주는 친구! 원하는 만큼 복잡하게 구현 가능하다.

canActivate 함수는 ExecutionContext 인스턴스를 인자로 받는다. ExecutionContext는 현재 요청을 처리하는 handler, controller, 그리고 현재 요청과 관련된 인자들(http 요청의 경우 request, response, next 등..)을 받아올 수 있는 메소드를 제공한다. 더 자세한 설명은

[NestJS] ArgumentHost and Excecution Context, Reflectioin and MetaData를 확인하자

3) 적용법

controller, method, global 범위로 지정해줄 수 있다. 컨트롤러 안의 모든 함수에 적용하거나, 특정 함수에 적용하거나, 혹은 모든 컨트롤러에 적용하거나! 라는 뜻

초기화의 책임, controller scope

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

@UseGuards 데코레이터에 넣어줘 사용할 수 있다. ‘,’로 구분해 여기에 여러 가드를 넣어줄 수도 있다.

잘 보면 Guard 인스턴스가 아닌 클래스를 넘겨줬는데, 이는 NestJS에게 인스턴스 초기화에 대한 책임을 위임하겠다는 뜻이다.

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

pipe이나 exception filter처럼 인스턴스를 넘겨줄 수도 있다

method, global scope

컨트롤러의 특정 함수 위에 @UseGuards를 써서 해당 함수에만 적용되게 하거나,

// main.ts
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new AuthGuard());

요렇게 useGlobalGuards로 전체 어플리케이션에 적용도 가능하다.

global scope라는 것은, 특정 모듈에서 초기화 되는게 아니라 모든 모듈의 밖에서 단독적으로 초기화 된다는 뜻이다.

이렇게 모듈 밖에서 초기화 시키는 경우에는 AuthGuard에 의존성을 주입할 수 없게 된다. Guard의 동작이 모듈 외부(main.ts)에서 이루어지기 때문이다. 의존성을 굳이 주입하기 위해서는

// app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

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

이렇게 class provider로 등록하면 된다. 물론 꼭 class provider 형식이 아니더라도 어쨋든 최상위 module 파일에 등록해주면 된다.

[NestJS] ArgumentHost and Excecution Context, Reflectioin and MetaData를 잘이해하고, 해당 링크의 Putting it all together 부분을 확인하면, 특정한 경우에만 request를 핸들링해주는 guard를 손쉽게 커스터마이즈할 수 있다!

4) 커스터마이즈

JwtGuard

// jwt.guard.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (request) => {
          return request?.cookies?.accessToken;
        },
      ]),
      ignoreExpiration: false,
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(payload) {
    return { id: parseInt(payload.id), userRole: payload.userRole };
  }
}

우리는 위와 같은 JwtStrategy를 사용하고 있다.

로그인, 회원가입을 제외한 모든 페이지에서는 유효한 jwt 토큰을 가지고 있도록 하고, 특정 api들은 점주만, 특정 api들은 고객까지 사용할 수 있도록 하고자 한다.

모든 페이지에서 jwt 토큰을 검사할 필요가 있으므로, 이 부분에 대한 검증은 전역으로 빼고자 했다. 이 때 그럼에도 불구하고 public하게 열려있어야하는 api들이 있으므로 이를 위한 데코레이터를 만들었다.

// public.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const PUBLIC_KEY = 'PUBLIC_KEY';
export const Public = () => SetMetadata(PUBLIC_KEY, true);

SetMetadata로 위와 같이 public decorator를 만든다. PUBLIC_KEY를 key로, true를 value로 갖는 metadata가 만들어진 것이다.

// jwt.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { PUBLIC_KEY } from '../decorators/public.decorator';

@Injectable()
export class JwtGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) {
      return true;
    }

    return super.canActivate(context);
  }
}

passport에서 제공하는 AuthGuard를 상속해 JwtGuard를 만들어준다.

reflector로 PUBLIC_KEY를 먼저 조회해, metadata가 존재한다면 무조건 true를 반환하게 하고, 그게 아니라면 부모의 canActivate를 호출해 본래의 로직을 따라가도록 커스터마이즈 했다.

이로써 @Public이 붙어있는 핸들러라면 어떠한 요청이든 해당 핸들러로 통과하게 된다.

RoleGuard

내가 진행하는 커피 주문 앱 제작 프로젝트에서는 유저가 점주와 고객으로 나뉜다.

고객이 점주를 대상으로 하는 api에 접근하면 안되니 이에 맞는 guard도 만들어줬다.

// userRole.enum.ts
export enum USER_ROLE {
  CLIENT = 'CLIENT',
  MANAGER = 'MANAGER',
}

enum을 하나 만들어 두고

마찬가지로 핸들러에 metadata를 추가할 수 있게

// role.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { USER_ROLE } from '../../user/enum/userRole.enum';

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

커스텀 데코레이터도 하나 만들어둔다.

// role.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  BadRequestException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { USER_ROLE } from 'src/user/enum/userRole.enum';
import { ROLES_KEY } from '../decorators/roles.decorator';

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

  canActivate(context: ExecutionContext): boolean | Promise<boolean> {
    const req = context.switchToHttp().getRequest();
    const { user } = req;

    if (!user) {
      throw new BadRequestException(
        '요청에 유저를 식별할 수 있는 정보가 없습니다.'
      );
    }

    const allowedRoles = this.reflector.getAllAndOverride<USER_ROLE[]>(
      ROLES_KEY,
      [context.getHandler(), context.getClass()]
    );

    return allowedRoles.some((role) => user.userRole === role);
  }
}

일단 해당 guard가 걸리는 상황은 JwtGuard를 통과한 이후이다. Passport는 strategy에서 validate 함수에 구현해 놓은 부분의 반환 값을 request객체 아래에 user라는 key로 달아준다.

위에서 잠깐 봤던 JwtStrategy를 확인해보면 우리는 id와 userRole을 필드로 가지고 있는 객체를 반환해줬다.

ExecutionContext 객체로 이 request.user를 가져와 userRole을 꺼내 들어온 요청이 어떤 권한을 가지고 있는지 확인할 수 있다.

some 함수를 통해 reflector를 통해 요청이 들어온 핸들러의 metadata를 가져오고, 요청의 권한이 metadata에 명시된 권한들 중 존재한다면 요청을 핸들러로 넘겨주는 로직을 만들 수 있다.

some 함수는 하나라도 callback이 반환하는 결과 중 하나라도 true가 존재하면 true를 반환하는 함수이다.

끝!!!

Ref

https://docs.nestjs.com/guards
https://docs.nestjs.com/security/authentication

profile
응애

0개의 댓글