NextJS에서 JWT 관리하기 (NextJS API Routes 활용)

eeeyooon·2024년 4월 19일
5
post-thumbnail

🌼 해당 글은 다음 스펙을 기준으로 작성되었습니다.

  • NextJS v14.1.0
  • pages router
  • typescript

NextJS에서 JWT 관리하기

새로 참여한 프로젝트에서 맡게 된 파트 중에 "회원가입 / 로그인"이 있었다. 우리는 구글 로그인만 사용하기로 했고, 구글 소셜 로그인 구현은 백엔드에서 진행하기로 했다. 프론트엔드에선 그저 로그인 성공 시 넘겨주는 accessTokenrefreshToken을 가지고 있다가, API 요청을 보낼 때 headersaccessToken을 넘겨주면 된다고 했다. 사실 무슨 말인지 이해는 잘 못했지만 어렵게 느껴지진 않았다. ㅎㅎ 할만할 듯?
그러나 막상 작업에 들어가자 이 파트를 맡은 걸 매우 후회했다 ··· (생략)


나의 목표 📝

  • 유저가 "구글로 로그인하기"를 클릭하면 구글 OAuth URL로 리디렉션 시킨다.
  • 로그인 성공 시 프론트엔드 url로 리디렉션되고, 해당 url에서 파라미터로 accessToken, refreshToken, hasInfo (회원가입 / 로그인을 구분하기 위한 boolean 값)을 받는다.
  • accessTokenrefreshToken을 쿠키로 관리한다.
  • 이 작업이 끝나면 "/home"으로 리디렉션시킨다.
  • accessToken이 만료되면 refreshToken을 가지고 새로운 accessToken을 발급 받는다.
  • 로그아웃 시 accessTokenrefreshToken을 쿠키에서 제거한다.

JWT가 뭔데?

(프론트엔드에서 로그인을 처리하기 전에, 꼭 이 글을 보는 걸 추천한다. 프론트엔드에서의 로그인, JWT 관리 등을 검색해봤다면 무조건 봤을 글이라고 생각하지만 꼭 읽어보길 추천한다. )


JWT는 유저의 신원이나 권한을 결정하는 정보를 담고 있는 데이터 조각이다. 유저가 로그인을 할 때 서버가 인증 정보를 보내주는데, 이때 암호화나 시그니처 추가가 가능한 데이터 패키지 안에 인증 정보를 담아 준다. 이 패키지가 JSON Web Token 즉, JWT이다. 이 안에 담기는 정보 중 accessTokenreferhsToken이 유저 인증에 사용되는데, 이 정보를 클라이언트에 저장한다.

이런 JWT 인증방식은 비밀키로 암호화를 하기 때문에 클라이언트와 서버는 안전하게 통신할 수 있다.
다만 이런 JWT 가 탈취 당했을 때 발생할 수 있는 여러 보안상 문제가 존재한다. 이때문에 유효 기간을 두는 것이다. 다만 유효기간을 짧게 두면 사용자가 로그인을 자주 해야하므로 사용자 경험적으로 좋지 않고, 길게 두면 보안상 탈취 위험이 높기 때문에 유효 기간이 다른 2개의 JWT(accessToken, refreshToken)을 사용하는 것이다.


  • 평소 API 통신에는 accessToken을 사용하고, refreshToken은 이 accessToken이 만료됐을 때 사용한다.
  • 통신 과정에 탈취 당할 위험이 큰 accessToken의 만료 기간은 짧게 두고 refreshToken으로 주기적으로 새로운 accessToken을 재발급한다.
  • 공격자는 accessToken을 탈취하더라도 짧은 유효 기간이 지나면 사용할 수 없다.
  • 정상적인 클라이언트는 유효 기간이 지나더라도 refreshToken을 사용하여 새로운 accessSToken 재발급이 가능하다.

결론적으로, 짧은 시간 동안에만 사용 가능하며 주기적으로 재발급 받을 수 있게 하여 accessToken이 유출되더라도 그 피해를 최소화하는 방식이다.


