개발일지

[NestJS] Passport+JWT+Guard 검증 로직 커스텀하기 본문

NestJS, Node.js/#02 NestJS

[NestJS] Passport+JWT+Guard 검증 로직 커스텀하기

lyjin 2024. 1. 6.

개요

이전에 개인프로젝트에서 Passport를 이용해서 jwt 검증을 구현한 적이 있다. 이때 어려웠던게 1. jwt 검증 로직 커스텀하기, 2. 에러 커스텀하기 였다. 결국 Guard canActivate() 메서드 안에서 error mesage에 따라 원하는 에러 객체를 반환할 수 있도록 구현했었다. 사실 상 Passport strategy의 장점을 살리지 못했던 방법이라고 생각한다.


이번 새 프로젝트에 들어가면서 또 한번 인증/인가를 구현하게 되었다. 그때의 기억을 살려 다시 한번 도전해봤다. 아니 근데 웬걸 공식문서(Extending guards)에 떡하니 나와있었다. 지금보면 너무나도 간단한 걸 그땐 왜 몇번을 읽어도 이해가 안됐던건지.. 허무하면서 뿌듯하다.


서론이 너무 길었는데 어찌됐던 전보다 더 개선된 Passport 사용기 시작! 이전 구현 과정은 [NestJS] 로그인 구현 #2 - Passport, JWT 에서 확인할 수 있다.

 


JwtStrategy

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
    constructor(
        private readonly config: ConfigService,
        private readonly authService: AuthService
    ) {
        JwtStra super(jwtStrategyOptionsForVerify(config));
    }

    async validate(payload: IJwtPayload) {
        const user = await this.authService.validateUserByJwtPayload(payload);

        return user; // request.uer
    }
}

 

후에 나오겠지만 에러 핸들링은 Guard의 handleRequest() 메서드에서만 이뤄지도록 구현했기때문에 validate()에서는 별다른 로직 없이 검증된 user 데이터를 그대로 반환해주었다. 여기서 또 user 검증 로직(validateUserByJwtPayload)을 왜 굳이 AuthService에서 구현한 뒤 주입 받는걸까.. 하는 의문을 가진 적이 있었다. JwtStrategy은 오로지 jwt 전략에만 집중할 수 있도록 책임을 분리하기 위해서라고 한다. (단일 책임 원칙)

 


config 분리

JwtStrategy super() 인수로 jwt 검증을 옵션이 들어가야한다. 이 옵션들만을 관리하는 모듈을 따로 빼서 작성했다.

 

import { IJwtConfig } from "@common/interfaces/config.interface";
import { ConfigService } from "@nestjs/config";
import { JwtModuleAsyncOptions, JwtModuleOptions } from "@nestjs/jwt";
import { ExtractJwt } from "passport-jwt";

export const jwtOptionsForSign: JwtModuleAsyncOptions = {
    useFactory: async (config: ConfigService) => {
        const env = config.get<IJwtConfig>('jwt');

        return {
            secret: env.secrets,
            signOptions: {
                expiresIn: env.expiresIn,
                algorithm: env.algorithm,
            }
        } as JwtModuleOptions
    },
    inject: [ConfigService]
};

export const jwtStrategyOptionsForVerify = (config: ConfigService) => {
    const env = config.get<IJwtConfig>('jwt');

    return {
        jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
        ignoreExpiration: false,
        secretOrKey: env.secrets,
        algorithm: env.algorithm
    }
};

 

jwtOptionsForSign은 jwt 생성을 위한 옵션이고 jwtStrategyOptionsForVerify은 jwt 검증을 위한 옵션이다. 이렇게 한 곳에 모아놓으면 관리에도 용이하고 쉽게 파악할 수 있다.

 


JwtAuthGuard

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

    canActivate(context: ExecutionContext) { ... }

    handleRequest(err, user, info) {
        if (err || info || !user) {
            throw err || new CustomException(
                'UNAUTHORIZED_JWT',
                { errMessage: info?.message },
                HttpStatus.UNAUTHORIZED
            );
        }

        return user; // request.uer
    }
}

canActivate() 메서드가 true를 반환하면 handleRequest() 메서드가 실행된다. 인수 중에 user는 위의 JwtStrategy에서 반환된 검증된 user 객체이다. 검증되지 않은 경우 에러를 던지고 아니면 user 객체를 반환한다.


canActivate() vs. handleRequest()

둘 다 인증에 관련된 비슷한 역할을 하는 메서드인 것 같아서 차이점이 궁금했다.

  • canActivate 메서드
    • CanActivate 인터페이스에 정의된 메서드로 주로 라우터 Guard에서 사용된다.
    • 라우터 가드에서 사용되며, 주로 리소스 접근 권한을 확인한다.
  • handleRequest 메서드
    • CanActivate가 아닌 Passport와 함께 사용되는 AuthGuard에 정의된 메서드
    • canActivate()가 true를 반환하면 실행되며, 실제 사용자 인증 로직을 처리한다.
    • 부모 클래스(NestAuthGuard)에서 자동 호출되며 인증 결과를 handleRequest 메서드로 전달한다.

세분화 하던 에러 코드를 통합한 이유

이전에 구현했을 땐 토큰이 없을 경우, 만료된 토큰 등에 따라 에러 코드가 세분화 했었다. 그런데 지금 생각해보니 굳이 세분화할 필요가 없었다. 프론트에서는 어쨌든 "토큰 검증 실패" 라는 동일한 결과이기에.. 그래서 이번엔 동일한 에러코드를 사용하고 메시지로 구별할 수 있도록 했다.



이렇게 handleRequest() 메서드를 오버라이딩하여 간단하게 jwt 인증을 커스텀해볼 수 있었다. 더 나아가 Guard 전역 설정, Public() 데코레이터 생성 등 새롭게 적용해본 것들이 많다. 이 게시글도 나중에 보면 또 아쉬울 수도 있겠지만 전에 비해 PassportStrategy을 제대로 사용해본 것 같아서 뿌듯하다!