[Next 프로젝트] JWT, 리프레시 토큰 로그인 구현

원지·2023년 9월 19일
0

넥스트 프로젝트

목록 보기
1/3

프로젝트를 진행하던 중 로그인 담당자가 하차해 미완성인 로그인 파트를 마무리하게 되었습니다 !
코드를 수정하며 겪은 시행착오와 공부한 것들을 기록해보겠습니다 😀

🔑 JWT + 리프레시 토큰


이미지 출처 https://tansfil.tistory.com/58

저희 프로젝트는 JWT 기반의 리프레시 토큰 방식으로 로그인을 구현했습니다. JWT 인증 방식은 Token의 유효기간을 길게 설정할 경우 보안에 취약하고, 짧게 설정할 경우 사용자가 자주 로그인을 해야하기 때문에 불편합니다. 따라서 유효기간은 짧게 설정하되 엑세스 토큰이 만료되면 리프레시 토큰으로 새 엑세스 토큰을 받아 로그인을 유지할 수 있는 "jwt + Refresh Token"으로 로그인을 구현했습니다.

📌 뭐가 문제일까?

if (status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const state = store.getState();
        const refreshToken = state.user.refreshToken;

        const res = await axios.post(
          `${process.env.NEXT_PUBLIC_API_KEY}/member`,
          {},
          {
            headers: {
              AuthorizationRefresh: `Bearer ${refreshToken}`,
            },
          },
        );

        const newToken = res.headers.Authorization;

        originalRequest.headers.Authorization = `Bearer ${newToken}`;

        return request(originalRequest);
      } catch (err) {
        return Promise.reject(err);
      }
    }

    return Promise.reject(error);
  },
);

위 코드는 로그인 담당자가 작성한 axios 인터셉터입니다.

엑세스 토큰이 만료돼 401 에러가 발생한 경우, 리프레시 토큰으로 새 엑세스 토큰을 발급 받습니다. 이때 요청을 보낸 리프레시 토큰은 만료되고 새로운 엑세스 토큰과 리프레시 토큰이 재발급됩니다. 하지만 전역에서 관리되고 있는 토큰의 state값은 업데이트하지 않았고, 만료된 토큰으로 계속해서 api 요청을 보내 오류가 발생했습니다.

🔎 토큰을 어떻게 관리하면 좋을까 ?

만료된 토큰값을 업데이트해 문제를 해결하고 나니 의문이 들었습니다. 저희 프로젝트는 로그인 한 유저의 정보를 리덕스 툴킷으로 관리하며 이 값을 모두 로컬스토리지에 저장하고 있었습니다.

interface UserState {
  	socialId: string;
  	email: string;
  	nickname: string;
  	point: number;
  	grade: string;
  	role: string;
  	cumulativeAmount: number;
  	memberCoupon: number;
  	accessToken: string;
  	refreshToken: string;
}

유저 정보에는 위와 같이 엑세스 토큰과 리프레시 토큰이 포함되어 있습니다. 따라서 로컬스토리지에 이 모든 값이 저장되고 있는 상황인데 .. "JWT 인증 방식이 보안에 취약하기 때문에 리프레시 토큰을 발급 받는 것 아니었나? 엑세스 토큰을 로컬스토리지에 저장하며 로그인을 유지하는 것이 맞나?" 의구심이 들었습니다.

결과적으로 이 방법은 보안에 매우 취약하며 리프레시 토큰을 쓰는 것이 무의미한 방식이라 생각했고, 전체적인 로직을 수정했습니다.

📝 해결 방법

제가 생각한 해결 방식은 다음과 같았습니다.

  1. 로그인의 응답값으로 받은 엑세스 토큰과 리프레시 토큰은 유저 정보와 분리해서 관리한다.

  2. 엑세스 토큰은 메모리에 저장하고 리프레시 토큰은 쿠키 혹은 로컬스토리지에 저장한다.

import { Cookies } from 'react-cookie';

const cookies = new Cookies();

export const setCookie = (name: string, value: string) => {
  return cookies.set(name, value, { path: '/' });
};

export const getCookie = (name: string) => {
  return cookies.get(name);
};

export const removeCookie = (name: string) => {
  return cookies.remove(name, { path: '/' });
};

기존 redux-toolkit으로 관리되고 있던 userState에서 리프레시 토큰과 엑세스 토큰을 제외한 뒤, 리프레시 토큰을 쿠키에 저장했습니다.

