본문 바로가기
Node.js

[NestJS] Passport JWT 토큰 인증 구현 & 에러 핸들링

by WhoamixZerOne 2023. 7. 6.

https://www.linkedin.com/pulse/why-you-should-start-using-nest-js-yaman-alashqar

JWT(JSON Web Token)에 대한 내용은 아래의 글을 참조
JWT(JSON Web Token)

 

이 글은 NestJS에서 JWT, Passport-jwt를 사용하여 토큰 인증을 구현하다가 겪은 내용을 정리한 글이다.

 

✔ 필요한 종속성 설치

NestJS에서 JWT 토큰과 Passport-Jwt를 사용하기 위해서 먼저 필요한 종속성을 설치해야 한다.

$ npm install --save @nestjs/passport passport @nestjs/jwt passport-jwt
$ npm install --save-dev @types/passport-jwt

✔ 동작 흐름

  • 로그인에 발급받은 토큰 값을 HTTP 헤더에 "Authorization: Bearer 토큰 값" 추가하고 요청
  • AuthGuard
  • JWT가 인증을 Passport에 위임 / PassportStrategy(자동 인증)
  • 인증 성공 시 validate() 실행

✔ 구현

1. JwtModule, PassportModule 등록

// auth/auth.module.ts
import { forwardRef, Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
import { Algorithm } from 'jsonwebtoken';

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_ACCESS_SECRET'),
        signOptions: {
          expiresIn: configService.get<string>('JWT_ACCESS_EXPIRESIN'),
          algorithm: configService.get<Algorithm>('JWT_ALGORITHM_TYPE'),
        },
      }),
      inject: [ConfigService],
    }),
    forwardRef(() => UsersModule),
  ],
  controllers: [AuthController],
  providers: [AuthService, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

register()를 사용해서 PassportModule 환경을 구성한다.

기본 Strategy를 'jwt'에 해당하는 것으로 사용한다.

 

registerAsync()를 사용해서 JwtModule 환경을 구성한다.

중요한 정보는 환경 변수에 등록하고 ConfigService를 통해 값을 가져온다.

 

2. JwtStrategy 클래스(PassportStrategy 상속)

// auth/Jwt.Strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '../users/users.service';
import { UserDto } from '../users/dto/user.dto';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly usersService: UsersService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.JWT_ACCESS_SECRET,
    });
  }

  async validate(payload: {
    userId: number;    
  }): Promise<UserDto> {
    const user: UserDto = await this.usersService.findById(payload.userId);
    if (!user) {
      throw new UnauthorizedException();
    }

    return user;
  }
}

JWT, Passport를 사용할 경우 JWT는 인증을 Passport에게 위임하고 Passport는 JWT의 인증을 수행하고 디코딩된 JSON을 전달한다.

 

PassportStrategy에 일부 초기화가 필요하므로 super()에 옵션을 전달하여 초기화를 수행한다.

  • jwtFromRequest : Request에서 JWT를 추출하는 방법을 제공
    • ExtractJwt.fromAuthHeaderAsBearerToken() : API Request의 Authorization 헤더에 Bearer 토큰 제공
  • secretOrKey: 토큰 서명에 사용할 대칭 비밀키
  • ignoreExpiration : 기본 값 false, JWT가 Passport 모듈에 만료되지 않았는지 확인하는 책임을 위임하는 기본 설정. 만료된 토큰 시 요청이 거부되고 상태 값 401, Unauthorized 메시지 응답을 전송. Passport는 자동으로 처리된다.

아래 문제를 얘기할 때 ignoreExpiration true일 시 문제에 대해서 다시 얘기한다.

 

validate()는 JWT의 인증에 문제가 없을 시 호출이 되고 디코딩된 JSON을 매개변수로 받는다.

 

3. JwtAuthGuard 클래스

// auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

AuthGuard를 상속받는 클래스를 구현한다.

 

4. @UseGuards(JwtAuthGuard) 가드 등록

// users/users.controller.ts
import { Controller, Get, UseGuards, Request } from '@nestjs/common';
import { UsersService } from './users.service';
import { AuthService } from '../auth/auth.service';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';

