NestJS

[NestJS] JWT 인증 가드 작성 방법에 대한 두가지 방법 및 나의 생각

개발조하 2023. 12. 26. 16:46

개요

  • 인프런 강의를 듣다가 지금까지 못본 방법으로 jwt 인증을 구현하는 것을 보았다.
  • 내가 기존에 알고있던 방법과 새로 알게된 방법을 보고 이에 대한 차이점과 앞으로는 나는 어떤 방법을 통해서 개발할지를 생각해보자.

기존 방식으로 구현하는 법 (내가 원래 알고있던 방식)

내가 기존에 알고있는 방식은 PassportStrategy 를 사용하는 방식이였다. 사용 방식은 다음과 같다.

  1. 사용할 모듈에 JwtModule 등록

     // auth.module.ts
     @Module({
       imports: [
         PassportModule,
         JwtModule.register({ // JwtModule 등록 부분
           secret: process.env.JWT_SECRET || jwtConfig['accessSecret'], // 비밀키 등록
           signOptions: { // default 유효 기간 셋팅 
             expiresIn: jwtConfig['accessTokenExpiresIn'],
           },
         }),
       ],
       controllers: [AuthController],
       providers: [
         AuthService,
         AuthRepository,
         LocalStrategy,
         JwtRefreshStretagy,
         JwtAccessStretagy,
       ],
     })
     export class Auth...
  2. strategy 작성

    1. local.strategy.ts

       import { PassportStrategy } from '@nestjs/passport';
       import { AuthService } from '../auth.service';
       import { Strategy } from 'passport-local';
       import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
      
       @Injectable()
       export class LocalStrategy extends PassportStrategy(Strategy) {
         constructor(private readonly authService: AuthService) {
           super({
             usernameField: 'user_id', // username 으로 쓸 필드 이름
             passwordField: 'password', // 비밀번호로 쓸 필드 이름
             passReqToCallback: false,
           });
         }
      
         async validate(user_id, password) {
           console.log('[Guard] local guard');
           const user = await this.authService.validateLocalStrategy(
             user_id,
             password,
           );
           if (!user) {
             throw new HttpException(
               { message: '로그인에 실패하였습니다. (존재하지 않는 사용자 입니다.)' },
               HttpStatus.UNAUTHORIZED,
             );
           }
           return user;
         }
       }
    2. accessToken.strategy.ts

       import { PassportStrategy } from '@nestjs/passport';
       import { ExtractJwt, Strategy } from 'passport-jwt';
       import * as config from 'config';
       import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
       import { AuthService } from '../auth.service';
      
       /**
        * PassportStrategy 인증을 진행할 때 사용할 전략을 정의
        */
       @Injectable()
       export class JwtAccessStretagy extends PassportStrategy(Strategy, 'access') {
         constructor(private readonly authService: AuthService) {
           super({
             secretOrKey: process.env.JWT_SECRET || config.get('jwt.accessSecret'),
             ignoreExpiration: false,
             jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
           });
         }
      
         async validate(payload) {
           console.log('[Guard] access jwt guard');
           const user = await this.authService.validateAccessTokenStretagy(
             payload.user_id,
           );
           if (!user) {
             throw new HttpException(
               { message: '잘못된 사용자 입니다.' },
               HttpStatus.UNAUTHORIZED,
             );
           }
           return user;
         }
       }
  3. 컨트롤러에 AuthGuard 로 우리가 작성한 전략으로 가드를 수행하도록 등록

     // auth.controller.ts
     @Post('login')
     @UseGuards(AuthGuard('local')) // local 전략
     async loginProcess(@Res() res: Response, @Body() user: LoginDto) {}

해당 방식의 특징으로는 PassportStrategy 객체에서 입력되는 토큰에 대해 사용자가 별도의 로직을 안해도 검증을 해준다는 점이 있다. 또한 토큰 검증 이후 사용자가 추가적으로 validate 함수를 통해서 인증 로직을 구현할 수 있다. validate 함수의 반환값은 request 객체에 삽입되게 된다. (ex. 2번 전략의 경우 request.user 에 반환값이 들어있다.)

새로운 방식