사실 처음엔 익숙한 로컬스토리지에 리프레시 토큰을 저장할까 했는데 HttpOnly 쿠키로 보안을 강화하고, next.js의 cookies 익스텐션으로 로그인 여부에 따른 분기 처리를 하기 위해 쿠키에 저장하는 방법을 선택했습니다.

📌 쿠키 경로 설정 옵션에서 주의할 점

cookies.set(name, value, { path: '/' });

react-cookie 라이브러리에서 쿠키를 설정할 땐 cookies.set(쿠키 이름, 쿠키 값, 옵션) 방식으로 값을 지정하며, 옵션 path는 쿠키의 경로를 설정합니다.

path는 이 경로에 해당하는 URL에서만 쿠키가 전송됩니다. 기본값은 "/"이며, 이 경우 모든 경로에서 쿠키가 사용됩니다.

저는 기본값이 “/”로 되어 있어서 set할 때는 path를 "/"로 추가했지만 remove에서는 옵션 설정을 따로 안 했는데 (도대체 왜 ...) 가끔씩 쿠키가 삭제되지 않는 오류가 발생했습니다. 구글링 해보니 저와 같은 경우(https://github.com/bendotcodes/cookies/issues/346)가 있어 코드를 수정할 수 있었는데요 .. 쿠키를 설정한 경로와 삭제하는 경로를 일치시키지 않아 발생한 문제였습니다. 여러분은 꼭 ! 세팅할 때와 삭제할 때 동일한 옵션을 설정해 저와 같은 문제는 겪지 마세요 ..

💡 완성 코드

request.interceptors.request.use(
  async config => {
    const { accessToken, refreshToken } = getTokens();

    if (!refreshToken) {
      window.location.href = 'https://shop.vercel.app/login';
      return config;
    }

    if (!accessToken) {
      return await renewTokens().then(tokens => {
        config.headers['Authorization'] = `Bearer ${tokens.accessToken}`;
        return config;
      });
    }

    config.headers['Authorization'] = `Bearer ${accessToken}`;
    return config;
  },
  error => {
    console.error(error);
    return Promise.reject(error);
  },
);

최종적으로 수정된 인터셉터입니다.

api 요청을 보낼 때, 리프레시 토큰이 없는 경우 로그인 화면으로 이동합니다. 엑세스 토큰은 메모리에 저장하기 때문에 페이지 이동, 새로고침 등으로 토큰이 없어질 때 리프레시 토큰으로 새 엑세스 토큰을 발급받습니다.

function middleware(request: NextRequest, event: NextFetchEvent) {
  const refreshToken = request.cookies.get('refreshToken')?.value;
  const pathname = request.nextUrl.pathname;

  if (refreshToken) {
    if (pathname === '/login') {
      return NextResponse.redirect(
        new URL('/?alert=이미 로그인한 사용자입니다.', request.url),
      );
    }
    if (pathname === '/register') {
      return NextResponse.redirect(
        new URL('/?alert=이미 가입된 회원입니다.', request.url),
      );
    }
  }

  if (!refreshToken && pathname.startsWith('/mypage')) {
    return NextResponse.redirect(
      new URL('/?alert=로그인 후 이용 가능한 서비스입니다.', request.url),
    );
  }
}

그리고 로컬스토리지의 엑세스 토큰 여부로 로그인 분기를 나누며 발생했던 문제(회원만 접근할 수 있는 페이지의 경우, 페이지 접근 자체를 막아야하나 페이지로 이동한 뒤 리다이렉트하는 오류 발생)를 해결하기 위해 미들웨어에서는 쿠키에 저장된 리프레시 토큰의 유무에 따라 로그인 여부를 판단할 수 있도록 수정했습니다.

🕞 아쉽거나 궁금한 .. 미래의 내가 해결해야 할 부분

나름 공부하며 .. 코드를 수정하긴 했는데 엑세스토큰을 발급 받기 위해 너무 많은 api 요청을 보내는 게 오히려 자원을 낭비하고 있는건가 ? 다른 더 좋은 방법이 있을까 ? 의문이 들기도 하고 .. 미들웨어에서 리프레시 토큰 유무에 따라 분기처리를 한 게 신경쓰이기도 합니다 .. 엄밀히 따지자면 로그인 유무를 판단하는 건 엑세스 토큰이어야 하는 거 아닌가 ? 이 로직 잘못됐나 ? 고민이 되는데 .. 일단 지금으로서는 최선의 방법이었다네요 .. 실무에서는 어떤 방법으로 로그인을 유지하는지 궁금한데 미래의 내가 이 고민에 대한 해결책을 제시해주기를 ! ㅎㅎ

0개의 댓글