JWT를 선택한 이유 + NestJS 로 JWT 구현하기

김현욱·2022년 8월 15일
1

개발일지

목록 보기
2/3

1. 개요

모든 웹 서비스, 앱 서비스 나아가 인트라넷을 사용할 때 반드시 필수로 구현하는 기능이 로그인 기능이다.
개발을 깊게 공부하기 전에는 로그인 기능이 복잡한 기능인지 몰랐다.
(예를 들자면 쇼핑몰? 그게 어려워?)
ToDoList 수준에 토이프로젝트를 만들 때 다음과 같이 생각했었다.

  1. 아이디, 비밀번호를 DB 에 저장한다.
  2. Request Body 에 있는 값들을 단순 비교 한다.
EX) 
if(req.body.id === user.id && req.body.password == user.password) { 
	return true; 
}  else { 
	return false; 
}
  1. 로그인 성공/실패

하지만 실제 사람들이 이용할 서비스를 만들면서 이런 식으로 만들어 놓으면 안되는 다는 것을 피드백, 경험 등을 통해서 알았다.

  1. 위와 같은 방식으로 구현 해 놓으면 권한이 있어야 접근할 수 있는 API 를 요청할 때 유저의 id,password 를 계속 보내야 하고 이는 보안 문제를 야기한다.
  2. client-side 에서 login id 와 password 를 계속 관리 해야한다.

2. JWT를 쓰기로 결정했던 이유

아마 제 생각에 극초기 스타트업들(시리즈A 이전)이나 예비 법인설립자 팀에서 서비스를 만들 때 다음과 같은 이유로 대부분 JWT 를 선택하지 않을까 싶습니다.
(통계적인 자료는 없고 그냥 제 뇌피셜+경험입니다)
(참고로 NodeJs, NestJs 를 사용하는 입장에서 작성했습니다.)

  1. 메모리 DB 를 따로 두기엔 돈이 부족하다.
  2. 서비스 초기엔 강제 로그아웃 등의 사용자들을 직접 관리할 일이 드물고,
    보통 웹과 앱을 동시에 운영하는 팀이 많이 없다

1. 메모리 DB 를 따로 두기엔 돈이 부족하다 :

우선 다음과 같은 상황이라고 가정하겠습니다

  • 서버리스로 운영하고 있고 클라우드는 AWS 사용
  • 세션 저장소는 메모리 DB 사용
  • 서버 인스턴스는 ECR/ECS 를 사용해서 띄운다
  • AWS RDS 사용
  • NAT 게이트웨이 사용
  • 프리티어 계정 이용

세션을 저장할 때 보통 3가지 선택지가 있습니다.

  • 톰켓 세션
  • mysql, postgresql 등 RDMS
  • 메모리 DB (Redis 등) 에 Session 저장

주로 선택하는 방법은 메모리 DB 에 저장하는 방법을 많이 선택합니다.
톰켓 세션은 서버 재시작 시 로그인을 다시 해야하고 RDMS 는 로그인 요청이 많아지면 성능이슈가 발생하기 때문입니다.

위에 언급한 환경이 가장 싸게 할 수 있고 개발자가 많이 없을 때 사용하는 방법인 거 같습니다.

제가 현재 118MB 크기에 NestJS 를 실행시키고 개발용으로 저만 사용했을 기준으로 ECR/ECS 에서 고정적으로 18달러 정도가 나오고 있고 그 외 부가적인 리소스들까지 합치면 40달러 정도가 나오고 있습니다(VAT 제외)

만약 RDS 를 t2.micro 보다 높은 것을 사용하거나, 프리티어 계정이 없는 경우 비용은 당연히 더 많이 청구될 것입니다. 여담이지만 AWS에 익숙하지 않으시거나 인스턴스 끄는 걸 깜빡했을 때 1~2달 사이에 많은 비용이 청구된 경험을 주변에서 심심치 않게 보는 거 같습니다. 물론 저도 20만원 청구됐던 경험이 있습니다...
(법인이 있다면 크레딧을 받을 수 있는 방법이 있는데 이 경우는 제외하겠습니다.)

고작 40달러 아니야? 라고 생각하실 수도 있지만 저처럼 거의 자본없이 창업을 하시는 분들이라면 40달러가 결코 작은 돈이 아니라는 것에 공감하실 수 있을 것입니다.

만약 여기서 메모리 DB 를 도입한다면 대략적인 추가 비용은 다음과 같습니다.
출처 : https://aws.amazon.com/ko/elasticache/pricing/

개발용으로 낮은 유형의 t2.micro 를 사용하면 월 18.98달러의 추가 비용이 발생합니다.

만약 범용적인 인스턴스 m6g.large 를 선택한다면 132.13 달러가 발생합니다.

