티스토리 뷰

nodejs

express에서 jwt로 인증하기

안양사람 2021. 11. 10. 22:53
728x90
SMALL

배경

우리가 웹페이지에서 로그인을 하면 새로고침을 하더라도 로그인이 유지가 된다. 이걸 가능하게 하는 가장 대표적인 기술 두가지는 jwt와 세션 쿠키 방식이다. 물론 두가지 방식을 같이 사용하는 경우도 있다. 나는 그동안 passport를 이용한 세션 쿠키 방식을 많이 사용해왔다. 우아한테크캠프 4번째 프로젝트 때 거의 처음으로 jwt를 팀원을 통해 배웠고 그때 완벽하게 이해하지 못했다고 생각해서 조금 신경써서 이번에 공부했다.

 

jwt란

jwt는 JsonWebToken의 약자다. 간단히 말하면 암호화된 데이터다. header, payload, signature 세부분으로 나뉘어지며 header에는 토큰 타입과 어느 알고리즘을 사용할지, payload에는 데이터, signature에는 비밀키가 있다. 프론트에서 암호화된 토큰을 서버로 전송하고 서버에서 decode해서 정보를 읽는 것이다.

 

왜 필요한데??

원래 사용하던 session, cookie 방식에서 jwt 방식이 생긴 이유가 무엇일까?? session cookie를 간단히 설명하자면 쿠키에는 세션 id가 저장되어 있고 프론트에서 서버로 쿠키를 보내고 서버에서 데이터베이스에 접근해 세션id로 필요한 데이터를 꺼낸다. 이 방식의 문제점이라고 한다면 데이터베이스에 접근하는 것이 문제점이다. 데이터베이스는 확장하기가 쉽지 않다. 서버의 용량이라던가 이런 부분은 비교적 쉽게 확장할 수 있는데 db는 진짜 쉽지 않다. db를 여러개로 나눠서 상황에 따라 다른 정보를 얻어온다고 생각해보자. 솔직히 나는 이 부분에 대해 감도 안온다. 조금 흥미가 생긴다면 아래와 같은 글을 읽어보자

https://d2.naver.com/helloworld/551588

 

어쨋든... 그래서 db에 접근을 최소화하는 것이 좋다. jwt를 이용하면 db에 접근하지 않는다. 당연히 보안상으로는 조금 떨어진다. 각자 장단점이 있다. 

 

만들어보기

jwt 유틸함수

import logger from '@/config/logger';
import { TOKENEXPIREDERROR } from '@/constants';
import jwt, { JwtPayload, VerifyErrors } from 'jsonwebtoken';

type TokenType = 'access' | 'refresh';

interface ITokenOption {
  id: string;
  nickname: string;
}

const getExp = (tokenType: TokenType): number => {
  const ACCESS_TOKEN_EXPIRE_DATE = Math.floor(Date.now() / 1000) + 60 * 30; // 30분
  const REFRESH_TOKEN_EXPIRE_DATE = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 7; // 7일
  return tokenType === 'access' ? ACCESS_TOKEN_EXPIRE_DATE : REFRESH_TOKEN_EXPIRE_DATE;
};

const getSecret = (tokenType: TokenType): string => {
  const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET || 'access';
  const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh';
  return tokenType === 'access' ? ACCESS_TOKEN_SECRET : REFRESH_TOKEN_SECRET;
};

const createToken = (tokenType: TokenType, option: ITokenOption): string => {
  const exp = getExp(tokenType);
  const secret = getSecret(tokenType);
  const token = jwt.sign({ exp, ...option }, secret);
  return token;
};

const decodeToken = (tokenType: TokenType, token: string): Promise<JwtPayload> => {
  return new Promise(resolve => {
    const secret = getSecret(tokenType);
    jwt.verify(token, secret, (err: VerifyErrors | null, decoded) => {
      if (err) {
        if (err.name !== TOKENEXPIREDERROR) {
          logger.info(err);
        }
        resolve({ jwtError: err.name });
      }
      if (typeof decoded !== 'object') {
        const jwtError = 'token is not a object';
        logger.info(jwtError);
        resolve({ jwtError });
      }
      resolve(decoded as JwtPayload);
    });
  });
};

