Next-Auth 를 이용한 토큰 갱신 및 자동 로그인 구현 (feat. iron-session)

terry yoon·2023년 11월 30일
9
post-thumbnail
post-custom-banner

Next-Auth 로그인 세팅 방법이 궁금하다면 이전 글을 참고해주세요! 바로가기

배경 설명 및 다룰 내용

🔍 기존 AccessToken 의 보안적 이슈

기존 로그인 방식은 API 응답값으로 만료 기한이 무제한인 accessToken 을 받아 사용하고 있었습니다. 다만 웹에서 로그인 기능이 추가되면서, 토큰 유효 기간을 제한하고 대신 refreshToken 을 통해 갱신하는 방식으로 보안 이슈를 해결하고자 로그인 관련 스펙 변경이 이뤄졌습니다.

🔍 공용 PC 로그인 이슈

사용자가 공용 PC 에서 웹 로그인을 하는 경우, 보안을 위해 브라우저를 종료할 경우 자동으로 로그아웃 되어야 합니다. 기존 웹에서는 브라우저가 종료 되어도 로그아웃 되지 않는 문제가 있어, 이를 위해 로그인 시 로그인 유지(remember-me) 옵션을 제공할 필요가 있습니다.

👀 다룰 내용

  1. AccessToken Rotation 적용 방법 소개
  2. Next-Auth 라이브러리에서 자동 로그인 구현 방법 소개

🎡 AccessToken Rotation

⚙️ Next-Auth JWT 콜백 설정하기

📒 공식 문서 확인하기 : Auth.js

jwt 콜백은 session을 업데이트 하거나 호출할 경우에 실행되는 함수입니다.

[jwt 콜백 실행조건]
Requests to /api/auth/signin, /api/auth/session and calls to getSession(), getServerSession(), useSession() will invoke this function, but only if you are using a JWT session. This method is not invoked when you persist sessions in a databas

예를 들어 클라이언트 사이드에서 useSession 이나 getSession 과 같은 함수를 호출할 때마다 jwt 콜백이 실행되는데, 이 때 token 객체에 저장된 토큰 만료 시간과 현재 시간을 비교하는 로직을 추가하여, token 객체를 갱신하면 됩니다.

최초 로그인 시, token 정보 세팅

 // [...nextauth].ts
 
