개발 이야기/React & Next

[Next.js/JWT] Next.js 13 환경에서 권한/인증로직 구현하기

Heman 2023. 12. 17. 21:52

[Next.js/JWT] Next.js 13 환경에서 권한/인증로직 구현하기

얼마전에 시작한 사이드 프로젝트의 환경을 Next.js 13 버전으로 적용하기로 했습니다.
프로젝트의 가장 기초는 로그인/회원가입 그리고 인증처리인데요. 제가 어떤 방법으로 Next.js 13 버전에서 인증로직을 작성했는지 여러분들께 공유하고자 합니다.

 

 

cookies-next

JWT를 사용할때, 쿠키에서 관리를 하는경우가 많은데요. 이를 위해 서버사이드 랜더링이 되는 next.js 에서는 cookies-next 라이브러리를 사용합니다.

처음 프로젝트를 세팅할 때 생각없이 react-cookie 라이브러리를 사용했었는데, 로그인 후 토큰을 쿠키에 저장하고 메인 페이지로 리다이렉트 하니 제대로 로직이 동작하지 않았습니다 ㅠㅠ

메인 페이지에서 console 을 찍어보니 쿠키가 undefined 였는데요.

같은 도메인이더라도 로그인 시 로그인 페이지에만 토큰이 쿠키에 저장되고, 메인 페이지에는 쿠키가 담기지 않았습니다. 그러니 저처럼 삽질하지 마시고 여러분들은 next.js 13 에서는 cookies-next 쓰시길!

 

사용법도 쉽답니다 ㅎㅎ

 

 

이제 얼른 본론으로 가봅시다!

 

 

권한처리 어떻게 해요?

기본적인 논리구조는 간단합니다.

 

로그인/회원가입 페이지에 로그인한 유저가 있다면 프로젝트의 메인인 List 페이지의 주소로 리다이렉트 합니다.

제 프로젝트에는 루트 주소인 " / " 페이지는 존재하지 않기 때문에 " / " 에 접근하는 유저는 로그인 페이지로 리다이렉트 됩니다.

그리고 권한이 없거나 로그인하지 않은 유저가 그 외의 페이지로 접근한다면, 서버에서 유저의 권한을 확인 후 에러를 반환하며 로그인 페이지로 리다이렉트 시킵니다.

 

위 로직을 따르기 위해서는 접근하는 유저의 로그인 여부를 우선 판별해야합니다.

access 토큰의 유효성을 확인하는 절차가 필요하겠죠?

 

access 토큰의 유효성 확인은 페이지에 접근할 때, 혹은 어떤 액션을 수행할 때 수시로 이루어져야합니다.

모든 페이지나 액션에 동일한 api 를 호출하는 코드를 작성하기에는 코드의 중복이 너무 많습니다. 이런 상황에서 일반적으로 사용되는 방식이 interceptor를 사용하는 것이죠!

 

 

requester.ts

저는 interceptor를 사용하는 requester와, 사용하지 않는 defaultRequester 두가지를 작성했습니다.

 

interceptor를 사용하는 requester에서는 api 호출 후 발생하는 에러를 확인합니다.

access 토큰이 만료되어 서버에서 권한 에러 "401"을 내려주면, refresh 토큰으로 access 토큰을 발급해주는 api 를 호출하고 응답으로 받아온 토큰들을 쿠키에 저장합니다. 그 후 이전에 실행했던 유저 액션인 originalRequest 를 반환합니다.

 

이 로직으로 유저가 로그인하지 않았거나, 권한이 없을때 최종적으로 에러가 반환됩니다.
물론 성공적으로 재발급 받는다면 문제가 되지 않겠죠!

 

import axios, { CreateAxiosDefaults, ParamsSerializerOptions } from 'axios';
import AuthApi from '@/apis/AuthApi';
import { stringify } from 'qs';
import { ACCESS_TOKEN_TITLE, REFRESH_TOKEN_TITLE } from '@/constants/common';
import { getCookie, setCookie, deleteCookie } from 'cookies-next';

let isRefreshing = false;

const TIME_OUT = 1000 * 120;
const UNAUTHORIZED = 401;
const STALE_REFRESH_TOKEN = 4108;

const accessToken = getCookie(ACCESS_TOKEN_TITLE);

const axiosDefault: CreateAxiosDefaults<any> = {
  baseURL:
    process.env.NEXT_PUBLIC_NODE_ENV === 'development'
      ? process.env.NEXT_PUBLIC_DAEHWA_URL_DEV
      : process.env.NEXT_PUBLIC_DAEHWA_URL_PROD,

  timeout: TIME_OUT,
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json',
  },
  paramsSerializer: {
    serialize: stringify,
  } as ParamsSerializerOptions,
};

const defaultRequester = axios.create(axiosDefault);
const requester = axios.create({
  ...axiosDefault,
  headers: {
    ...axiosDefault.headers,
    Authorization: accessToken,
  },
});