서버-클라이언트 통신에서의 프로세스

  1. 로그인에 성공하면 클라이언트는 백엔드 서버로부터 refreshTokenaccessToken을 받는다.
  2. 클라이언트는 refreshTokenaccessToken을 로컬에 저장한다.
  3. 클라이언트는 API 통신 시, 헤더에 accessToken을 넣어 통신을 한다.
  4. 일정 시간이 지나 accessToken의 유효 기간이 만료되었다.
    4.1 유효기간이 만료된 accessToken을 헤더에 넣어 API 통신을 시도한다.
    4.2 클라이언트로부터 유효기간이 만료된 accessToken을 받은 서버는 401(Unauthorized)에러 코드로 응답한다.
    4.3 401 에러를 통해 클라이언트는 accessToken의 유효기간이 만료되었음을 알 수 있다.
  5. 헤더에 AccessToken이 아닌 refreshToken을 넣어 accessToken 재발급 API를 요청한다.
  6. rerfreshToken으로 사용자의 권한을 확인한 서버는 새로운 accessToken을 발급하여 응답한다.

JWT를 어디에 저장해야 할까

이제 accessTokenrefreshToken의 사용 이유와 프로세스에 대해 얼추 이해했다. 그러면 이제 이 JWT를 로컬 어디에 저장해야할 지 고민이 된다. 기본적으로 3 가지 방법이 있다.


  1. localStorage / sessionStorage 저장
  2. 쿠키 저장
  3. secure httpOnly 쿠키 저장

1번의 경우 Javascript로 접근이 가능하며 보안상 취약점이 많아 제외했다. 2번의 경우 역시 Javascript 내 접근이 가능하며, XSS 취약점이나 CSRF 취약점이 있을 때 API 요청 시 공격을 수행하거나 유저 권한으로 정보를 가져올 수 있다.
3번 방식은 브라우저에 쿠키로 저장되는 건 2번과 같지만 httyOnly 쿠키의 경우 Javascript 내에서 접근이 불가능하며, secure을 적용하면 https 접속에서만 동작한다.
(3번의 경우에도 취약점이 존재하나, 자세한 내용은 적지 않겠다.)

로컬 스토리지나 세션 스토리지는 Javascript로 접근이 가능한 문제도 있었으나 서버 사이드에선 접근할 수 없다는 것도 가장 큰 문제였다. 우리 프로젝트는 NextJS 프로젝트였고, 서버사이드에서 API 요청이나 로그인 여부 확인이 필요했기 때문에 1번은 일찌감치 탈락했다.


여기까지 찾아봤을 때 생각했던 건 "refreshToken은 httpOnly 쿠키에 저장하고 accessToken은 recoil에 저장하자!"였다. 다만 recoil과 Nextjs가 상성이 좋지 않다는 게 가장 큰 문제였다. 앞서 다른 작업을 할 때 recoil로 관리되던 데이터가 새로 고침 시 날라가는 걸 확인할 수 있었다. 그래서 recoil을 사용하여 어떤 값을 전역적으로 관리하려면 recoil-persist를 사용해서 로컬 스토리지에 저장하는 작업을 추가로 진행했어야 했다.

만약 recoilaccessToken을 관리한다고 해도, 똑같이 새로고침 시 날라가버린다면 recoil-persist로 브라우저 저장소에 저장해야 하는데 이건 로컬 스토리지에 accessToken을 저장하는 것과 같지 않을까,, 라는 고민이 들었다. 더 이상적인 JWT 핸들링 방법을 고민했으나, 작업 일정이 촉박하였고 JWT 공부에 더 시간을 투자할 수 없는 상황이었다. 그래서 우선 accessToken 역시 쿠키에 저장하기로 하였다.😔

다만 SSR(서버 사이드)에선 Next 서버를 통해 httpOnly 쿠키에 접근할 수 있었으나 CSR(클라이언트 사이드)의 경우 httpOnly 쿠키에 접근할 방법이 없어 accessToken은 일반 쿠키에 저장하였다. 보안상 아쉬움이 정말 많았지만 우선은 이렇게 진행할 수 밖에 없었다. ㅠㅠ (최대한 쿠키의 SameSite, httpOnly, secure 속성 등을 확인하여 보안 이슈를 방어하고자 하였다. 그리고 accessToken의 만료 시간을 10분으로 짧게 제한 두었다.)


