Next.js에서 토큰 인증 구현하기

Next.js에서 토큰 인증 구현하기

Category
Published
July 6, 2024
Last updated
Last updated October 25, 2024
💡
이 포스트는 현재 작성중입니다

개요

Next.js Pages Router를 사용해본 입문자가 JWT 기반 access token, refresh token을 이용한 인증을 구현해본 경험을 적었습니다.

배경 상황

  • 서비스는 Next.js 프론트엔드와 Spring 백엔드로 구성되었습니다
  • 인증 방식은 JWT 기술을 이용해 access token과 refresh token을 사용하는 방식입니다
  • 인증은 OAuth 인증을 사용합니다
    • OAuth인증을 진행후 서버에서 고유의 JWT를 받아 백엔드 API에 접근하는 방식입니다
  • 인증을 위해서 API 엔드포인트에 접근 시 Authorization 헤더에 Bearer 타입을 이용하여 전송해야 합니다
  • 로그인 시 JWT는 body에 주어지며, 따로 추가적인 백엔드에서의 추가적인 쿠키 지원은 없습니다

익히 알려진 구현 방식

  • NextAuth.js나 React-query를 사용하면 CSR SSR 모두에 대응되는 인증을 편하게 구현할 수 있습니다
  • 그러나 전반적인 React와 웹 브라우저 공부를 하기위해서 직접 설계하고 구현해보기로 했습니다

필요한 구현

  • Cookie(쿠키), localStroage(로컬 스토리지), sessionStorage(세션 스토리지), 비공개 변수(React 상태 관리) 등 여러 방식중 token을 저장할 방식을 정하고 구현해야 합니다
  • 매번 백엔드 API 호출시에 토큰을 이용한 인증을 포함한 API 호출 코드를 작성할 필요가 없게, 공통적으로 사용할 수 있는 인터페이스를 작성하기로 했습니다

1. 쿠키와 Axios interceptor를 이용한 방식

가장 처음으로 시도한 방식은 access token과 refresh token을 쿠키에 저장하고, axios의 interceptor를 이용하여 자동으로 token을 최신화 하고 인증을 자동으로 처리하는 방식이었습니다.

쿠키

JWT를 사용한다면 토큰을 저장할 곳은 쿠키, 로컬 스토리지, React 상태등 다양한 장소가 있습니다.
제가 처음 선택한 방식은 쿠키였습니다. access token은 그냥 저장하고, refresh token은 http only로 저장하면 XSS와 CSRF 공격을 방어할 수 있을 것으로 보았습니다.

Axios interceptor

notion image
이때 API 요청시에 자동으로 access token을 포함하여 요청할 수 있게 위와 같은 플로우를 설계하고 구현했습니다.