const getAccessToken = (authorization: string | undefined): string | undefined => {
  return authorization?.split('Bearer ')[1];
};

const getRefreshToken = (cookies: { refreshtoken: string | undefined }): string | undefined => {
  return cookies?.refreshtoken;
};

export { createToken, decodeToken, getAccessToken, getRefreshToken };

 

코드 자체는 어렵지 않다. 

getExp에서 만료시간을 return한다. 바로 return하는 것이 메모리상으로 더 효율적이지만 가독성을 생각해서 함수를 만들었다. getSecret은 secret을 가져오는 함수다.

위 두가지 함수를 이용해서 createToken 함수를 만들었다. jwt.sign의 인자에 payload와 secret을 넣어준다. 

decodeToken을 보면 jwt.verify를 이용해서 검증하고 에러가 아닐시에 decode한다. logger는 그냥 로깅하는건데 신경쓰지 않아도 된다. 궁금하다면 바로 밑에 글 winston으로...

그리고 getAccessToken은 header의 authorization의 bearer 다음부분으로 가져온다. 이건 그냥 약속이다. 그리고 refreshtoken은 쿠키에서 가져온다. 하나만 사용해도 상관없지만 보안을 위해 두 가지를 사용했다.

 

controller

import { NextFunction, Request, Response } from 'express';
import AuthService from '@/services/auth-service';
import { refreshTokenCookieOptions } from '@/constants';

const service = new AuthService();
const REFRESHTOKEN = 'refreshtoken';

class AuthController {
  async signup(req: Request, res: Response, next: NextFunction) {
    try {
      const { email, nickname, password } = req.body;
      const { accessToken, refreshToken } = await service.signup(email, nickname, password);
      res.cookie(REFRESHTOKEN, refreshToken, refreshTokenCookieOptions);
      res.status(201).json({ accessToken });
    } catch (err) {
      next(err);
    }
  }

