JWT로 로그인 및 로그인 유지하기(feat. Next JS)

김은호·2023년 5월 8일
5

JWT(Json Web Token)?

클라이언트와 서버 사이에 통신을 할 때 권한을 위해 사용하는 토큰이다. 암호화된 상태로 주고 받을 수 있다. 클라이언트가 토큰이 없으면 서버에서 거부하고, 토큰이 있다면 그 토큰의 종류에 따라 서버에서 적절한 응답을 줄 수 있다.

구성요소

크게 헤더, 페이로드, 시그니처 파트로 나뉜다.

  • 헤더: 어떠한 알고리즘으로 암호화 할 것인지, 어떠한 토큰을 사용할 것인지에 대한 정보
  • 페이로드: 전달하려는 정보(사용자 정보)가 담김.
  • 시그니처: 헤더와 페이로드를 합친 후 토큰을 발급하는 서버에서 만든 비밀 키로 인코딩된 정보가 담긴다. 페이로드가 변조되어도 시그니처 정보와 다르면 토큰이 손상되었는지 알 수 있어 보안성을 높인다.

사용하는 이유

모던 웹을 구성하는 HTTP 프로토콜은 Stateless(통신이 종료되면 요청한 사용자의 정보를 잃어버린다)를 지향하여 매 요청마다 다시 인증을 해야한다.

JWT 이전에는 사용자가 회원가입을 하면 사용자의 세션을 서버에 저장하고, 고유한 세션 ID를 쿠키에 담아 클라이언트에 보내준 후 클라이언트에서 매 요청마다 쿠키에 세션 ID를 담으면 서버에서 확인을 한 후 인가된 사용자면 적절한 응답을 보내주는 식으로 동작했다.

위 방법은 문제점이 있는데, 사용자가 많아질수록 세션의 양이 많아져 서버 부하가 커진다. 또 이를 해결하기 위해 추가 서버를 설치하려고 해도 동기화가 까다롭고 비용 측면에서도 부담이 된다.
-> Stateless 특성을 이용한 또다른 사용자 인증 알고리즘이 필요하다.

JWT는 사용자 정보를 서버에 저장할 필요없이 토큰 안에 담아 통신하므로 서버에 저장할 내용이 없다. 또한 고유 비밀키로 인코딩하므로 보안에 뛰어나고 변조가 되어도 쉽게 알아차릴 수 있다.

사용 방식

  1. 사용자가 로그인을 하면 서버에서 토큰을 발급하여 전달해준다.
  2. 사용자는 서버로 요청할 때마다 토큰을 함께 보내고, 서버는 토큰을 받아 디코딩하여 기간이 만료되었는지, 변조되었는지 여부를 확인한다.
  3. 토큰이 적절하지 않거나 없다면 사용자에게 다시 로그인을 하라고 알리고, 적절한 토큰이라면 사용자에 맞는 응답을 보내준다.

문제점

JWT는 Stateless 특성에 맞추어 만들어졌기 때문에 토큰의 상태를 저장하지 않는다. 그래서 페이로드에 저장된 데이터가 바뀌어도 서버와 동기화되지 않는 문제점이 있을 수 있고, 페이로드에 저장된 데이터가 탈취될 수 있는 위험성도 있다. 이를 위해 토큰의 만료 시간을 짧게하면 로그인을 자주 해야하므로 UX가 떨어지게 된다.

해결 - Refresh Token

사용자마다 고유한 Refresh Token을 Access Token과 함께 발급한다. Access Token에 사용자 정보를 담으며 만료 기간을 짧게(1~10분)두고 Refresh Token은 길게(1~2주 등)부여한다. Access Token이 만료되면 클라이언트에서 Refresh Token을 통해 새로운 Access Token을 발급하도록 서버에 요청한다.
발급을 할 때 Access Token은 response의 data에 넣어주고, Refresh Token은 헤더의 쿠키에 담아 브라우저로 보내준다.

이를 통해 헤더에서 지정한 암호화 알고리즘으로 새로운 Access Token이 매번 발급되어 보안을 높일 수 있다.

구현

// jwtUtils.ts
import jwt from 'jsonwebtoken';
const secret = process.env.JWT_SECRET!;

// access Token 발급
const sign = (userId: string) => {
  return jwt.sign({ id: userId }, secret, {
    algorithm: 'HS256', // 암호화 알고리즘
    expiresIn: '1h', // 유효기간
  });
};

// access Token 검증
const verify = (token: string) => {
  let decoded: any = null;
  try {
    decoded = jwt.verify(token, secret);
    return {
      ok: true,
      userId: decoded.id,
    };
  } catch (error: any) {
    return {
      ok: false,
      message: error.message,
    };
  }
};

// refresh Token 발급
const refresh = (userId: string) => {
  return jwt.sign({ id: userId }, secret, {
    algorithm: 'HS256',
    expiresIn: '14d', // 유효기간
  });
};

const refreshVerify = (token: string) => {
  try {
    jwt.verify(token, secret);
    return true;
  } catch (error) {
    return false;
  }
};

export { sign, verify, refresh, refreshVerify };
// /api/auth/signin.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import bcrypt from 'bcryptjs';
import Data from '@/utils/data';
import { UserType } from '@/types/type';
import { refresh, sign } from '@/utils/jwtUtils';
import { connect } from '@/utils/db/dbConnect';