requester.interceptors.response.use(
  (response) => response,
  (error) => {
    const originalRequest = error.config;

    if (
      error.response &&
      error.response?.status === UNAUTHORIZED &&
      !originalRequest._retry &&
      !isRefreshing
    ) {
      const refreshToken = getCookie(REFRESH_TOKEN_TITLE) as string;

      isRefreshing = true;

      return AuthApi.refresh({ refreshToken })
        .then((response) => {
          originalRequest._retry = true;

          const { result } = response.data;
          const newAccessToken = result?.accessToken;
          const newRefreshToken = result?.refreshToken;

          setCookie(ACCESS_TOKEN_TITLE, newAccessToken);
          setCookie(REFRESH_TOKEN_TITLE, newRefreshToken);

          return axios({
            ...originalRequest,
            headers: {
              ...axiosDefault.headers,
              Authorization: newAccessToken,
            },
          });
        })
        .catch((error) => {
          const { data } = error.response;

          if (data?.status?.code === STALE_REFRESH_TOKEN) {
            deleteCookie(REFRESH_TOKEN_TITLE);
            deleteCookie(ACCESS_TOKEN_TITLE);

            window.alert(data?.status?.message || '만료된 세션입니다.');
            window.location.assign('/session-expired');
          } else {
            const { status, data } = error.response;

            window.alert(
              `${status || data?.status?.code} ${
                data?.status?.message || '에러'
              }`,
            );
          }

          return Promise.reject(error);
        })
        .finally(() => {
          isRefreshing = false;
        });
    }

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

export { defaultRequester, requester };

 

 

그런데 위 로직은 api 호출시에만 토큰의 만료 여부를 확인할 수 있습니다.

그렇다면 api 호출이 없는 페이지의 클라이언트 화면에서는 어떻게 권한처리를 할 수 있을까요?

 

일단 서버 개발자에게 달려가서 토큰 유효성 검사를 해주는 api를 내놓으라고 윽박지릅시다!

일단 토큰 유효성 검사를 하는 api가 필요합니다!

 

저는 react-query 라이브러리로 해당 api를 호출하는 useAuthMe() 라는 커스텀 훅을 만들었습니다.

api 를 통한 access 토큰 유효성 검사에 실패하면 로그인 페이지로 보내는 간단한 로직입니다!

 

export const useAuthMe = () => {
  const queryKey = ['auth-me'];
  const queryFn = () => AuthApi.me();

  return useQuery(queryKey, queryFn, {
    staleTime: Infinity,
    suspense: true,
    useErrorBoundary: false,
    enabled: false,
    onSuccess: () => {},
    onError: () => {
      window.location.assign(SIGN_IN_PATH);
    },
  });
};

 

 

이제 이 커스텀 훅을 사용하는 컴포넌트를 작성해봅시다~

 

 

app/layout.tsx

next.js 이전 버전과 비교하여 13 버전에서 가장 큰 변화 중 하나로 app 디렉토리의 layout.tsx 파일이 있죠!

저는 페이지의 권한을 체크하는 AuthWrapper 라는 컴포넌트를 만들어서 children을 감쌌습니다.

 

이 AuthWrapper 컴포넌트에서 access 토큰의 유효성 검사와 추가적인 권한 확인을 할 것입니다.

 

export default function RootLayout({ children }: Props) {
  return (
    <html lang="ko">
      <body>
        <ErrorBoundary>
          <QueryProvider>
            <AuthWrapper>{children}</AuthWrapper>
          </QueryProvider>
        </ErrorBoundary>
      </body>
    </html>
  );
}

 

 

AuthWrapper.tsx

AuthWrapper 컴포넌트에서는 주로 라우트 주소를 기준으로 권한에따라 리다이렉트 처리를 담당합니다..!

 

만약 useAuthMe() 훅의 유효성 확인 api에서 에러를 반환하거나 access 토큰을 확인할 수 없다면, 모든 토큰을 삭제합니다. 그리고 현재 유저가 머물러 있는 페이지에 따라 대응이 달라지는데요.

위 상황에서는 모든 토큰이 삭제 되었으니 로그인/회원가입 페이지가 아니라면, 기본적으로 로그인 페이지로 리다이렉트 됩니다.

 

반대로 로그인한 유저가 로그인/회원가입 등의 페이지에 있다면 List 페이지로 리다이렉트 되며,

위의 모든 조건을 통과하면 useQuery의 refetch() 함수로 한번 더 검사를 하고 로직은 종료됩니다.

 

이 컴포넌트로 페이지들을 감싸주면 useEffect에 의해 컴포넌트의 mount 시점에 권한 검사를 할 수 있습니다.

 

코드는 아래와 같습니다!

 

const PUBLIC_ROUTES = [SIGN_IN_PATH, SIGN_UP_PATH];
const ROOT = '/';

const removeAllCookies = () => {
  ...
};

export default function AuthWrapper({ children }: Props) {
  const { error, refetch } = useAuthMe();

  useEffect(() => {
    (async () => {
      const currentPath = window.location.pathname;
      const accessToken = getCookie(ACCESS_TOKEN_TITLE);

      if (error || !accessToken) {
        removeAllCookies();
      }

      if (error || ROOT.includes(currentPath)) {
        window.location.assign(SIGN_IN_PATH);
        return;
      }

      if (PUBLIC_ROUTES.includes(currentPath)) {
        if (!accessToken) {
          return;
        }

        await refetch();

        window.location.assign(LIST);
        return;
      }

      await refetch();

      return;
    })();
  }, []);

  return error ? null : children;
}

 

 

마무리하며

이 프로젝트에서는 기본적이 로그인여부나 access 토큰의 여부에 따라 권한을 처리하는 로직만을 작성해보았는데요.
실무에서는 더 다양한 권한 처리 방법과 로직이 존재한답니다! 예를 들면 유저가 가진 권한에 따라 볼 수 있는 컴포넌트가 달라진다거나, 보이는 페이지 자체가 다를 수도 있겠지요 ㅎㅎ

이번 사이드 프로젝트는 어드민 권한은 필요하지 않을 것 같아 간단하게 작성할 수 있었어요. 고민이 되는 부분은 서버사이드 랜더링 시, 권한 검사를 화면이 랜더링 되기 전에 수행하고 싶은데, 아직 next.js 13버전에 미숙하다보니 시련을 겪는 중입니다 ㅋㅋ

좋은 팁을 알고 계신 분들은 언제든 공유 부탁드립니다~! ㅋ.ㅋ