+글을 작성하는 시점에서 생각했을 때 가장 이상적인 핸들링은 프론트엔드 도메인과 백엔드 도메인을 일치시키고 accessTokenrefreshToken을 모두 secure httpOnly 쿠키에 저장하는 것이다. 도메인이 같을 때 클라이언트에서 HTTP 요청을 보낼 때 자동으로 쿠키가 서버에 전송되기 때문이다. (우리 프로젝트에서 백엔드와 프론트엔드의 도메인을 일치 시키고자 시도하였으나 실패하였다,,) 이 역시 XSS 취약점이 있다면 보안 이슈가 존재하기 때문에 클라이언트와 서버에서 별도의 XSS 방어 처리를 해줘야 한다.

참고하기 좋은 글


결론적으로 어떻게 사용했는데?

우리 프로젝트는 서버 사이드와 클라이언트 사이드에서 API 요청을 보냈고 요청을 보낼 때마다 accessToken을 헤더에 담아서 보내줘야 했다. 또한 로그인 여부는 accessToken의 존재 여부로 확인하였고 이 역시 서버 사이드와 클라이언트 사이드 모두에서 accessToken을 접근하였다. 그리고 refreshTokenhttpOnly 쿠키에 저장되었으므로 무조건 서버 사이드에서만 접근할 수 있었다. 요약하자면 다음과 같다.

  • NextJS 14 버전 pages 라우터
  • Typescript 사용
  • 서버 사이드와 클라이언트 사이드에서 모두 accessToken 접근
  • 서버 사이드에서만 refreshToken 접근 가능

로그인 성공 후 리디렉션 되는 프론트엔드 URL은 "{CLIENT_BASE_URL}/oauth"이다. 이때 파라미터로 accessTokenrefreshToken, hasInfo(회원가입 / 로그인 구분하는 boolean) 값을 받는다.

다음 코드는 pages/oauth/index.tsx의 일부이다.

export default function Oauth() {
 
  const [accessToken, setAccessToken] = useState<string | null>('');
  const [refreshToken, setRefreshToken] = useState<string | null>('');
  const [hasInfo, setHasInfo] = useState<boolean | null>(null);

useEffect(() => {
  	const url = window.location.search;
    const urlParams = new URLSearchParams(url);
  
  	// url 파라미터에서 값 가져오기
    const accessTokenParam = urlParams.get('accessToken');
    const refreshTokenParam = urlParams.get('refreshToken');
    const hasInfoParam = urlParams.get('hasInfo');
  
  	// 해당 값들을 state로 저장하기
    if (accessTokenParam !== null) {
      setAccessToken(accessTokenParam);
    }
    if (refreshTokenParam !== null) {
      setRefreshToken(refreshTokenParam);
    }
    if (hasInfoParam !== null) {
      setHasInfo(hasInfoBoolean);
    }
}, [])
  
  return <LoadingSpinner />;

}

oauth 페이지가 마운트될 때, url 파라미터에서 accessToken, refreshToken, hasInfo 값을 받아 온다. 각각의 값들은 useState를 통해 저장된다. (eslint 경고 방어로 조건문을 추가했다.)

그리고 accessTokenrefreshToken이 state에 저장되면 그 값들을 가지고 Next API Routes로 만든 session API로 전달한다. 이 session API에서 JWT를 쿠키에 저장하는 처리를 한다.

useEffect(() => {
    if (accessToken && refreshToken) {
      axios
        .post('/api/auth/session', {
          accessToken,
          refreshToken,
        })
        .then((res) => {
          if (res.status === 200) {
            setIsTokenPosted(true);
          }
        })
        .catch((error) => {
          console.error('로그인 성공 후 토큰 저장 실패', error);
        });
    }
  }, [accessToken, refreshToken]);

NextJS API Routes는 쉽게 말해선 Next 서버에서 사용할 수 있는 API를 만드는 기능이라고 보면 된다. pages/api 안에 만든 파일이 rest api path가 된다.
참고 포스팅 - Next API Routes란?


pages/api/auth/session.ts

import { NextApiRequest, NextApiResponse } from 'next';
import { setCookie } from 'cookies-next';
import IAuth from '@/types/auth';