@Controller('users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
    private readonly authsService: AuthService,
  ) {}
  
  ...
  
  @UseGuards(JwtAuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

@UseGuards 데코레이터에 만든 가드 JwtAuthGuard를 등록한다.

API 요청을 "GET /users/profile"하면 Controller의 getProfile method가 실행되기 전에 Request lifecycle인 구현되어 있는 가드 JwtAuthGuard부터 실행이 되면서 JWT 인증을 실행하게 된다.

인증이 무사히 완료되면 getProfile method가 실행된다.

 

✔ 문제

이 문제는 위의 내용을 구현하면서 들었던 개인적인 생각일 뿐입니다.

1. 만료 시간(expiresIn) 단위

NestJS에서 JWT는 jsonwebtoken 라이브러리를 사용하는데 여기서 만료 시간(expiresIn)의 단위는 ms(Milliseconds)이다.

숫자만 사용하면 "120"이면 "120ms"와 동일하다. 다른 방식으로도 지정이 가능하다.

"60s"는 s가 초로 60초에 해당하고 "2h"는 2시간, "1d"는 하루로 지정이 가능하다.

더 자세한 내용은 아래를 참조
npmjs - jsonwebtoken

ms단위를 깜빡하고 60초(1분)로 계산을 해서 만료 시간을 사용해 버렸다. 그러므로 만료 시간이 짧은 탓에 구현을 다 하고 Postman으로 요청을 보냈을 때 상태 값 401, Unauthorized 메시지 응답을 전송받았다.

에러 메시지가 구체적이지 않아 무슨 문제인지 10 몇 분 동안 코드와 눈싸움을 하다가 발견해서 해결했는데, 사실 이 문제는 내가 실수한 것뿐이라 문제라 할 수도 없다. 하지만 에러에 대한 응답 메시지는 문제라고 생각했다.

 

2. JwtService 의존성 주입

에러 메시지를 직접 처리해 주기 위해 ignoreExpiration의 값을 true로 변경하고 AuthGuard 상속받아 canActivate method를 오버라이딩해서 인증 처리를 하고 에러를 처리하려고 했다.

// auth/jwt-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly jwtService: JwtService) {
    super();
  }
  
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException('토큰이 존재하지 않습니다.');
    }

    try {
      const secret = process.env.JWT_ACCESS_SECRET;
      const payload = await this.jwtService.verifyAsync(token, {
        secret: secret,
      });
      request['user'] = payload;
    } catch (err: unknown) {
      if (err instanceof TokenExpiredError) {
        throw new UnauthorizedException('만료된 토큰입니다.');
      } else if (err instanceof JsonWebTokenError) {
        throw new UnauthorizedException('유효하지 않은 토큰입니다.');
      }
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

위와 같이 직접 verifyAsync()로 인증 처리하려고 했지만 실행 시 verifyAsync()에서 에러의 내용은 원하던 내용이 아니었다.

에러는 많이 봤던 "cannot read properties of undefined (reading verifyAsync)"가 콘솔에 출력이 된다.

콘솔에 this.jwtService를 출력해 보면 undefined가 나온다. 의존성 주입을 했는데 왜 JwtService를 사용하지 못하는지 아직도 의문이다... 이 부분은 원인을 찾지 못하였다.

그래서 다른 방식으로 에러 핸들링을 구현했다.

 

3. 에러 핸들링

위에서 얘기했던 PassportStrategy에 초기화하기 위해 옵션을 전달하는 부분에서 ignoreExpiration의 기본 값은 false라고 했다. Passport는 인증을 자동으로 처리하고 에러에 대한 응답을 전부 상태 값 401, Unauthorized 메시지 응답을 전송하게 된다.

그러면 가장 중요한 만료된 토큰일 때 처리 하기가 힘들어진다. 또한 만료된 토큰 문제가 아니더라도 다른 에러가 발생했을 때 어떤 문제인지 알 수가 없다.

 

ignoreExpiration의 값을 true로 변경했었는데 이 부분을 내가 잘 못 이해하고 있었다. "true"로 설정하면 Passport가 자동으로 인증을 안 하고 직접 인증 처리를 구현해야 하는 걸로 생각을 했었는데 그게 아니었다.

"true"로 설정을 해버리면 JWT의 만료 시간을 무시하고 인증하는 과정에서 만료된 토큰도 허용이 된다. 즉, 만료 시간이 지났어도 계속 성공적으로 인증을 처리해서 진행이 된다는 점이다. 이는 만료된 토큰도 허용이 되므로 보안에 더욱더 취약할 수 있다.

"true"로 사용하는 것은 특별 상황 혹은 토큰이 만료된 후 일부 리소스에 대한 액세스를 허용하고자 할 때 유용하다고 하는데 그런 상황이 어떤 게 있을지 지금은 와닿지 않는다.

 

그래서 다시 "false"로 변경을 하고 아래와 같이 에러 핸들링을 구현했다.

// auth/jwt-auth.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './auth.service';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly authService: AuthService) {
    super();
  }

  handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
    if (info) {
      this.authService.JwtTokenErrorHandle(info);
    }

    return super.handleRequest(err, user, info, context);
  }
}
// auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';

@Injectable()
export class AuthService {
  
  ...

  JwtTokenErrorHandle(info: any): void {
    const errorMsg = info?.message ?? undefined;

    if (info instanceof TokenExpiredError) {
      throw new UnauthorizedException('만료된 토큰입니다.');
    } else if (info instanceof JsonWebTokenError) {
      if (errorMsg === 'invalid token') {
        throw new UnauthorizedException('유효하지 않은 토큰입니다.');
      } else if (errorMsg === 'jwt malformed') {
        throw new UnauthorizedException('잘못된 구성 요소의 토큰입니다.');
      } else if (errorMsg === 'invalid signature') {
        throw new UnauthorizedException('유효하지 않은 서명입니다.');
      }
    } else if (errorMsg === 'No auth token') {
      throw new UnauthorizedException('토큰이 존재하지 않습니다.');
    }
  }
}

위의 에러 핸들링에 관한 내용은 NestJS 공식문서에도 나와있는데 영어이다 보니 자세히 못 보고 지나쳤던 것 같다.

NestJS 에러 핸들링 내용은 아래 참조
NestJS Extending guards

 

 

🔗 Reference

'Node.js' 카테고리의 다른 글

[NestJS] 파일 업로드(File upload) Multer  (0) 2023.07.25
[NestJS] Jest 단위 테스트 Mock  (0) 2023.07.22
[NestJS] Configuration 설정 & TypeORM 연결  (0) 2023.05.31
Node.js 구조 & 동작 원리  (0) 2022.08.17
Node.js 교과서 4주차  (0) 2022.06.09

댓글