세션이 좋다, Jwt 가 좋다를 개발적으로 논하기 이전에 사업을 운영하는 관점에서 바라본다면 발생하지 않을 수 있고 꼭 필요한 기능이 아니라면 돈이 덜 드는 방향으로 선택하는 것이 사업적으로 맞는 판단이라고 생각합니다.
(혹시 aws 예상비용 계산하시고 싶으신 분들은 aws예상비용 을 참고하시면 좋을 거 같습니다)

2.서비스 초기엔 강제 로그아웃 등의 사용자들을 직접 관리할 일이 드물고,
보통 웹과 앱을 동시에 운영하는 팀이 많이 없다
:

여기서 제가 말하는 '웹과 앱을 동시에 운영한다' 는 넷플릭스처럼 모바일에서도 볼 수 있고 데스크톱으로도 볼 수 있는 데 동시에 같은 사람이 로그인하면 문제가 발생하는 서비스를 말합니다.
대개 초기 스타트업의 웹,앱은 MVP(Minimum Viable Product) 성격을 많이 띄는 거 같습니다. 물론 웹과 앱 모두 지원한다면 좋겠죠.
하지만 VC도 만나러 다녀야하고 IR 자료도 만들어야하고 PPT 연습도 해야 하고
심지어 서비스 기능도 계속해서 Develop 됩니다.
계속되는 수정사항이 얼마나 화나는 일인지 개발자분들이라면 매우 공감하실겁니다......하아...
이러한 해결해야하는 문제가 많은 상황 속에서 '앱과 웹을 모두 지원하면서 2가지 디바이스에서 동시에 로그인하는 상황을 제어하겠다' 는 쉽지 않을 겁니다.
( 물론 반드시 이런 상황을 제어해야하는 서비스는 예외입니다. )

저희 팀은 저 혼자 개발자였고 이 상황을 혼자서 컨트롤하기엔 너무 어려울 거 같았습니다. 유저를 계속해서 추적해야할 이유도 없었고요.

그래서 저는 Session 방식에 비해 돈이 덜 들고, 신경쓸게 session 대비 아주 조금 더 적은 jwt 를 선택했습니다.

3. NestJs 를 이용하여 Jwt Login 구현

1) 환경

  • NodeJS version : 16.16.0
  • passport, passport-jwt, passport-local

2) 구현 흐름도

  • 로그인 시 Access Token 과 Refresh Token 을 쿠키에 담아 보냅니다.
  • Refresh Token 은 DataBase 에 보관합니다.
  • 사용자가 토큰을 헤더에 담아보내면 NestJs의 AuthGuard 를 통해 유효성을 판단합니다.

3) 구현 예제 코드

1. local-strategy 구현

// local.strategy.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { AuthService } from '../auth.service';
import { Strategy } from 'passport-local';
import {User} from "../../user/entities/user.entity";

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super({
      usernameField: 'phoneNumber',
      passwordField: 'password',
    });
  }

  async validate(phoneNumber: string, password: string): Promise<User | UnauthorizedException> {
    const user: User = await this.authService.validateUser(phoneNumber, password);
    if (!user) {
      throw new UnauthorizedException();
    }
    return user;
  }
}
  • 저희 서비스는 아이디를 따로 입력받지 않고 휴대폰 번호를 아이디로 사용했습니다.
    그래서 vaildate 의 usernameField 를 phoneNumber 로 수정했습니다.
  • 해당하는 휴대폰 번호와 비밀번호를 찾지 못하면 UnauthorizedException 을 throw 합니다.
// auth.service.ts 

async validateUser(phoneNumber: string, password: string): Promise<any> {
    const user = await this.userRepository.findOne({
      where: { phoneNumber },
      select: ['phoneNumber', 'password', 'id'],
    });
    if (user && (await bcrypt.compare(password, user.password))) {
      const { ...result } = user;
      return result;
    }
    return null;
  }
  • local-stragtegy 에서 사용한 validateUser 함수입니다.
  • result 에 id 값을 추가한 것은 Jwt token 만들 때 사용하기 위해서 입니다.
// local-auth.guard.ts

import { AuthGuard } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
  • controller 에서 좀 더 명시적으로, 쉽게 사용하기 위해 custom guard 를 만들었습니다.
//auth.controller.ts

  @UseGuards(LocalAuthGuard)
  @Post('/login')
  async login(@Request() req) {
    return await this.authService.login(req);
  }

2. jwt-strategy 구현

jwt 에 담는 유저 정보는 userId만 담는 코드로 수정했습니다.

// jwt.strategy.ts