export default function Session(req: NextApiRequest, res: NextApiResponse) {
  
  // API 요청이 POST일 때만
  if (req.method === 'POST') {
    try {
      const { accessToken, refreshToken } = req.body as IAuth;

      if (accessToken && refreshToken) {
        setCookie('accessToken', accessToken, {
          req,
          res,
          path: '/',
          maxAge: 60 * 60 * 24,
          httpOnly: false,
          secure: process.env.NODE_ENV === 'production',
          sameSite: 'lax',
        });

        setCookie('refreshToken', refreshToken, {
          req,
          res,
          path: '/',
          maxAge: 60 * 60 * 24,
          httpOnly: true,
          secure: process.env.NODE_ENV === 'production',
          sameSite: 'lax',
        });

        res.status(200).json({ message: '토큰 저장 성공' });
      } else {
        res
          .status(400)
          .json({ message: '토큰 저장 실패 | 토큰이 존재하지 않습니다.' });
      }
    } catch (error) {
      res.status(500).json({ message: '토큰 저장에 실패하였습니다.' });
    }
  } else {
    res.setHeader('Allow', ['POST']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}

Set-Cookie를 사용하여 쿠키에 저장할 수 있었으나, 좀 더 쉽게 쿠키를 관리하기 위해 cookies-next 라이브러리를 사용하였다. 꼭 사용할 필요는 없다. 개발 환경(npm run dev) 일 땐 http로 접근하므로 배포환경일 때만 secure 속성을 켜도록 설정하였다. 또한 refreshToken은 서버에서만 접근할 수 있도록 httpOnly 속성을 켜두었다. 둘 다 만료기간은 하루이지만, 실제 JWT의 만료기간과는 상이하다. (우리 프로젝트에서 accessToken은 10분, refreshToken은 2주동안 유효하다.)


클라이언트 사이드에선refreshTokenhttpOnly 쿠키에 저장할 수 없으므로 서버가 res, req 객체를 통해 쿠키를 저장할 수 있도록 session API Routes를 생성하였다. 또한 성공 시 200 코드와 message를 전달하였고 실패 시 경우에 따라 다른 에러 코드를 응답하도록 하였다.

session API에서 200 상태 코드를 응답하면 isTokenPosted를 true로 변경한다. isTokenPosted이 true이거나 getCookie('accessToken') 값이 존재할 때 hasInfo에 따라 "/register"이나 "/home"으로 라우팅 처리를 하였다.


로그아웃 구현

로그아웃 시 쿠키에 저장된 accessTokenrefreshToken을 제거해야 한다. 로그인일 때(session)와 마찬가지로 httpOnly 쿠키에 접근하기 위해서 logout API Route를 만들어 쿠키를 제거하였다.

import { NextApiRequest, NextApiResponse } from 'next';
import { deleteCookie } from 'cookies-next';

export default function logout(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === 'POST') {
    deleteCookie('accessToken', { req, res, path: '/' });
    deleteCookie('refreshToken', { req, res, path: '/' });

    res.status(200).json({ message: '로그아웃 성공 | 토큰 삭제' });
  } else {
    res.setHeader('Allow', ['POST']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}

마지막으로

Next에서 JWT 핸들링에 대해 고민한 과정과 최종 코드를 정리하였다. 위에서 말했던 것처럼 보안상의 아쉬운 점이 존재하는 코드이다. 더 좋은 방법을 찾기 위해서 많이 알아보았으나 현재 코드 이상으로 좋은 방법(가능한 방법)을 찾지 못하였다. NextJS도 JWT도 이번 프로젝트에서 처음 제대로 사용해보는 초보자라 더 어렵게 느껴졌던 것 같다. 😥

그리고 사실 가장 어려웠던 부분은 accessToken 만료 시 refreshToken을 재발급 받는 기능을 구현하는 것이었다. 해당 내용은 다음 포스팅에서 서술하겠다.


만약 포스팅에서 이해가 되지 않는 부분이나 혹은 잘못 설명한 부분이 있다면 댓글로 알려주세요 ! 🙇🏻‍♀️🙇🏻‍♂️

전체 코드는 여기에서 확인할 수 있습니다.


참고

🧐 Access Token과 Refresh Token이란 무엇이고 왜 필요할까?

0개의 댓글