새로운 방식으로는 PassportStrategy 를 사용하는게 아닌 직접 토큰 파싱, jwt 검증, 헤더에 반환값 삽입 등을 구현하는 것이다. (물론 jwt 검증은 jwtService.verify 함수를 사용한다.) 사용방법은 다음과 같다.

  1. 사용할 모듈에 JwtModule 등록

     // auth.module.ts
     import { Module, forwardRef } from '@nestjs/common';
     import { AuthService } from './auth.service';
     import { AuthController } from './auth.controller';
     import { JwtModule } from '@nestjs/jwt';
     import { UsersModule } from 'src/users/users.module';
    
     @Module({
       imports: [JwtModule.register({}), forwardRef(() => UsersModule)],
       exports: [AuthService],
       controllers: [AuthController],
       providers: [AuthService],
     })
     export class AuthModule {}
  2. Custom Guard 작성

    1. basic-token.guard.ts (local 과 비슷한 놈으로 사용자가 로그인시 id, password 를 그대로 주는게 아니라 base64(id:password) 값을 주는게 일반적이라고 한다..)

       @Injectable()
       export class BasicTokenGuard implements CanActivate {
         constructor(private readonly authService: AuthService) {}
         async canActivate(context: ExecutionContext): Promise<boolean> {
           const req = context.switchToHttp().getRequest();
      
           // {authorization: 'Basic asdkjadkljasld'}
           const rawToken = req.headers['authorization'];
           if (!rawToken) {
             throw new UnauthorizedException('권한이 없습니다.');
           }
      
           // Basic asldjkhasdjklajsldkj -> asldjkhasdjklajsldkj 추출
           const token = this.authService.extractTokenFromHeader(rawToken, false);
           // asldjkhasdjklajsldkj -> email, passowrd 추출
           const { email, password } = this.authService.decodeBasicToken(token);
           const user = await this.authService.authenticateWithEmailAndPassword({
             email,
             password,
           });
           // 실행 컨텍스트에서 직접적으로 request 객체를 추출해서 user 값을 저장한다.
           req.user = user;
      
           return true;
         }
       }
    2. bearer-token.guard.ts

       import {
         CanActivate,
         ExecutionContext,
         Injectable,
         UnauthorizedException,
       } from '@nestjs/common';
       import { AuthService } from '../auth.service';
       import { UsersService } from 'src/users/users.service';
      
       @Injectable()
       export class BearerTokenGuard implements CanActivate {
         constructor(
           private readonly authService: AuthService,
           private readonly usersService: UsersService,
         ) {}
         async canActivate(context: ExecutionContext): Promise<boolean> {
           const req = context.switchToHttp().getRequest();
           const rawToken = req.headers['authorization'];
      
           if (!rawToken) {
             throw new UnauthorizedException('토큰이 없습니다.');
           }
      
           // Bearer asldjkhasdjklajsldkj -> asldjkhasdjklajsldkj 추출
           const token = this.authService.extractTokenFromHeader(rawToken, true);
           // jwtService.verify 함수를 통해서 토큰 검증 
           const result = await this.authService.verifyToken(token);
      
           /**
            * request 에 넣을 정보
            *
            * 1) 사용자 정보 - user
            * 2) token - token
            * 3) tokenType - access | refresh
            */
           req.token = token;
           req.tokenType = result.type;
           const user = await this.usersService.getUserByEmail(result.email);
           req.user = user;
      
           return true;
         }
       }
      
       @Injectable()
       export class AccessTokenGuard extends BearerTokenGuard {
         async canActivate(context: ExecutionContext): Promise<boolean> {
           await super.canActivate(context);
      
           const req = context.switchToHttp().getRequest();
           if (req.tokenType !== 'access') {
             throw new UnauthorizedException('Access Token이 아닙니다.');
           }
           return true;
         }
       }
      
       @Injectable()
       export class RefreshTokenGuard extends BearerTokenGuard {
         async canActivate(context: ExecutionContext): Promise<boolean> {
           await super.canActivate(context);
      
           const req = context.switchToHttp().getRequest();
           if (req.tokenType !== 'refresh') {
             throw new UnauthorizedException('Refresh Token이 아닙니다.');
           }
           return true;
         }
       }
  3. 컨트롤러에 가드를 등록해준다.

     @Post('token/access')
     @UseGuards(RefreshTokenGuard)
     postTokenAccess(@Headers('authorization') rawToken: string) {

두가지 방법에 대한 나의 생각

  • 두가지 방법 모두 간결하고 쉬워서 복잡하고 그럴게 없어보인다.
  • 다만 개인적으로 PassportStrategy 사용하는 방식이 더 끌린다.
    • 이유1 : 토큰 검증에 대해서 사용자가 별도로 서비스 로직을 구현하지 않아도 됨.
    • 이유2: validate 라는 메소르도 분리되어 해당 메소드 안에 토큰 검증 이후 추가적으로 로직을 구현할 수가 있어서 각 역할이 분리되어있는게 개인적으로는 트러블 슈팅에 좋을 듯함 (ex. 토큰이 잘못된건지, 인증 로직이 잘못되었는지 구분)
  • 물론 나는 두가지 방법 모두 좋다고 생각하고, 어떻게 구현하든 통일만 하면 상관없어보이긴하다.
  • 위 두가지 방법을 모두 알고있는 다음 다른 회사 또는 오픈소스를 분석할 때 참고하면 될듯 하다.