구현 중 문제점, 깨달은 점

  • Next.js에서의 API 호출은 클라이언트 사이드로 브라우저에서 직접 하는 방식과, 아니면 Next.js 서버에서 시행하는 방식이 있는데, 두가지 경우에서 쿠키를 취득하는 방법이 달랐습니다
    • 즉 공통된 코드를 사용할 수 없어, 두개의 axios client를 만드는 방식으로 구현했습니다
  • Refresh token을 HTTP Only로 관리하기 위해서는, 백엔드 서버에서 추가적으로 Set-Cookie등을 이용한 작업이 필요합니다
    • 그렇기에 Next.js의 API Routes 기능을 통해 한번 우회하는 방식으로 구현했습니다
    • 코드
      import { NextApiRequest, NextApiResponse } from "next"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") { res.setHeader("Allow", ["POST"]); res.status(405).end(`Method ${req.method} Not Allowed`); } const refreshToken = req.cookies.refreshToken; if (!refreshToken) { return res.status(401).json({ error: "No refresh token" }); } try { const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/auth/reissue`, { method: "POST", headers: { Authorization: `Bearer ${refreshToken}`, }, }); const data = await backendResponse.json(); if (!data.success) { return res.status(data.error.code).json({ error: data.error.message }); } res.setHeader("Set-Cookie", [ `accessToken=${data.data.accessToken}; Path=/; Secure; SameSite=Strict; Max-Age=3600`, // 1시간 만료 ]); return res.status(200).json({ accessToken: data.data.accessToken }); } catch (error) { // 네트워크 오류 처리 return res.status(500).json({ message: error.message }); } }
      api/auth/token/refresh.ts
    • 또한 Next.js에서 SSR처리를 할때도 자유롭게 쿠키를 조정할 수 있으며 HTTP Only도 읽고 새로 설정할 수 있었기에 이를 이용할 수도 있을 것 같습니다
  • 이때는 그리고 api 엔드포인트를 모아서 관리하기 위하여 API Routes를 활용했습니다
    • 예시 코드
      export async function getMyData(accessToken) { try { const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/my-page`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, }); // 토큰 만료 시 재발급 if (backendResponse.status === 401) { return null; } // 오류 처리 if (!backendResponse.ok) { const errorData = await backendResponse.json(); throw new Error(errorData.message); } const data = await backendResponse.json(); if (!data.success) { throw new Error(data.error); } return data.data; } catch (error) { console.error(error); return { error: error.message }; } } export default async function handler(req, res) { const id = req.query.id; // const collegeData = await getCollegeDetailData(id); if (req.method === "GET") { // res.status(200).json(collegeData); } }
    • 위의 예시 코드와 같이 handler와 요청 함수를 나누어서, 함수를 호출하는 방식으로 사용했습니다
    • Axios와는 같이 활용이 어렵기에, 로그인 상태가 필요없는 인증 미필요 API에만 한정적으로 사용했습니다
    • 그렇기에 Axios를 이용하는 대부분의 API는 한곳에 모아서 관리하지 못하고, URI가 곳곳에 하드코딩 되어 나누어져 있어 관리가 어려웠습니다

결과

clientApiClient
import axios from "axios"; import Cookies from "js-cookie"; export default function createApiClient() { const instance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_SERVER_URL, withCredentials: true, }); instance.interceptors.request.use( async (config: any) => { let accessToken = Cookies.get("accessToken"); if (!accessToken) { // access token 없을 때 refresh token으로 재발급 시도 await fetch(`/api/auth/token/refresh`, { method: "POST", }); accessToken = Cookies.get("accessToken"); } if (accessToken) { config.headers["Authorization"] = `Bearer ${accessToken}`; } return config; }, (error) => { return Promise.reject(error); } ); instance.interceptors.response.use( (response) => { return response; }, async (error) => { const originalRequest = error.config; if (error.response.status === 401 || error.response.status === 403) { await fetch(`/api/auth/token/refresh`, { method: "POST", }); const accessToken: string = Cookies.get("accessToken"); if (!accessToken) { document.location.href = "/login"; } originalRequest.headers["Authorization"] = "Bearer " + accessToken; return axios(originalRequest); } return Promise.reject(error); } ); return instance; }
clientApiClient.ts
serverApiClient
import axios from "axios"; import { refreshToken } from "firebase-admin/app"; export default function createApiClient(req, res) { const apiClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_SERVER_URL, withCredentials: true, }); // Request interceptor for API calls apiClient.interceptors.request.use( async (config) => { let accessToken; if (config.headers.Authorization) { accessToken = config.headers.Authorization.split(" ")[1]; } else { accessToken = req.cookies["accessToken"]; // Ensure this is accessible or passed appropriately } if (!accessToken) { // access token 없을 때 refresh token으로 재발급 시도 try { const refreshToken = req.cookies["refreshToken"]; const response = await fetch(`${process.env.NEXT_PUBLIC_WEB_URL}/api/auth/token/refresh`, { method: "POST", headers: { withCredentials: true, Cookie: `refreshToken=${refreshToken}`, }, }); const data = await response.json(); accessToken = data.accessToken; res.setHeader("Set-Cookie", `accessToken=${accessToken}; Path=/; SameSite=Strict; Max-Age=3600`); config.headers = { ...config.headers, Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }; } catch (error) { console.error("access token 발급중 오류\n", error); return Promise.reject(error); } } else if (accessToken) { // access token이 있을 때 config.headers = { ...config.headers, Authorization: `Bearer ${accessToken}`, }; } return config; }, (error) => { Promise.reject(error); } ); apiClient.interceptors.response.use( (response) => { return response; }, async (error) => { const originalRequest = error.config; if ((error.response.status === 401 || error.response.status === 403) && !originalRequest._retry) { originalRequest._retry = true; try { const refreshToken = req.cookies["refreshToken"]; const response = await fetch(`${process.env.NEXT_PUBLIC_WEB_URL}/api/auth/token/refresh`, { method: "POST", headers: { withCredentials: true, Cookie: `refreshToken=${refreshToken}`, }, }); const data = await response.json(); const accessToken = data.accessToken; res.setHeader("Set-Cookie", `accessToken=${accessToken}; Path=/; SameSite=Strict; Max-Age=3600`); originalRequest.headers["Authorization"] = "Bearer " + accessToken; return apiClient(originalRequest); } catch (refreshError) { console.error("accessToken 무효로 재발급중 오류:\n", refreshError); return Promise.reject(refreshError); // Or handle a redirect to login } } return Promise.reject(error); } ); return apiClient; } // 사용 예시 export async function getServerSideProps(context) { const { req, res } = context; const apiClient = createServerApiClient(req, res); ~~~ }
serverApiClient.js
위의 코드는 각각 클라이언트 사이드, 서버 사이드에서 API 요청을 할 때 사용하는 axios client이며, 서버 사이드에서 요청할 시에는 req, res를 입력받아야 합니다.
위의 코드는 JWT의 유효기간에 따른 처리도 되어 있지 않고, 무조건 서버로 보내는 것을 시도한 뒤, 문제가 생기면 새롭게 access token을 받아오는 방식의 코드입니다. 또한 refresh token 만료시에 문제가 생길 수 있는 코드입니다.
또한 현재 로그인 상태인지 판별하기도 어려워, 단순히 refreshToken의 존재 여부에 따라서 로그인이 되어 있는지 판단했습니다. const isLogin = req.cookies.refreshToken ? true : false; 또한 클라이언트에서는 HTTP Only이기에 refreshToken에 접근할 수 없어 서버 사이드에서만 판별할 수 있었습니다.
 
여기서 더 개선을 하고 사용할 수도 있었겠지만, 이 시점에서 쿠키에 대한 공부도 충분히 되었다고 생각하고, 그냥 로컬 스토리지를 사용하는 방식으로 전환하기로 결정했습니다.
쿠키를 이용한 구현을 하며 느낀점은
  • 백엔드 서버에서 쿠키에 대한 지원이 없다면 HTTP Only사용이 어려울 수도 있다(Next.js 등 자체 서버가 있다면 우회가 가능하나 React라면 HTTP Only 도입 자체가 어려울 수 있다)
  • 쿠키는 생각보다 더 복잡한 기능이며 다양한 옵션과 헤더을 알아야 하고, 웹브라우저 구현에 따라서도 영향을 많이 받는 요소이다

2. 로컬 스토리지와 Axios interceptor를 이용한 방식

쿠키에 비해 로컬 스토리지는 CSRF에는 안전하나 XSS에는 위험한 것으로 알려져 있습니다. 그러나 현재 서비스에 당장 XSS을 실행할 스크립트를 작성할 만한 기능자체가 별로 없기에, 로컬 스토리지를 사용해보기로 했습니다.
로컬 스토리지를 사용하면 서버 사이드에서 localStorage에 접근하기가 어렵기에 인증 관련 API를 이용할 때 SSR를 이용하기가 어렵지만, 이 시점에서 SSR은 비로그인으로 접근 가능한 경우에만 제공하는 것으로 마음먹었습니다.

구현

import axios, { AxiosInstance } from "axios"; const convertToBearer = (token: string) => { return `Bearer ${token}`; }; const token = { access: typeof window === "undefined" && typeof global !== "undefined" ? null : localStorage.getItem("accessToken"), refresh: typeof window === "undefined" && typeof global !== "undefined" ? null : localStorage.getItem("refreshToken"), }; const instance: AxiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_SERVER_URL, withCredentials: true, headers: { Authorization: `Bearer ${token?.access}`, }, }); export default instance; instance.interceptors.request.use( (config) => { console.log("config: ", config); config.headers["Authorization"] = `Bearer ${token?.access}`; return config; }, (error) => { console.error("Error request: ", error); return Promise.reject(error); } ); instance.interceptors.response.use( (response) => { console.log("response: ", response); return response; }, async (error) => { console.error("Error: ", error); if (error.response?.status === 401 || error.response?.status === 403) { try { const { data: { data: { accessToken }, }, } = await axios.post(`${process.env.NEXT_PUBLIC_API_SERVER_URL}/auth/reissue`, {}, { headers: { Authorization: `Bearer ${token?.refresh}` } }); //refresh 유효한 경우 새롭게 accesstoken 설정 window.localStorage.setItem("accessToken", accessToken); if (error?.config.headers === undefined) { error.config.headers = {}; } else { error.config.headers["Authorization"] = convertToBearer(accessToken); localStorage.setItem("accessToken", accessToken); // 중단된 요청 새로운 토큰으로 재전송 const originalResponse = error.config; return await axios.request(originalResponse); } } catch (err) { console.error(err); document.location.href = "/login"; // 로그인 페이지로 이동 } } else { console.log("error not auth: ", error); throw error; } } );
axiosClient.ts
서버 사이드에서는 인증 필요 API를 이용하지 않기로 했기에 서버 사이드용 axios client는 더이상 구현할 필요가 없어졌습니다.
JWT expire 시간을 이용하지 않고 여전히 우선 요청을 하고 서버에서 체크를 하는 방식으로 구현했습니다.

문제점

      3. 로컬 스토리지와 Recoil 그리고 Custom Hook을 이용한 방식

       

      레퍼런스, 읽어 보면 좋은 글