export default async function name(req: NextApiRequest, res: NextApiResponse) {
  // mongoDB 연결
  connect();
  switch (req.method) {
    case 'POST':
      const { userId, password } = req.body;
      if (!userId || !password) {
        res.statusCode = 400;
        return res.send('아이디 또는 비밀번호를 입력해주세요.');
      }
      const userExist = await Data.user.findDB(userId);
      if (
        userExist.length === 0 ||
        !bcrypt.compareSync(password, userExist[0].password)
      ) {
        res.statusCode = 405;
        return res.send('존재하지 않는 계정입니다.');
      }

      // 토큰만들기
      const refreshToken = refresh(userId);
      const accessToken = sign(userId);
      // DB에 고유한 refreshToken 저장
      Data.user.updateDB(userId, refreshToken);

      // Refresh Token은 쿠키에 담아 보내줌
      res.setHeader(
        'Set-Cookie',
        `refreshToken=${refreshToken}; Path=/; Expires=${new Date(
          Date.now() + 60 * 60 * 24 * 1000 * 3,
        ).toUTCString()}; HttpOnly`,
      );

      res.statusCode = 200;
	
      // Access Token을 보내줌
      return res.send({ userId, accessToken });
  }
}
// api/auth/logout.ts

import type { NextApiRequest, NextApiResponse } from 'next';
import Data from '@/utils/data';
import { connect } from '@/utils/db/dbConnect';

export default async function name(req: NextApiRequest, res: NextApiResponse) {
  connect();
  switch (req.method) {
    case 'POST':
      try {
        const { refreshToken } = req.cookies;
        res.statusCode = 200;
        // 만료 시간을 현재 시간보다 빠르게 해서 만료되도록 하여 쿠키 삭제
        // HttpOnly라 브라우저에서 임의로 쿠키 조작 불가 -> 서버에서 설정
        res.setHeader(
          'Set-Cookie',
          `refreshToken=; Path=/; Expires=${new Date(
            Date.now() - 1,
          ).toUTCString()}; HttpOnly`,
        );
        if (refreshToken) {
          await Data.user.updateDBByToken(refreshToken);
          return res.send('로그아웃 완료');
        }
        return res.send('이미 로그아웃 되었습니다.');
      } catch (error) {
        res.statusCode = 405;
        return res.send(error);
      }
  }
}
// api/auth/token.ts, Token 재발급 API

import type { NextApiRequest, NextApiResponse } from 'next';
import Data from '@/utils/data';
import { connect } from '@/utils/db/dbConnect';
import { refresh, refreshVerify, sign } from '@/utils/jwtUtils';

export default async function name(req: NextApiRequest, res: NextApiResponse) {
  connect();
  switch (req.method) {
    case 'GET':
      try {
        let refreshToken = req.headers.cookie;
        if (refreshToken) {
          const user = await Data.user.findDBByToken(refreshToken);
          const userId = user[0].userId;
          // access Token 재발급
          const accessToken = sign(userId);
          if (!refreshVerify(refreshToken)) {
            // 만료된 경우
            refreshToken = refresh(userId);
            Data.user.updateDB(userId, refreshToken);
            res.setHeader(
              'Set-Cookie',
              `refreshToken=${refreshToken}; Path=/; Expires=${new Date(
                Date.now() + 60 * 60 * 24 * 1000 * 3,
              ).toUTCString()}; HttpOnly`,
            );
          }
          return res.send({ userId, accessToken });
        }
        return res.send('로그인 해주세요!');
      } catch (error) {
        res.statusCode = 405;
        return res.send(error);
      }
  }
}

로그인 유지

Access Token을 받아와 state로 유지하도록 구현했다. 따라서 새로고침을 하여 state가 날라가는 경우의 처리가 필요하다.

export const getServerSideProps = async (ctx: any) => {
  const { req } = ctx;
  let data = req.cookies.refreshToken;
  if (data === undefined) {
    // 아직 로그인을 안한 경우
    data = 'error';
    return { props: { data } };
  } else {
    // 로그인을 하고 새로고침을 한 경우
    const result = await getAccessToken(data);
    return { props: result.data };
  }
};

getServerSideProps로 새로고침을 하면 서버에서 쿠키의 존재 여부를 보고 Access Token을 발급받아 Props로 넘겨주어 로그인이 유지되도록 한다.

Access Token의 만료 시간이 다가오는데 로그인을 하고 있는 경우 미리 Access Token을 발급받아 로그인을 유지하는 처리도 필요하다.

  // 30분마다 access Token 재발급
  const onSilentRefresh = async () => {
    const result = await getAccessTokenInClient();
    const accessToken = result.data.accessToken;
    try {
      setData((prev) => {
        return {
          ...prev,
          accessToken,
        };
      });
      setTimeout(onSilentRefresh, 600000 - 10000);
    } catch (error) {
      console.error(error);
    }
  };

Access Token의 만료 기간이 10분일 때, setTimeout을 통해 재귀로 동작하게 하여 만료 기간 10초 전에 미리 발급받도록 하였다.

0개의 댓글