async jwt({ token, profile, account }) {
  try {
    	// 초기 로그인 시, 토큰 정보에 만료 시간 & refreshToken 세팅
   	 if (account) {
    	...
       token.refreshToken = refreshToken;
   	   token.accessTokenExpires = jwtParse(accessToken).exp; 
  	    ...
  	  }
  	  ...
  	  return token;
  	} catch (error) { 
 }

jwt 콜백이 실행될 때, 최초 로그인인 경우 account 객체가 콜백 인수로 전달됩니다. account 객체에는 로그인과 관련된 정보들이 있지만, 저희는 해당 객체를 참조하지 않고 내부적으로 다시 API 호출을 통해 전달받은 토큰 정보를 token 객체에 담아줍니다. 이렇게 token 객체를 반환하면 다음 jwt 콜백 실행 시에 동일한 내용의 token 객체가 다시 인수로 전달됩니다.

토큰 만료 시간 비교

 // [...nextauth].ts
 
async jwt({ token, profile, account }) {
  try {
   ... 
   const timeRemaing = token.accessTokenExpires - (Math.floor(new Date().getTime() / 1000) + 10 * 60);

   if (timeRemaing <= 0) {
      const newToken = await refreshAccessToken(token);
      ...

      return { ...newToken };
   }

    return token;
    ...
  } catch (error) {
    ...
}

콜백이 실행되면 다음과 같이 token 객체에 저장된 expireTime 과 현재 시간을 비교합니다. 갱신 시간은 토큰 만료 10분전으로 설정하여, 만료 전 미리 토큰을 갱신하도록 처리했습니다. 토큰 발급 API 요청을 통해 새로운 토큰을 발급받고 이를 다시 반환해주면 토큰 갱신이 완료 됩니다.

토큰 업데이트 주기 설정

📒 관련 문서 : Client API | NextAuth.js

사용자가 사이트 안에서 페이지를 이동하는 등 여러 액션을 하는 과정에서 jwt 콜백이 실행되지만, 만약 사이트 안에서 아무 행동도 없이 그대로 멈춰있는 경우 jwt 콜백이 실행되지 못해 콜백 내 토큰 비교 로직이 실행될 수 없습니다. 이를 위해 클라이언트에서 일정한 주기를 두어 토큰 만료 시간을 체크해 강제적으로 jwt 콜백이 실행되도록 설정했습니다.

const { data : session, update } = useSession()

...

const RefreshTokenExpireTime = ({
  session,
  update,
}: {
  session: Session | null;
  update: (data?: any) => Promise<Session | null>;
}) => {
  const interval = useRef<NodeJS.Timer | undefined>(undefined);

  useEffect(() => {
    if (interval.current) {
      clearInterval(interval.current);
    }

    const watchAndUpdateIfExpire = () => {
      if (session) {
        const nowTime = Math.floor(new Date().getTime() / 1000);
        const timeRemaining = session.accessTokenExpires - 7 * 60 - nowTime; // unix timestamp

        if (timeRemaining <= 0) update();
      }
    };

    interval.current = setInterval(watchAndUpdateIfExpire, 1000 * 10);

    return () => clearInterval(interval.current);
  }, [session, update]);

  return <></>;
};

RefreshTokenExpireTime 컴포넌트는 props로 session 정보와 update 핸들러를 주입받아, 강제적으로 session을 업데이트하는 컴포넌트 입니다. 10초에 한 번씩 현재 session 정보에 담긴 accessTokenExpireTime 과 현재 시간을 비교해, 만료 7분 전에 session을 업데이트 하여 jwt 콜백을 실행하도록 강제하였습니다.

useSession 에서 반환받은 update 함수를 호출하면, jwt 콜백이 재실행됩니다. 따라서 jwt 콜백에 설정한 토큰 만료 로직을 실행할 수 있어, 만약 토큰이 만료될 경우 갱신되도록 할 수 있습니다.

useSession 훅에서 update 핸들러를 반환받기 위해서는 next-auth v4.22.1 이상 사용해야 합니다. (관련 PR 링크)

⚙️ 갱신된 Token 전파하기

클라이언트 사이드에서 로그인 토큰의 유효성을 판단하기 위해(만료된 토큰인지 여부 등), 별도의 API 라우트를 만들어 이를 검증하도록 했습니다. 저희 팀에서는 리액트 쿼리를 사용하여 이런 API 요청을 훅 형태로 만들어 사용하고 있습니다.

useCheckIsLogin 훅을 사용한 컴포넌트가 mount 되면 프론트 server(API route)에 API 요청하여 token 정보와 status를 반환하고 있습니다.

다만 위에서 설명드린 것처럼 클라이언트 사이드에서 토큰을 갱신한 경우, 업데이트 된 새로운 토큰 정보를 받아오도록 다시 query 요청을 해야 합니다. 이를 위해 useCheckIsLogin 쿼리 key 옵션으로 session 삽입하여, session이 업데이트 되면 새로 query를 받아오도록 설정했습니다.

const { data : session, update } = useSession() <-- update를 호출하면 session이 다시 반환됩니다. 

const useCheckIsLogin = (session: Session | null) => { <-- 반환 받은 session 정보를 토대로 쿼리를 세팅합니다.
  return useQuery(['check-is-login', session], checkIsLogin, {
    enabled: Boolean(session),
  });
};

왜 next-auth 가 제공하는 useSession 대신 useCheckIsLogin 을 별도로 만들어 사용하나요?

물론 useSession 에서 바로 업데이트된 session 정보를 가져올 수 있지만, 이후 자동 로그인 로직 구현 과정에서 useCheckIsLogin 을 사용하는 방법으로 변경하였습니다. 자세한 내용은 뒤에 자동로그인 구현 내용에서 설명하겠습니다.

🔒 Remember Me (로그인 유지하기)

⚙️ Session 쿠키 vs Persistent 쿠키

두 가지 쿠키에 대한 이해

자동 로그인을 구현하기 위해서는 우선 토큰이 저장된 쿠키의 저장 옵션을 자유롭게 변경할 수 있어야 합니다. 이를 위해 먼저 알아야 하는 개념은 Session Cookie 와 Persistent Cookie 입니다.

두 개념의 차이는 바로 Cookie의 유효 기간을 의미하는데 Session 쿠키는 브라우저의 세션이 유지되는 동안만 유효한 값으로 세션이 종료되면 해당 쿠키는 사라지게 됩니다. 반면에 Persistent 쿠키는 사용자가 지정한 특정일까지 유효하여 브라우저 세션이 종료되어도 쿠키 정보가 사라지지 않습니다.

이 때문에 로그인 토큰을 가지고 있는 쿠키의 만료를 지정할 수 있어야, 사용자가 로그인 유지하기를 선택한 경우와 그렇지 않은 경우 브라우저 세션이 종료 되었을 때 자동 로그아웃 여부를 결정할 수 있습니다.

session 쿠키 지정 방법

국제 인터넷 표준화 기구(IETF)에서 HTTP State Management Mechanism 이라는 문서 중 Section 5.3 Storage Model 에서 쿠키의 만료일을 지정하는 방법에 대해 나와 있습니다. (RFC 6265: HTTP State Management Mechanism)

만약 쿠키를 지정할 때 ‘Max-Age’ 또는 ‘Expire’ 라는 속성을 함께 설정할 경우, 해당 쿠키는 persistent flag가 true로 활성화 되고 지정된 날짜까지 만료일이 지정됩니다. 그 외에 경우는 persistent flag 가 false 가 됩니다. 즉, 쿠키 설정 시 Max-age 나 Expire 속성이 없을 경우 해당 쿠키는 세션 쿠키로 저장됩니다.

따라서 로그인 유지 기능을 선택한 사용자에 대해 로그인 토큰을 저장한 쿠키의 Max-Age / Expire 옵션을 지정해주고 그렇지 않을 경우 해당 속성 없이 저장하면 됩니다.

위에서 말한 것처럼 로그인 토큰을 Session 쿠키로 저장하기 위한 옵션을 Next-auth 에서는 제공하지 않습니다. 토큰의 Max-age 를 지정할 수 있도록 했지만, 아이에 Max-Age 속성을 주지 않고 저장하는 것은 불가능합니다. 만약 Max-Age 속성을 지정하지 않은 경우 Next-Auth 의 경우 default 로 30일을 만료일로 설정하도록 되어 있습니다.

// next-auth 내부 init 함수
export async function init({ authOptions, ... }) {
  ...
  const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default
  ...
  
  const options = {
    ...,
    session : {
      ...
      maxAge, <--- 기본적으로 30일의 maxAge 값을 지정하고
      ...,
      ...authOptions.session <-- 사용자가 maxAge를 지정했다면 해당 값으로 덮어쓰기를 합니다. 
    }
  }
  ...
  return { options, cookies }
}

next-auth 라이브러리 github issue 를 살펴보면, 이런 설정 때문에 max-age 설정을 optional 하게 지정할 수 있도록 만들자는 제안들이 많았습니다. 저도 역시 관련해 질문을 남겼는데, maintainer 에게 받은 응답은 불완전한 브라우저 동작 때문에 세션 쿠키가 원하는 데로 동작하지 않을 수 있다는 답변이었습니다.

실제로 MDN 에서 session 쿠키에 대한 경고 문구를 안내하고 있는데, 내용을 살펴보면 많은 브러우저에서 session restore 기능을 제공하고 있어서, 만약 세션이 종료되어도 세션 쿠키가 사라지지 않을 수 있다고 되어 있습니다.

실제로 맥 환경에서 브라우저를 완전 종료 하지 않고 닫는 경우에, 세션 쿠키가 여전히 남아 있는 걸 확인할 수 있었습니다. 이 때문에, 만약 윈도우가 닫힐 때만 트리거되는 이벤트가 있다면 해당 이벤트 핸들러에 로그아웃 기능을 구현하는 방법도 고려하였습니다. 하지만 beforeunload 와 같은 이벤트는 윈도우 종료 뿐만 아니라, 페이지 새로고침 시에도 트리거 되는 등 이벤트를 명확히 구분하는 것이 힘들어 현실적으로 세션 쿠키를 설정해 구현하는 것이 최선의 방법이라고 생각했습니다.

🛠️ Problem Solving : 자동 로그인을 위한 별도의 쿠키를 만들자

로그인 만료를 체크할 수 있는 별도의 쿠키

위에 언급한 문제 때문에, 결국 별도의 쿠키값을 이용해 “로그인 유지하기” 기능을 활성화한 사용자의 쿠키는 max-age 를 설정하고 반대의 경우 session 쿠키로 설정 되도록 하였습니다. 즉, 로그인 토큰을 담는 쿠키와 별도로 세션만 체크하는 쿠키를 만들기로 하였습니다. (약간의 꼼수…ㅋ)

방법을 찾아보다가, next-auth에서 추천하는 라이브러리인 iron-session 라이브러리를 시도하게 되었습니다. iron-session 은 stateless 한 서버 상태를 보완하기 위해, 서버에서 상태를 저장하여 암호화한 뒤 쿠키에 저장하고 나중에 복호화 해서 상태를 읽고 수정할 수 있도록 지원하는 라이브러리 입니다. 따라서 서버에서 원하는 정보를 저장하고 읽을 수 있는 장점이 있습니다.

물론 클라이언트 사이드에서도 직접 쿠키에 값을 설정해 저장할 수 있겠지만, 로그인 관련 로직이 모두 서버 사이드에 응집되어 있어 서버에서 이를 처리한다면 나중에 다른 팀원들이 코드를 파악할 때 놓치는 부분이 생기지 않을 수 있을거라 생각하여 해당 라이브러리를 사용했습니다.

iron-session과 next-auth 합치기

다행히 iron-session 은 API 라우트와 함께 사용할 수 있는 withIronSessionApiRoute라는 wrapper 함수를 제공하고 있습니다.

// [...nextauth].tsx

import { sessionOption } from '@utils/auth/iron-session';

async function auth(req: NextApiRequest, res: NextApiResponse) {
  ...
  return await NextAuth(req, res, option);
}

export default withIronSessionApiRoute(auth, sessionOption)

⚠️ 제가 사용한 irons-session 은 v6.3.1 입니다. v8 부터는 with... prefix 대신 getIronSession 으로 변경 되었습니다. (Release 8.0.0 · vvo/iron-session )

  • 세션 옵션 정하기
// 함수 형태의 sessionOption
export const sessionOption = (
  req: NextApiRequest | IncomingMessage,
  res: NextApiResponse | ServerResponse,
) => {
  let maxAge: number | undefined = undefined;
  const cookies = parseCookies({ req });

  if (cookies["로그인 유지 여부"]) { // 쿠키에서 로그인 유지하기 여부 확인
    maxAge = cookies["로그인 유지 여부"] === 'true' ? SECONDS_OF_30_DAYS : undefined;
  }

  return {
    cookieName: "저장할 쿠키 이름",
    password: "쿠키 암호화 키",
    cookieOptions: {
      ...,
      secure: process.env.NODE_ENV === 'production'
      maxAge,
    },
  };
};

→ session 에 값을 저장하기 위해 옵션을 설정해야 합니다. 이 때 maxAge 옵션을 지정할 수 있는데, number 타입의 값을 할당하면 persistent cookie 가 되고 undefined 값을 할당하면 session cookie가 됩니다.

→ sessionOption 위치에는 옵션 객체 뿐만 아니라 옵션 객체를 반환하는 함수 형태로도 전달할 수 있습니다. 저는 클라이언트 사이드에서 사용자의 “로그인 유지” 여부를 cookie 에 담아 보낸 다음, 그 값을 읽어 maxAge 를 세팅하도록 했기 때문에 함수 형태의 sessionOption으로 설정하였습니다.

  • 세션값 저장하기
// [...nextauth].tsx

const authOption = {
  events : {
    signOut : async () => {
      req.session.destroy(); 
    },
  },
  ...
  callbacks : {
    ...,
    async jwt({ token, account }){
      ...
      req.session['만료 여부 체크'] = ...;
      await req.session.save(); 
    }
  }
}

→ 로그인 이후 토큰을 저장(업데이트)하는 시점에, req.session에 적당한 값을 넣고 저장합니다. 이제 이 값의 유무를 이용해 로그아웃 여부를 체크합니다.

→ 로그아웃 되는 경우 req.session 에 저장된 값을 모두 지우도록 signOut 이벤트 핸들러 안에서 destroy 메소드를 호출 하게 합니다.

토큰 검증하기

로그인 유지하기 기능을 추가하며, 검증 프로세스가 하나 더 추가되었습니다. 기존에는 useSession 훅이 반환한 session 정보에 토큰이 존재하고 해당 토큰이 유효한지만 확인했다면, 이제는 iron-session에 저장한 ‘만료 여부 체크’ 값이 있는지 확인해서 없으면 로그아웃을 시켜주는 과정이 추가되었습니다.

sessionOption 을 설정할 때 쿠키 속성을 secure로 저장했기 때문에, 클라이언트에서 접근할 수 없어 API 라우트를 만들고 해당 API 로 요청을 보낸 뒤 확인하는 방식으로 구현하였습니다.

📈 Scale Up : 한 번의 요청으로 로그인 여부와 쿠키 만료를 체크할 수 없을까?

1차적으로 개발을 마치고 살펴보니, 로그인 정보를 불러오는 요청과 다시 또 만료 여부를 체크하는 과정이 나뉘어있어 코드가 산만하고, 한눈에 코드를 파악하기 쉽지 않아보였습니다. 지금 당장은 이해할 수 있지만, 향후 추가 개발 시 분명히 헷갈릴 수 있겠다고 생각하여 리팩토링이 필요함을 느꼈습니다.

API 요청 경로를 하나로 통일하자

지금은 로그인 정보 따, 만료 체크 따로 요청했다면 이제는 하나의 API 요청 경로에 로그인 정보를 호출하고 만료 체크까지 하여 클라이언트에서는 하나의 요청과 응답 결과로 로그인 여부를 판별할 수 있도록 만들고 싶었습니다.

이 때 기존에 useCheckIsLogin 에서 만료 체크를 위해 만들어 두었던 route API 파일을 변형하여 해당 파일 내에서 로그인 세션 정보와 만료 체크를 하도록 응집하였습니다.

// /pages/api/auth/checkIsLogin.ts

async function checkIsLogin(req: NextApiRequest, res: NextApiResponse) {
  const 만료여부 = req.session.['만료 여부 체크'];
  const session = await getLoginSession(req, res); // /api/auth/session 으로 API 요청
  
  /* session 과 만료 여부에 따라 비로그인/로그인 상태를 응답값으로 내려줍니다. */
}

원래 기존에 useSession 이 바로 session 정보를 요청하고 받아왔다면 이제는 custom 하게 설정한 API route 를 거쳐 가도록 설정하여 클라이언트에서는 만료 여부를 생각하지 않고 응답 결과에 따라 로그인 여부만 체크하면 됩니다.

이를 위해서 Next.js 에서 제공하는 rewrite 옵션을 활용하여 기존에 useSession 내 API 요청 경로인 /api/auth/session 을 /api/auth/checkIsLogin 으로 변경하였습니다. 이 덕분에 기존 코드에서 사용하고 있던 useSession 을 제거하지 않고도 받아온 응답값을 useCheckIsLogin 훅에 전달하여 로그인 로직을 처리하도록 했습니다.

// next.config.js 

module.exports = {
  ...
  async rewrites() {
    ...,
    { source: '/api/auth/session', destination: '/api/auth/checkIsLogin' },
    ...
  },
  ...
}

회고

개발을 하면, 정해진 답이 있으면 좋겠지만 지금과 같이 주어진 환경에서 최적의 방법을 찾아나가야 하는 경우가 많은 거 같습니다. 위의 방법이 최고의 방법은 아닐 수 있기 때문에 불안하긴 하지만 저의 상황에서 적절한 도구를 탐색하고 이를 이용해 문제를 해결해 나가는 과정을 배우며 최적의 방안을 고민해볼 수 있었습니다.

profile
배운 것을 기록하는 FrontEnd Junior 입니다
post-custom-banner

0개의 댓글