import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.ACCESS_SECRET_KEY,
    });
  }

  async validate(payload: any) {
    return {
      userId: payload.userId,
    };
  }
}
  • Access Token 인증 관련 코드입니다.
  • Access Secret key 와 Refresh Secret Key 는 따로 관리하고 있습니다.
  • 유효 시간도 환경 변수로 처리해서 관리하고 있습니다.
// jwt.guard.ts

import { AuthGuard } from '@nestjs/passport';

export class JwtAuthGuard extends AuthGuard('jwt') {}
  • local-strategy 와 마찬가지로 Custom AuthGuard 를 만들어서 Annotation 을 사용하고 있습니다.
// jwt-refresh.strategy.ts

import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';

export class JwtRefreshStrategy extends PassportStrategy(
  Strategy,
  'jwt-refresh'
) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: process.env.REFRESH_SECRET_KEY,
      passReqToCallback: true,
    });
  }

  async validate(req: Request, payload: any) {
    return {
      userId: payload.userId,
    };
  }
}
  • Refresh Token 은 Access Token 과 코드가 거의 동일합니다.
  • Access Token 과 Refresh Token 의 차이점은 Service 로직을 수행하느냐, Access Token 을 다시 발급해주냐 입니다.
// jwt-refresh.guard.ts

import { AuthGuard } from '@nestjs/passport';

export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}

3. Service 로직 구현

controller 의 Path 은 일부러 삭제했습니다.

// auth.controller.ts