  async login(req: Request, res: Response, next: NextFunction) {
    try {
      const { email, password } = req.body;
      const { accessToken, refreshToken } = await service.login(email, password);
      res.cookie(REFRESHTOKEN, refreshToken, refreshTokenCookieOptions);
      res.status(200).json({ accessToken });
    } catch (err) {
      next(err);
    }
  }

mvc패턴에서 컨트롤러 부분이다. signup부분을 보면 refreshtoken을 쿠키에 저장하고 accesstoken을 프론트에 넘겨준다. 그리고 프론트에서는 accesstoken을 로컬 스토리지에 저장한다. service계층에서 토큰값을 얻어오는 코드가 있는데 위에서 만든 유틸함수에서 createToken을 이용하면 된다.

 

jwt middleware

import { NextFunction, Request, Response } from 'express';
import { createToken, decodeToken, getAccessToken, getRefreshToken } from '@/utils/jwt';
import { REFRESHTOKEN, refreshTokenCookieOptions, TOKENEXPIREDERROR } from '@/constants';

const jwtMiddleware = async (req: Request, res: Response, next: NextFunction) => {
  const accessToken = getAccessToken(req.headers.authorization);
  const refreshToken = getRefreshToken(req.cookies);

  if (!accessToken || !refreshToken) {
    return next();
  }

  const { id: aId, nickname: aNickname, jwtError: aError } = await decodeToken('access', accessToken as string);
  const {
    exp: rExp,
    id: rId,
    nickname: rNickname,
    jwtError: rError,
  } = await decodeToken('refresh', refreshToken as string);

  if (aError || rError) {
    if (aError === TOKENEXPIREDERROR && !rError) {
      const newAccessToken = createToken('access', { id: rId, nickname: rNickname });
      return res.status(200).json({ requestAgain: true, newAccessToken });
    }
    if (rError === TOKENEXPIREDERROR) {
      res.clearCookie(REFRESHTOKEN);
      return res.status(200).json({ expiredRefreshToken: true });
    }
    return next();
  }

  if (aId !== rId || aNickname !== rNickname) {
    return next();
  }

  const [id, nickname] = [aId, aNickname];
  req.user = { id, nickname };

  const now = Math.floor(Date.now() / 1000);

  if ((rExp as number) - now < 60 * 60 * 24 * 3.5) {
    // 3.5일
    const newRefreshToken = createToken('refresh', { id, nickname });
    res.cookie(REFRESHTOKEN, newRefreshToken, refreshTokenCookieOptions);
  }
  next();
};

export default jwtMiddleware;

passport를 이용할때는 내부적으로 req.user에 데이터를 넣어서 next()로 넘겨줬다. 이와 똑같은 역할을 하게 만들었다.

어렵지 않으니 위에서부터 차근차근 살펴보자.

먼저 accessToken과 refreshToken이 둘중 하나라도 없으면 next로 바로 넘겨준다.

그리고 두가지 토큰 모두 decode한다. 만약 accessToken이 만료되었고 refreshToken 에러가 없다면 requestAgain과 새로운 accessToken을 보내준다. 그래서 프론트에서 requestAgain이 true라면 로컬에 accessToken을 새로 세팅하고 다시한번 같은 api에 요청을 보낸다.

그리고 refershtoken이 만료됬다면 쿠키를 없애고 위와 비슷하게 처리해준다. 

두 가지 경우 모두 아닌데 에러라면 그냥 next로 넘겨준다.

이제 req.user에 원하는 데이터를 저장한다. 그리고 3.5일보다 refershtoken의 만료시간이 적게 남았다면 새로 토큰을 만들어준다.

 

constants/index.ts(참고용)

const REFRESHTOKEN = 'refreshtoken';

const TOKENEXPIREDERROR = 'TokenExpiredError';

const refreshTokenCookieOptions = {
  httpOnly: true,
  expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7일
};

export { REFRESHTOKEN, TOKENEXPIREDERROR, refreshTokenCookieOptions };

 

axios 모듈

import axios from 'axios';

const client = axios.create();

client.defaults.baseURL = process.env.NODE_ENV === 'development' ? '/' : `http://${window.location.hostname}:3000`;
client.defaults.withCredentials = true;

const requestEvent = new CustomEvent('request');
const requestEndEvent = new CustomEvent('request-end');

async function request(method, url, body, multipart) {
  window.dispatchEvent(requestEvent);
  const headerOption = multipart && { 'Content-Type': 'multipart/form-data' };
  try {
    const accessToken = window.localStorage.getItem('user') || '';
    const res = await client({
      method,
      url,
      headers: {
        Authorization: `Bearer ${accessToken}`,
        ...headerOption,
      },
      ...(body && { data: body }),
    });

    if (res.data.requestAgain) {
      const { newAccessToken } = res.data;
      if (newAccessToken) {
        window.localStorage.setItem('user', newAccessToken);
      }

      const newResult = await request(method, url, body, multipart);
      return newResult;
    }

    if (res.data.expiredRefreshToken) {
      // eslint-disable-next-line no-alert
      alert('토큰 기간이 만료되었습니다. 다시 로그인 해주세요');
    }

    return res;
  } catch (err) {
    if (axios.isAxiosError(err)) {
      if (err.response && err.response.status === 401) {
        window.localStorage.removeItem('user');
        window.location.href = '/';
      }
    }
    throw err;
  } finally {
    window.dispatchEvent(requestEndEvent);
  }
}

export default request;

이제 마지막으로 프론트에서 처리를 하면 끝이다

조금 불필요한 부분도 있는데 신경써야될 부분은 headers의 authorization에 토큰을 넣는 부분과 request again, expiredRefreshToken부분이다. 위에서 이미 설명했지만 requestAgain일때는 새로운 accesstoken을 저장하고 다시한번 요청한다. 그리고 expiredRefreshToken일때는 그냥 원하는 처리를 해주면 된다. 사실 alert를 사용하는 것은 좋지 않다. toast ui나 커스텀 모달창을 이용하는 것을 추천한다. 그리고 catch문에서 401이라면 user의 로컬값 즉 accessToken을 삭제하면 된다. 

 

후기

쓰다보니 조금 횡설수설한것같다. 면접이나 과제 테스트, 포트폴리오를 볼 때도 인증부분은 조금 빡세게 본다는 얘기를 들은적이 있다. 그만큼 중요한 부분이다. 이번에 조금 제대로 jwt를 공부한 것 같아서 기분은 나쁘지 않다. 

728x90
LIST
댓글
공지사항