import {
  Body,
  Controller,
  Get,
  NotFoundException,
  Post,
  Query,
  Request,
  UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './local/local-auth.guard';
import { ApiTags } from '@nestjs/swagger';
import { CoreOutput } from '../common/dto/core-output.dto';
import { JwtRefreshAuthGuard } from './jwt/jwt-refresh-auth.guard';
import { CreateUserInput } from './dto/create-user.dto';
import { JwtAuthGuard } from './jwt/jwt-auth.guard';

@Controller('api/v1/auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  /**
   * Access Token, Refresh Token 발급
   * @param req
   */
  @UseGuards(LocalAuthGuard)
  @Post()
  async login(@Request() req, @Res({ passthrough: true }) response) {
    await this.authService.login(req, response);
  }

  /**
   * Access token, Refresh Token 재발급
   * @param req
   */
  @UseGuards(JwtRefreshAuthGuard)
  @Get()
  async refresh(@Request() req) {
    return await this.authService.refreshTokens(req);
  }

  /**
   * Refresh token null 처리
   * @param req
   */
  @UseGuards(JwtRefreshAuthGuard)
  @Get()
  async logout(@Request() req): Promise<NotFoundException | CoreOutput> {
    const { userId } = req.user;
    return await this.authService.logout(userId);
  }
  • login 시 Access Token 과 Refresh Token 이 쿠키에 담겨 사용자에게 갑니다.
  • access token 이 만료됐을 때 사용하는 api 를 따로 두었고, refreshTokens 에서 access token 과 refresh token 을 새로 발급해주고 User Entity 에 있는 refresh token 을 update 합니다.
  • User Entity 에 refresh token 을 저장하는 column을 두었습니다.
  • logout 시에 refresh token 의 값을 null 처리하는 방식으로 처리했습니다.
import {
  Injectable,
  NotFoundException,
  UnauthorizedException,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from '../user/entities/user.entity';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { CreateUserInput } from './dto/create-user.dto';
import { CoreOutput } from '../common/dto/core-output.dto';
import { HttpService } from '@nestjs/axios';
import { v4 as uuidv4 } from 'uuid';

@Injectable()
export class AuthService {
  constructor(
    private readonly configService: ConfigService,
    private readonly jwtService: JwtService,
    private readonly httpService: HttpService,
    @InjectRepository(User)
    private readonly userRepository: Repository<User>
  ) {}

  async validateUser(phoneNumber: string, password: string): Promise<any> {
    const user = await this.userRepository.findOne({
      where: { phoneNumber },
      select: ['phoneNumber', 'password', 'id'],
    });
    if (user && (await bcrypt.compare(password, user.password))) {
      const { ...result } = user;
      return result;
    }
    return null;
  }

  async login(req: any,response: Reponse) {
    const { id, phoneNumber } = req.user;
    const { accessToken, refreshToken } = await this.getTokens(
      id,
    );
    
    // refresh token 갱신
    await this.updateRefreshToken(id, refreshToken);
	
    response.cookie('jwt', access_token, { httpOnly: true });
    response.cookie('jwt-refresh', refresh_token, { httpOnly: true });
    return {
		ok : true,
    };
  }


  async refreshTokens(req: any) {
    const { userId, role, phoneNumber, refresh_token } = req.user;
    const user = await this.userRepository.findOne({
      where: { id: userId },
      select: ['refreshToken'],
    });

    if (!user) {
      return new NotFoundException();
    }
    if (refresh_token !== user.refreshToken) {
      return new UnauthorizedException();
    }

    const { accessToken, refreshToken } = await this.getTokens(
      userId,
      role,
      phoneNumber
    );
    await this.updateRefreshToken(userId, refreshToken);

    response.cookie('jwt', access_token, { httpOnly: true });
    response.cookie('jwt-refresh', refresh_token, { httpOnly: true });
    return {
		ok : true,
    };
  }

  async logout(userId: number): Promise<NotFoundException | CoreOutput> {
    const user = await this.userRepository.findOne({
      where: { id: userId },
    });

    if (!user) {
      return new NotFoundException();
    }

    await this.userRepository.update(userId, {
      refreshToken: null,
    });

    return {
      ok: true,
    };
  }


  async updateRefreshToken(userId: number, refreshToken: string) {
    await this.userRepository.update(userId, {
      refreshToken: refreshToken,
    });
  }

  async getTokens(userId: number, phoneNumber: string, userRole: string) {
    const [accessToken, refreshToken] = await Promise.all([
      this.jwtService.signAsync(
        {
          userId: userId,
        },
        {
          secret: this.configService.get<string>('ACCESS_SECRET_KEY'),
          expiresIn: this.configService.get<string>('ACCESS_EXPIRES_IN'),
        }
      ),
      this.jwtService.signAsync(
        {
          userId: userId,
          phoneNumber: phoneNumber,
        },
        {
          secret: this.configService.get<string>('REFRESH_SECRET_KEY'),
          expiresIn: this.configService.get<string>('REFRESH_EXPIRES_IN'),
        }
      ),
    ]);

    return {
      accessToken: accessToken,
      refreshToken: refreshToken,
    };
  }
}
  • refreshTokens : 해당 함수에서 access token 과 refresh token 을 모두 Update 합니다. 제가 refresh token 까지 새로 발급하기로 결정한 이유를 설명해보겠습니다.
    제가 저희 서비스에서 고민했던 2가지는 다음과 같습니다.

    1) 서비스의 특성상 매일 매일 접속할 필요는 없습니다. 그래서 Refresh Token 의 유효 시간을 너무 짧게 잡으면 사용자가 매번 다시 로그인을 해야합니다.
    이런 UX는 너무 안 좋기 때문에 Refresh Token 의 유효 기간을 길게 잡아야된다고 생각했습니다.

    2) 하지만 보안상 유효 시간을 너무 길게 잡으면 refresh token 이 탈취당했을 때 대처할 방법이 없습니다.

그래서 제가 선택한 방법은 access token 갱신을 요청할 때 refresh token 도 같이 갱신하는 방법을 선택했습니다. 해당 방법을 선택하면 2가지가 해결된다고 생각했습니다.

  1. 사용자가 로그인을 자주 안하더라도 너무 자주 로그아웃되지 않는다.
  2. refresh token 이 매번 갱신되기 때문에 유효기간이 짧은 효과를 가져온다.

사실 이 방법이 맞는 지는 확신이 안 들긴 합니다만 제가 알고 있는 지식과 자료 조사를 했을 때 가장 최선의 방법이라고 생각했습니다.
(저는 주니어 개발자이기 때문에 해당 방법이 잘못됐거나 더 좋은 해결책이 있으면 댓글 달아주세요... 정말 도움이 많이 됩니다..ㅠ😂)

  • updateRefreshToken : 해당 함수에서 token 을 갱신할 때 save 가 아니라 update 를 사용했습니다. update 를 사용한 이유는 이미 token 을 통해 user 가 있다는 것을 확인 상태이기 때문에 update 를 쓰더라도 문제가 없다고 판단했습니다.

  • getTokens : NestJS 에서는 기본적으로 JwtConfigure 를 제공해줍니다.
    저는 Access Token 과 Refresh Token 의 만료 시간과 Secret 키를 따로 관리하고 싶어서 해당 함수를 만들어주었습니다.

지금까지 jwt를 활용한 NestJs Login 이었습니다. 잘못된 부분이 있거나 궁금한 부분있으면 편하게 댓글 남겨주시면 감사하겠습니다😊
client-side 는 Flutter 로 구현했는데 해당 부분은 차차 업로드하겠습니다.

profile
아 왜 안돼?

2개의 댓글

comment-user-thumbnail
2023년 4월 6일

안녕하세요. 게시글 잘읽었습니다!
혹시 jwt 인증이 필요한 API를 호출할 때 클라이언트에서 Access token을 어떻게 보내야하나요?

1개의 답글