React

 

NextAuth를 사용하여 JWT 기반 인증을 구현하면서, 접근 토큰(access token)과 갱신 토큰(refresh token) 로직을 구현하는 과정은 중요합니다.

이 작업은 특히 토큰의 만료 및 갱신을 효과적으로 관리하는 데 필요합니다. 이 정리에서는 해당 코드를 바탕으로 NextAuth에서의 토큰 관리 전략을 설명하고,

각 주요 부분을 간략히 분석해 보겠습니다.

그리고 $currentSessionToken 변수값을 만들어서 처리하였는데,    nextauth.js 의 안에서   콜백에서  토큰값이 정상적으로  업데이트 처리 된다면,

$currentSessionToken 변수는 사용할 필요가 없다.

 

 

먼저, 넥스트  환경은  Using App Router 방식으로 개발하였으면 넥스트와  next-auth 버전은 다음과 같다.

 "next": "14.2.5",
  "next-auth": "^4.24.7",
"react": "^18.3.1",

또한,  타입스크립트를 적용했으며,  백엔드는 스프링부트 와 express 를 번갈아 적용하면서 테스트를 진행하였다.

그리기  갱신토큰 ( refreshToken )  저장소는   redis 를 이용하였다.

 

백엔드 스프링부트 redis 적용은 다음을 참조

https://macaronics.net/m01/spring/view/2183

 

 

//갱신토큰 로직 nextjs 다음을 참조

https://next-auth.js.org/v3/tutorials/refresh-token-rotation

 

 

 

 

1. 접근 토큰(Access Token)과 갱신 토큰(Refresh Token) 로직:

  1. 로그인 및 토큰 생성:

    • 사용자가 로그인하면, 서버는 **접근 토큰(Access Token)**과 **갱신 토큰(Refresh Token)**을 생성합니다.
    • 생성된 두 토큰을 프론트엔드(클라이언트)로 반환합니다.
  2. 토큰 저장:

    • **접근 토큰(Access Token)**은 클라이언트 측(예: 브라우저의 메모리나 쿠키)에 저장되어, 서버와의 인증 요청에 사용됩니다.
    • **갱신 토큰(Refresh Token)**은 보안이 중요한 데이터이므로 서버 측의 Redis와 같은 인메모리 데이터 저장소에 저장합니다. Redis를 사용하는 이유는 빠른 속도와 가벼운 특성 덕분에 효율적인 토큰 관리가 가능하기 때문입니다.
  3. 토큰 유효 기간 설정:

    • **접근 토큰(Access Token)**의 유효 기간은 보통 1시간으로 설정됩니다.
    • **갱신 토큰(Refresh Token)**의 유효 기간은 보통 2주로 설정됩니다.
  4. 접근 토큰 만료 및 재발급:

    • **접근 토큰(Access Token)**은 서버와의 인증 요청 시 사용되며, 1시간 동안 유효합니다.
    • 만약 접근 토큰이 만료되면, 클라이언트는 **갱신 토큰(Refresh Token)**을 사용해 서버에 새로운 접근 토큰을 요청합니다.
  5. 갱신 토큰 인증 및 재발급:

    • 서버는 클라이언트로부터 받은 **갱신 토큰(Refresh Token)**을 Redis에 저장된 토큰과 비교하여 일치하는지 확인합니다.
    • 토큰이 일치하면, 서버는 새로운 **접근 토큰(Access Token)**과 **갱신 토큰(Refresh Token)**을 생성하여 Redis에 갱신된 토큰을 저장합니다.
    • 생성된 토큰들은 다시 클라이언트로 전송됩니다.
  6. 토큰 오류 처리:

    • 갱신 토큰이 유효하지 않거나 만료되었을 경우, 또는 다른 오류가 발생하면 사용자를 로그아웃 처리하여 재인증을 요구합니다.

 

 

 

 

2.NextAuth() 호출 옵션 정리

 

1. providers:

  • 설명: 인증 공급자를 지정합니다. 예를 들어, Credentials, Google, GitHub 등의 인증 방법을 설정할 수 있습니다.
  • 예시
providers: [
  Providers.Google({
    clientId: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  }),
]

 

2. session:

  • 설명: 세션 관리 방식을 지정합니다. jwt나 database 등의 옵션을 통해 세션 저장 방식을 선택할 수 있습니다.
session: {
  strategy: 'jwt',
  maxAge: 30 * 24 * 60 * 60, // 30일
}

 

3. pages:

  • 설명: 사용자 정의 페이지 경로를 지정합니다. 기본 로그인 페이지는 /auth/signin으로 설정되어 있지만, 이를 사용자 정의 페이지로 변경할 수 있습니다.
  •  

예시

pages: {
  signIn: '/auth/custom-signin',
  signOut: '/auth/custom-signout',
  error: '/auth/error', // 에러 페이지
  verifyRequest: '/auth/verify-request', // 이메일 확인 페이지
  newUser: '/auth/new-user' // 신규 사용자 등록 후 이동할 페이지
}

 

4. callbacks:

  • 설명: 인증과 세션 관리 중 호출되는 핸들러들을 정의합니다.

  • callbacks.signIn:

    • 설명: 사용자가 로그인을 시도할 때 호출됩니다. true를 반환하면 로그인 성공, false를 반환하면 실패로 처리됩니다.
    • 예시
callbacks: {
  async signIn(user, account, profile) {
    return true; // 로그인 성공
  },
}

 

5. events:

  • 설명: 인증 이벤트 발생 시 호출되는 핸들러를 정의합니다. (이 항목은 추가적으로 넣어두었습니다)
  • 예시
events: {
  async signIn(message) { /* on sign in */ },
  async signOut(message) { /* on sign out */ },
  async createUser(message) { /* user created */ },
  async updateUser(message) { /* user updated */ },
  async linkAccount(message) { /* account linked */ },
  async session(message) { /* session is active */ },
}

 

6. handlers:

  • 설명: Next.js API 라우트에서 인증 관리를 위한 API 핸들러(GET, POST 함수) 객체입니다. 예를 들어, GET /api/auth/callback/:provider와 같은 라우트를 처리하는 데 사용됩니다.

7. signIn:

  • 설명: 사용자 로그인을 시도하는 비동기 함수입니다. provider와 credentials 같은 매개변수를 통해 로그인을 수행합니다.

8. signOut:

  • 설명: 사용자 로그아웃을 시도하는 비동기 함수입니다. 로그아웃 후 리다이렉션할 URL 등을 지정할 수 있습니다.

9. auth:

  • 설명: 세션 정보를 반환하는 비동기 함수입니다. 현재 사용자 세션을 확인하고 관리하는 데 사용됩니다.

10. unstable_update(update):

  • 설명: 세션 정보를 갱신하는 비동기 함수입니다. 현재는 실험적인 기능으로, 추후 정식 버전에서 이름이 update로 변경되거나 다른 방식으로 바뀔 수 있습니다.

 

 

 

 

 

 

 

 

 

3. NextAuth를 이용한 갱신 토큰 발급 및 업데이트 처리 방법 

 

1. NextAuth의 Session 및 Auth 업데이트 처리 방법

  • 서버 측 업데이트 처리: NextAuth의 callbacks 내에서 signIn, jwt, session 등의 콜백을 통해 세션과 JWT 토큰을 관리합니다.

    • signIn 콜백: OAuth 또는 자격 증명 로그인 후 서버에서 사용자 데이터와 토큰을 반환받아 사용자 객체에 병합.
    • jwt 콜백: JWT 토큰을 발급하거나 갱신. trigger와 session 파라미터를 통해 클라이언트에서 토큰 업데이트를 처리.
    • session 콜백: 세션을 클라이언트로 전달할 때 호출. 세션 정보에 토큰과 사용자 정보를 추가.
  • 클라이언트 측 업데이트 처리: useSession 훅을 사용하여 클라이언트 측에서 세션을 관리하고, 필요한 경우 토큰을 갱신하는 로직을 추가합니다.

 

const { data: session } = useSession();

 

 

2. NextAuth에서의 오류 처리와 로그아웃

  • NextAuth에서는 오류 발생 시 직접적인 로그아웃 처리를 할 수 없습니다. 따라서 RefreshTokenError와 같은 메시지를 전달하여 클라이언트 또는 미들웨어에서 로그아웃 처리 컴포넌트를 트리거해야 합니다.
  • RefreshTokenError 객체를 만들어 토큰 갱신 실패 시 반환하도록 처리함으로써 클라이언트 쪽에서 자동으로 로그아웃을 처리할 수 있습니다.

 

/app/api/auth/[...nextauth]/route.ts

const RefreshTokenError = {
  user: {},
  accessToken: null,
  refreshToken: null,
  accessTokenExpires: 0,
  error: 'RefreshTokenError',
  iat: 0,
  exp: 0,
  jti: '',
};

 

1) ClientSessionHandler.tsx  토큰만료시 클라이언트에서 로그아웃 처리 방법

// /components/auth/ClientSessionHandler.js
"use client";
import { CustomSession, CustomUser } from "@/app/api/auth/[...nextauth]/route";
import { signOut, useSession } from "next-auth/react";
import { useEffect } from "react";

//next Auth 에서 에러시 RefreshTokenError 코디 로그아웃처리를 해야 한다.
// 따라서, 다음 코드를 처리하지 않으면 무한 루프에 걸릴수 있다.
export default function ClientSessionHandler() {
  const { data: session } = useSession() as { data: CustomSession | null };  
    
  useEffect(() => {
     //갱신토큰 오류시 로그아웃 처리
    if (session?.error === "RefreshTokenError") {
      console.log(" 갱신토큰 오류시 로그아웃 처리 ");
      signOut({ callbackUrl: "/" });
    }

  }, [session]);

  return null;
}

 

2) middleware.ts 미들웨어에서 로그아웃 처리방법

'use client';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
import { CustomUser,  TokenType } from './app/api/auth/[...nextauth]/route';


function isTokenType(token: any): token is TokenType {
  return (
    token &&
    typeof token.iat === 'number' &&
    typeof token.exp === 'number' &&
    typeof token.jti === 'string'
  );
}

export const config = {
  matcher: [
    '/properties/add', '/properties/saved', '/messages',
    '/profile',    
    '/profile/:path*',    
    '/admin/:path*',
    '/boards/write',
    '/boards/update',
  ],
};

export async function middleware(req: NextRequest) {
  const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
  const { pathname } = req.nextUrl;
 
  // 토큰이 없거나 잘못된 형식일 경우 로그인 페이지로 리다이렉트
  if (!token || !isTokenType(token)) {
    console.log("토큰이 없거나 잘못된 형식일 경우 로그인 페이지로 리다이렉트");
    return NextResponse.redirect(new URL('/auth/signin', req.url));
  }

  // 토큰 갱신 에러 처리
  if (token.error === "RefreshTokenError") {
    console.log("????????미들웨어 auth/signout 처리 : token error");
    return NextResponse.redirect(new URL('/auth/signout', req.url));
  }


  // 관리자 경로 접근 권한 체크
  if (pathname.startsWith('/admin') && token.user) {
    const customUser = token.user as CustomUser ;
    if (customUser.role.toLowerCase()!== 'admin') {
      return NextResponse.redirect(new URL('/', req.url));
    }    
  }

  // 인증 및 회원가입 경로 접근 제한
  if ((pathname.startsWith('/auth') || pathname.startsWith('/auth/signin')) && token) {
    console.log(" 인증 및 회원가입 경로 접근 제한 " );
    return NextResponse.redirect(new URL('/', req.url));
  }

  return NextResponse.next();
}

 

 

3. 갱신 토큰 로직 및 $currentSessionToken 사용

  • NextAuth의 jwt와 session 콜백에서 $currentSessionToken 변수를 사용하여 토큰 갱신이 처리되지 않을 경우를 대비한 임시 저장소로 사용합니다.
  • $currentSessionToken 변수는 최신 갱신된 토큰을 저장하고, axiosInstance와 같은 요청 인터셉터에서도 사용될 수 있게 합니다.
  • refreshAccessToken 함수는 갱신 토큰을 이용해 새로운 접근 토큰을 서버로부터 받아오고, $currentSessionToken을 업데이트합니다.

 

export async function refreshAccessToken(token: any) {
  // 오류 방지를 위해 초기화
  $currentSessionToken = null;

  try {
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refreshAccessToken`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken: token.refreshToken }),
    });

    const responseData = await response.json();
    if (!response.ok || responseData.code === -1) {
      throw new Error(responseData.message);
    }

    const updateData = {
      ...token,
      accessToken: responseData.data.accessToken,
      refreshToken: responseData.data.refreshToken,
      accessTokenExpires: responseData.data.accessTokenExpires,
      expires: responseData.data.accessTokenExpires,
    };

    $currentSessionToken = updateData;
    return $currentSessionToken;
  } catch (error) {
    console.log('갱신 재발급 실패: 로그아웃 처리');
    return RefreshTokenError;
  }
}

 

 

4. Axios Interceptor를 사용한 토큰 갱신 처리

  • axiosInstance는 API 요청을 보내기 전에 세션에서 최신 토큰을 가져와 요청 헤더에 추가합니다.
  • 요청이 실패하고 응답 코드가 403인 경우, refreshAccessToken 함수를 호출하여 토큰을 갱신하고, 새로운 토큰으로 원래 요청을 재시도합니다.

 

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

    if (error.response.status === 403 && error.response.data.message === "ACCESS_TOKEN_EXPIRED" && !originalRequest._retry) {
      originalRequest._retry = true; // 무한반복 방지

      const session = await getServerSession(authOptions) as CustomSession | null;
      if (session) {
        const newSession = await refreshAccessToken(session);

        if (newSession && newSession.accessToken) {
          axios.defaults.headers.common['Authorization'] = `Bearer ${newSession.accessToken}`;
          originalRequest.headers['Authorization'] = `Bearer ${newSession.accessToken}`;
          return axiosInstance(originalRequest); // 원래 요청 재시도
        }

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

 

 

 

 

1) /app/api/auth/[...nextauth]/route.ts  전체소스

import { NextApiRequest, NextApiResponse } from 'next';
import NextAuth, { AuthOptions,  Session, User } from 'next-auth';
import GithubProvider from 'next-auth/providers/github';
import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider from 'next-auth/providers/google';
import { JWT } from 'next-auth/jwt';
import KakaoProvider from "next-auth/providers/kakao"; 
import NaverProvider from "next-auth/providers/naver"; 


export interface CustomUser extends User{
  id:string; //넥스트에서 생성한 키값
  name: string;
  email: string;
  image: string;
  memberId: number;
  userId: string;
  role: string;
}


//시스템에서는 보안을 위해 JWT 토큰에는 다음정보들만 저장한다.
export interface TokenType extends JWT {  
  name?: string; //사용자 이름
  email?:string;
  picture?:string;
  sub: string; //주체,  토큰이 발급된 사용자 또는 엔티티의 고유 식별자
  role: string; //사용자 권한
  accessToken: string;  //접근토큰
  refreshToken: string; //갱신토큰
  accessTokenExpires: number;  //백엔드 서버에서 발급한 만료시점  
  iat: number; //발급 시점
  exp: number; //만료시점
  jti: string; //JWT의 고유 식별자(ID)로, JWT가 중복 사용되지 않도록 방지하는 데 사용됩니다.
}



export interface CustomSession extends Session {
  user: CustomUser|{};
  accessToken: string|null;
  refreshToken: string|null;
  accessTokenExpires: number;
  error?: string;
  iat?:number,
  exp?:number,
  jti?:string;
}

export interface CustomToken {
  accessToken: string;
  refreshToken: string;
  accessTokenExpires: number;
  user: CustomUser |{}; // or a more specific type if possible
  error?: string;
  iat?:number,
  exp?:number,
  jti?:string;
}




// RefreshTokenError 인터페이스로 정의
interface RefreshTokenError {
  user: Record<string, any>;
  accessToken: string | null;
  refreshToken: string | null;
  accessTokenExpires: number;
  error: string;
  iat: number;
  exp: number;
  jti: string;
}

const RefreshTokenError: RefreshTokenError = {
  user: {},
  accessToken: null,
  refreshToken: null,
  accessTokenExpires: 0,
  error: 'RefreshTokenError',
  iat: 0,
  exp: 0,
  jti: '',
};

// 인증 옵션 구성
export const authOptions: AuthOptions = {
  secret: process.env.NEXTAUTH_SECRET,
  session: {
    strategy: 'jwt',
    maxAge: 60 * 60, // 1시간  백엔드 접근 토큰 만료시간과 일치 시킨다.
  },
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID as string,
      clientSecret: process.env.GITHUB_SECRET as string,
    }),
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID as string,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
      authorization: {
        params: {
          prompt: 'consent',
          access_type: 'offline',
          response_type: 'code',
          scope: 'openid email profile',
        },
      },
    }),
    KakaoProvider({
      clientId: process.env.KAKAO_CLIENT_ID as string,
      clientSecret: process.env.KAKAO_CLIENT_SECRET as string,
    }),
    NaverProvider({
      clientId: process.env.NAVER_CLIENT_ID as string,
      clientSecret: process.env.NAVER_CLIENT_SECRET as string,
    }),
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        userId: { label: 'User ID', type: 'text' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.userId || !credentials.password) {
          throw new Error('User ID 또는 Password가 누락되었습니다.');
        }
        
        const { userId, password } = credentials;
        try {
          const resData = await backEndLogin(userId, password);
          return resData;
        } catch (error) {
          if (error instanceof Error) {
            throw new Error(error.message);
          }
          throw new Error('로그인 오류');
        }
      },
    }),
  ],

  callbacks: {
    async signIn({ user, account, profile }): Promise<any> {
      if (['github', 'google', 'kakao', 'naver'].includes(account?.provider || '')) {
        const param = { provider: account?.provider, profile };

        // OAuth 로그인 후 백엔드에서 회원가입 처리 및 토큰 발행 처리
        const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/oauth2Process`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(param),
        });

        const response = await res.json();
        if (!res.ok) throw new Error(response.message);

        const data = response.data;
        const customUser = user as unknown as CustomUser;
        Object.assign(customUser, data); // 데이터를 커스텀 사용자 객체에 병합

        return customUser;
      }
      return true;
    },

    async jwt({ token, user , account, trigger, session  }: { token: any, user: any, account: any , 
        trigger?: "signIn" | "signUp" | "update" , session?: any }) {

      if (account&& user) {   
          //초기 로그인 시에만  account&& user 값이 들어가 있다. 로그인후 최초는 바로 반환처리한다.    
        return {
          accessToken: user.accessToken,
          refreshToken: user.refreshToken,
          accessTokenExpires: user.accessTokenExpires,
          user:{
            id: user.id,
            name: user.name,
            email: user.email,
            image: user.image,
            memberId: user.memberId,
            userId: user.userId,
            role: user.role,
          }
        } as CustomToken;
      } 
      

      //!!!다음은 클라이언트에서 useSession 을 이용한  token 및 session 업데이트 처리방법이다
      if (trigger === 'update' && session) {
        const { accessToken, refreshToken, accessTokenExpires , updateTarget } = session as any;
        console.log("\n\n⬆️⬆️⬆️⬆️⬆️update 회원정보 수정시 다음 로직  \n\n")
        if(updateTarget==="ACCESS_TOKEN_EXPIRED"){
          if (accessToken && refreshToken && accessTokenExpires) {
            $currentSessionToken  = {
              ...token,
              accessToken,
              refreshToken,
              accessTokenExpires
            }
          }         
          console.log("\n\n⬆️⬆️⬆️⬆️⬆️ ACCESS_TOKEN_EXPIRED updat????????????");
        }
      }

      //????token 가 정상적으로 업데이트 처리 될경우 $currentSessionToken 는 사용할 필요는 없다.
      //???? $currentSessionToken 에 값이 있을 경우 해당값으로 업데이트처리한다.
      if($currentSessionToken){
        token={
          user:$currentSessionToken?.user,
          accessToken: $currentSessionToken?.accessToken,
          refreshToken: $currentSessionToken?.refreshToken,
          accessTokenExpires: $currentSessionToken?.accessTokenExpires,
          error: $currentSessionToken?.error
        }      
      }

      console.log("====== ============ token.refreshToken :",  token.refreshToken);

      // 접근 토큰이 유효한 경우 반환
      if (typeof token.accessTokenExpires === 'number' && Date.now() < token.accessTokenExpires * 1000) {        
        console.log("\n\n************** 접근 토큰이 유효한 경우 기존 토큰반환됨 :통과 ****************\n\n");
        return token;
      }

      // 접근 토큰이 만료된 경우 갱신   
      console.log("????nextAuth 콜백에서 refreshAccessToken 함수 호출 ") ;
      $currentSessionToken= await refreshAccessToken(token);                 
      return $currentSessionToken;               
    },


 
    async session({ session, token }) {
      const customSession = session  as CustomSession;
      
      if(token){
        if(token.accessToken){
          customSession.user = token.user as  CustomUser;
          customSession.accessToken = token.accessToken as string;
          customSession.refreshToken = token.refreshToken as string;
          customSession.accessTokenExpires = token.accessTokenExpires as number;
          customSession.error = token.error as string;
        }else{  
          customSession.user = {};   
          customSession.accessToken =null;
          customSession.refreshToken = null;
          customSession.accessTokenExpires = 0;
          customSession.error = "RefreshTokenError"
          token.user={}
        }        
      }                  
      return customSession;
    },

  },

  pages: {
    signIn: '/auth/signin', // 로그인 페이지 경로 설정
    signOut: '/auth/signout', // 로그아웃 페이지 경로 설정
    error: '/auth/error', // 쿼리 문자열에 ?error=로 전달된 오류 코드
    verifyRequest: '/auth/verify-request', // (이메일 메시지 확인에 사용됨)
    newUser: '/auth/new-user' // 신규 사용자는 처음 로그인할 때 여기로 이동됩니다
  },


};


export const GET = async (req: NextApiRequest, res: NextApiResponse) => {
  return NextAuth(req, res, authOptions);
};

export const POST = async (req: NextApiRequest, res: NextApiResponse) => {
  return NextAuth(req, res, authOptions);
};

export const PUT = async (req: NextApiRequest, res: NextApiResponse) => {
  return NextAuth(req, res, authOptions);
};

export const PATCH = async (req: NextApiRequest, res: NextApiResponse) => {
  return NextAuth(req, res, authOptions);
};

export const DELETE = async (req: NextApiRequest, res: NextApiResponse) => {
  return NextAuth(req, res, authOptions);
};




// Backend 서버 로그인 처리 함수
async function backEndLogin(userId: string, password: string): Promise<User | null> {
  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId, password }),
  });

  const data = await res.json();
  if (!res.ok) throw new Error(data.message);

  return {
    id: data.memberId,
    memberId: data.memberId,
    userId: data.userId,
    name: data.name,
    email: data.email,
    role: data.role,
    accessToken: data.accessToken,
    refreshToken: data.refreshToken,
    accessTokenExpires: data.accessTokenExpires,
  } as User;
}




//???? 전역변수 사용이라 위험성에 유의 ????
let $currentSessionToken: any  = null;

// 접근 토큰 갱신 함수
export async function refreshAccessToken(token: any) {
  $currentSessionToken=null;

  try {
    console.log("***** ???? Refreshing Access Token ????:", token);
  
    const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/auth/refreshAccessToken`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken: token.refreshToken }),
    });


    const responseData = await response.json();
    if (!response.ok || responseData.code === -1) {      
      throw new Error(`Failed to refresh token: ${responseData.message}`);
    }

    console.log("\n\n????????재발급성공????????\n\n")    ;
    const updateData={
      ...token,
      accessToken: responseData.data.accessToken,
      refreshToken: responseData.data.refreshToken,
      accessTokenExpires: responseData.data.accessTokenExpires,
      expires: responseData.data.accessTokenExpires 
    };

  
    $currentSessionToken= updateData;
    return  $currentSessionToken;
       
  } catch (error) {
    console.log('❌ 갱신 재발급 실패: 로그아웃 처리❌');      
    return RefreshTokenError;      
  }
}





 

 

 

2) /utils/axiosInstance.js  전체 소스

// /utils/axiosInstance.js
import axios from 'axios';
import { getServerSession } from 'next-auth';
import { authOptions, CustomSession, refreshAccessToken } from '@/app/api/auth/[...nextauth]/route';

const axiosInstance = axios.create({
  baseURL: `${process.env.NEXT_PUBLIC_API_URL}`,  // 백엔드 서버의 API URL
});

axiosInstance.interceptors.request.use(
  async (config) => {
    const session = await getServerSession(authOptions) as CustomSession | null;
    if (session?.accessToken) {
      config.headers['Content-Type'] = 'application/json';
      config.headers['Authorization'] = `Bearer ${session.accessToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

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

    // 토큰 만료 시 갱신
    if (error.response.status === 403 && error.response.data.message === "ACCESS_TOKEN_EXPIRED" && !originalRequest._retry) {
      originalRequest._retry = true;//무한반복 방지

      const session = await getServerSession(authOptions) as CustomSession | null;
      if (session) {
        
        //NextAuth 에서 갱신토큰를 받아옴
        console.log("????axiosInstance 에서 refreshAccessToken 함수 호출 ") ;
        const newSession = await refreshAccessToken(session);

        if (newSession && newSession.accessToken) {
          // 원래 요청의 헤더를 새로운 접근 토큰으로 업데이트
          originalRequest.headers['Authorization'] = `Bearer ${newSession.accessToken}`;
          return axiosInstance(originalRequest); // 원래 요청 재시도
        } 

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



export default axiosInstance;

 

 

 

 

 

결론

NextAuth를 활용한 인증 및 토큰 갱신 로직은 다양한 콜백과 함수를 활용하여 구성됩니다. 서버 측과 클라이언트 측 모두에서의 처리 방법을 명확히 이해하고, 필요한 곳에서 오류를 처리하여 사용자 경험을 최적화할 수 있습니다. $currentSessionToken과 같은 전역 변수를 사용해 문제를 해결할 수 있지만, 가능한 한 안전하게 사용하고, 코드가 예기치 않게 작동하지 않도록 주의해야 합니다.

추가적으로, NextAuth의 공식 가이드와 업데이트 문서를 참고하여 지속적으로 최신 버전의 보안 및 인증 전략을 반영하는 것이 중요합니다.

자세한 내용은 NextAuth 공식 문서에서 확인하실 수 있습니다.

 

 

 

 

 

 

백엔드  exress  참고

 

controllers/api/authController.ts

import { RequestHandler } from 'express';
import {
  addMemberModel,
  existingUserIdModel,
  getEmailMemberInfoModel,
  getMemberInfoModel,
} from '@/models/memberModel';
import {
  createAccessToken,
  createRefreshToken,
  isValidPassword,
  storeRefreshToken,
  getStoredRefreshToken,
  validateRefreshToken,
  deleteRefreshToken,
  storeLogoutInfo,
  validateAccessToken,
} from '@/util/auth';
import { isValidText } from '@/util/validation';
import { ResponseDTO } from '@/helper/ResponseDTO';

interface SignupData {
  userId: string;
  email: string;
  name: string;
  password: string;
  [key: string]: any;
}

/**
 * 사용자 가입 처리
 * @param req - Express 요청 객체
 * @param res - Express 응답 객체
 * @param next - 다음 미들웨어 함수
 */
export const signup: RequestHandler = async (req, res) => {
  const data: SignupData = req.body;
  const errors: Record<string, string> = {};

  try {
    const existingUserId = await existingUserIdModel(data.userId);
    if (existingUserId) {
      errors.userId = "아이디가 이미 존재합니다.";
      return res.status(422).json(ResponseDTO(-1, "아이디가 이미 존재합니다", errors));
    }

    const existingEmailMember = await getEmailMemberInfoModel(data.email);
    if (existingEmailMember) {
      errors.email = "이메일이 이미 존재합니다.";
      return res.status(422).json(ResponseDTO(-1, "이메일이 이미 존재합니다.", errors));
    }
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : "Unknown error";
    return res.status(500).json(ResponseDTO(-1, "서버 오류로 인해 요청을 처리할 수 없습니다.", { general: errorMessage }));
  }

  if (!isValidText(data.password, 4)) {
    errors.password = "유효하지 않은 비밀번호. 길이는 4자 이상이어야 합니다.";
    return res.status(422).json(ResponseDTO(-1, "유효하지 않은 비밀번호. 길이는 4자 이상이어야 합니다.", errors));
  }

  if (Object.keys(errors).length > 0) {
    return res.status(422).json(ResponseDTO(-1, "유효성 검사 오류로 인해 사용자 가입에 실패했습니다.", errors));
  }

  try {
    const createdMember = await addMemberModel(data);
    const authToken = createAccessToken(createdMember.memberId);
    return res.status(201).json(ResponseDTO(1, "사용자가 생성되었습니다.", null, {
      member: createdMember,
      token: authToken
    }));
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : "Unknown error";
    return res.status(500).json(ResponseDTO(-1, "사용자 생성 중 오류가 발생했습니다.", { general: errorMessage }));
  }
};






/**
 * 사용자 로그인 처리
 * @param req - Express 요청 객체
 * @param res - Express 응답 객체
 */
export const login: RequestHandler = async (req, res) => {
  const { userId, password } = req.body;
  console.log("로그인 처리:", userId, password);

  try {
    const member = await existingUserIdModel(userId);

    if (member) {
      const pwIsValid = await isValidPassword(password, member.password);

      if (!pwIsValid) {ResponseDTO(-1, "잘못된 자격 증명.", { credentials: "비밀번호가 일치하지 않습니다." })
        return res.status(422).json();
      }

      const { accessToken: newAccessToken, accessTokenExpires} = createAccessToken(member.memberId);
      const { refreshToken: newRefreshToken, refreshTokenExpires} = createRefreshToken(member.memberId);

      //갱신 토큰을 Redis 에 저장
      console.log("****2. 사용자 로그인 처리 갱신 토큰을 Redis 에 저장  :", newRefreshToken, member.memberId);
      await storeRefreshToken(newRefreshToken, member.memberId);

      const returnData = {
        memberId: member.memberId,
        userId: member.userId,
        name: member.name,
        email: member.email,
        role: member.role,
        accessToken: newAccessToken,
        refreshToken: newRefreshToken,
        accessTokenExpires:accessTokenExpires
      };

      return res.status(200).json(ResponseDTO(1, "로그인 성공.", null, returnData));
    }

    return res.status(401).json(ResponseDTO(-1, "등록되지 않은 아이디 입니다.", null));
  } catch (error) {
    return res.status(401).json(ResponseDTO(-1, "인증 실패.", null));
  }
};





//1.????접근 토큰 만료시간이 5분 이내인지 확인
export const checkAccessToken5min: RequestHandler = (req, res) => {
  console.log("!접근 토큰 만료시간이 5분 이내인지 확인 :");
  const { accessToken } = req.body;
  if (!accessToken) {
    return res.status(400).json(ResponseDTO(-1, "액세스 토큰이 제공되지 않았습니다.", null));
  }

  try {
    const decoded = validateAccessToken(accessToken);
    const currentTime = new Date().getTime() / 1000;

    // 300초(5분) 이내에 만료되는지 확인
    if (decoded.expiresIn - currentTime < 300) {
      console.log("!접근 토큰 만료시간이 5분 미만:");
      return res.status(401).json(ResponseDTO(-1, "액세스 토큰이 5분 이내에 만료됩니다.", ));
    } else {
      return res.status(200).json(ResponseDTO(1, "액세스 토큰이 유효합니다.", null));
    }
  } catch (error) {
    return res.status(401).json(ResponseDTO(-1, "인증 실패.", null));
  }
}


/**
 * 2.갱신토큰으로 재발급 처리 
 * @param req - Express 요청 객체
 * @param res - Express 응답 객체
 */
export const refreshAccessToken: RequestHandler = async (req, res) => {
  const { refreshToken } = req.body;

  console.log("1.갱신토큰으로 재발급 처리    :", refreshToken);
  
  if (!refreshToken) {
    return res.status(401).json(ResponseDTO(-1, '토큰이 제공되지 않았습니다.', null));
  }

  
  try {
    //갱신 토큰 유효성 검사
    const decoded = await validateRefreshToken(refreshToken);
    console.log("2.갱신 토큰 유효성 검사   :", decoded);

    const storedToken = await getStoredRefreshToken(decoded.memberId);
    console.log("3.redis 에 저장된 갱신토큰 값 가져오기   :", storedToken);


    if (storedToken !== refreshToken) {
      console.log("4.ERROR 유효하지 않은 토큰입니다");
      return res.status(403).json(ResponseDTO(-1, '유효하지 않은 토큰입니다.', null));
    }else{
        //갱신 토큰이 맞다면 접근토큰 및 갱신 토큰을 재발급 처리를 진행한다
        console.log("4.SUCCESS : 갱신 토큰이 맞다면 접근토큰 및 갱신 토큰을 재발급 처리를 진행한다");
        const { accessToken: newAccessToken, accessTokenExpires} =  createAccessToken(decoded.memberId);
        const { refreshToken: newRefreshToken, refreshTokenExpires }  = createRefreshToken(decoded.memberId);
        console.log("****5. SUCCESS : refreshToken  리프레시 토큰 처리 ==갱신 토큰 을 redis 저장 : " ,decoded.memberId , newRefreshToken)
        await storeRefreshToken(newRefreshToken, decoded.memberId);

        const member = await getMemberInfoModel(decoded.memberId);

        const returnData = {
          memberId: member.memberId,
          userId: member.userId,
          email: member.email,
          name: member.name,
          role: member.role,
          accessToken: newAccessToken,
          refreshToken: newRefreshToken,
          accessTokenExpires:accessTokenExpires
        };

        console.log("****6.SUCCESS ???? refreshToken  토큰이 갱신되었습니다: " ,returnData)
        return res.status(200).json(ResponseDTO(1, "토큰이 갱신되었습니다.", null, returnData));
    }


  } catch (err) {
    console.log( "갱신토큰으로 접근토큰 재발급처리오류  : " ,err);
    return res.status(403).json(ResponseDTO(-1, '유효하지 않은 토큰입니다.', null));
  }
};








/**
 * 사용자 로그아웃 처리
 * @param req - Express 요청 객체
 * @param res - Express 응답 객체
 */
export const logout: RequestHandler = async (req, res) => {
  const { userId, refreshToken } = req.body;

  console.log("로그아웃 처리:", userId, refreshToken);
  if (!userId || !refreshToken) {
    return res.status(400).json(ResponseDTO(-1, "이메일 또는 리프레시 토큰이 제공되지 않았습니다.", null));
  }

  try {
    const member = await existingUserIdModel(userId);
    await deleteRefreshToken(member.memberId);
    await storeLogoutInfo(member.memberId, refreshToken);

    return res.status(200).json(ResponseDTO(1, "로그아웃 성공.", null));
  } catch (error) {
    return res.status(500).json(ResponseDTO(-1, "로그아웃 실패.", null));
  }
};

 

 

utils/auth.ts

import { sign, verify,decode     } from 'jsonwebtoken';
import { compare } from 'bcryptjs';
import redisClient from '@/lib/redis';
import { NotFoundError } from './errors';

// 반환 타입 정의
interface AccessTokenResponse {
  accessToken: string;
  accessTokenExpires: number;
}

interface RefreshTokenResponse {
  refreshToken: string;
  refreshTokenExpires: number;
}


// 암호화 키를 설정합니다.
const KEY = process.env.JWT_SECRET_KEY as string;
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET as string;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET as string;

//const ACCESS_TOKEN_EXPIRATION = '1h'; // 1시간
const ACCESS_TOKEN_EXPIRATION = '1m'; // 1분
const REFRESH_TOKEN_EXPIRATION = 14 * 24 * 60 * 60; // 14일 (초)

//1. 입력받은 비밀번호와 저장된 비밀번호를 비교하는 함수입니다.
function isValidPassword(password: string, storedPassword: string): Promise<boolean> {
  return compare(password, storedPassword);
}

//2.접근 토큰 생성 : 1시간

function createAccessToken(memberId: string): AccessTokenResponse  {
  console.log("2.접근 토큰 생성 :", memberId, ACCESS_TOKEN_SECRET, ACCESS_TOKEN_EXPIRATION);
  
  // 접근 토큰 생성
  const accessToken = sign({ memberId }, ACCESS_TOKEN_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRATION });

  // 토큰 디코딩
  const decodedToken = decode(accessToken) as { exp: number };
  
  // 만료시간 추출 (UNIX 타임스탬프)
  const accessTokenExpires = decodedToken.exp;


  return {accessToken, accessTokenExpires};
}



//3.갱신 토큰 생성 : 14일
function createRefreshToken(memberId: string): RefreshTokenResponse {

  const refreshToken = sign({ memberId }, REFRESH_TOKEN_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRATION });  
    // 토큰 디코딩
  const decodedToken = decode(refreshToken) as { exp: number };

  const refreshTokenExpires = decodedToken.exp;

  return {refreshToken, refreshTokenExpires};
}




//4.접근 토큰 유효성 검사
function validateAccessToken(token: string): any {  
  return verify(token, ACCESS_TOKEN_SECRET);
}




//5. 갱신 토큰 유효성 검사
function validateRefreshToken(token: string): any {
  return verify(token, REFRESH_TOKEN_SECRET);
}


//6.갱신 토큰을 Redis에 저장하는 함수
async function storeRefreshToken(refreshToken: string, memberId: string): Promise<void> {
  try {
    const key = `refreshTokenInfo:${memberId}`;
    await redisClient.set(key, refreshToken, {EX: REFRESH_TOKEN_EXPIRATION});
    console.log(" //6.갱신 토큰을 Redis에 저장하는 함수 :", key, refreshToken, REFRESH_TOKEN_EXPIRATION);
  } catch (error) {
    console.error('Failed to store refresh token:', error);
    throw new Error('Failed to store refresh token');
  }
}

//7.저장된 Redis 값을 가져오는 함수
async function getStoredRefreshToken(memberId: string): Promise<string | null> {
  try {
    const key = `refreshTokenInfo:${memberId}`;
    return await redisClient.get(key);
  } catch (error) {
    console.error('Failed to retrieve refresh token:', error);
    throw new Error('Failed to retrieve refresh token');
  }
}

//8.인증 미들웨어 함수
async function checkAuthMiddleware(req: any, res: any, next: any): Promise<void> {
  
  console.log("미들웨어 인증 처리 :");

  if (req.method === 'OPTIONS') {
    console.log("OPTIONS :");
    return next();
  }
  if (!req.headers.authorization) {
    console.log("not-authenticated-1 :");
    return next(new NotFoundError('not-authenticated-1'));
  }

  const authFragments = req.headers.authorization.split(' ');

  if (authFragments.length !== 2) {
    console.log("not-authenticated-2 :");
    return next(new NotFoundError('not-authenticated-2'));
  }

  const authToken = authFragments[1];
  try {
    const validatedToken = validateAccessToken(authToken);
    req.token = validatedToken;
    req.memberId=validatedToken.memberId;

  } catch (error) {
    console.log("not-authenticated-3:", error);
    console.log("not-authenticated-3 error.message):", error.message);
    if(error.message=== 'jwt expired'){
      console.log("접근 토큰 인증 만료");
      return res.status(403).json({ message: 'ACCESS_TOKEN_EXPIRED' });
    }
    return next(new NotFoundError('not-authenticated-3'));
  }
  next();
}



//9.로그아웃 정보를 저장하는 함수
async function storeLogoutInfo(memberId: string, refreshToken: string): Promise<void> {
  const key = `logoutInfo:${memberId}`;
  const logoutTimeMilliseconds = Date.now();
  const logoutTime = new Date(logoutTimeMilliseconds).toLocaleString();

  const logoutInfo = {
    memberId,
    refreshToken,
    logoutTimeMilliseconds,
    logoutTime,
  };

  await redisClient.set(key, JSON.stringify(logoutInfo));
}


//10. 로그아웃 시 갱신 토큰 삭제
async function deleteRefreshToken(memberId: string): Promise<void> {
  const key = `refreshTokenInfo:${memberId}`;
  await redisClient.del(key);
}



// 11. 접근 토큰 파싱 함수
async function parseAccessToken(req: any, res: any, next: any) {
  if (req.method === 'OPTIONS') {
    return next();
  }
  if (!req.headers.authorization) {
    return next(new NotFoundError('not-authenticated'));
  }

  const authFragments = req.headers.authorization.split(' ');

  if (authFragments.length !== 2) {
    return next(new NotFoundError('not-authenticated'));
  }

  const authToken = authFragments[1];
  try {
    const validatedToken = validateAccessToken(authToken);
    //ex) 반환값 : { memberId: 6, iat: 1722000116, exp: 1722003716 }
    req.user=validatedToken;
    req.memberId=validatedToken.memberId;
    next();
  } catch (error) {
    return next(new NotFoundError('not-authenticated'));
  }

}









// 이메일을 받아 JSON Web Token을 생성하는 함수
function createJSONToken(email: string): string {
  return sign({ email }, KEY, { expiresIn: '1h' }); // 유효기간 1시간
}

// 토큰을 받아 유효성을 검증하는 함수
function validateJSONToken(token: string): any {
  return verify(token, KEY);
}














// 인증 미들웨어 함수
function checkAuthMiddlewareAlone(req: any, res: any, next: any) {
  if (req.method === 'OPTIONS') {
    return next();
  }
  if (!req.headers.authorization) {
    return next(new NotFoundError('인증되지 않았습니다.'));
  }

  const authFragments = req.headers.authorization.split(' ');

  if (authFragments.length !== 2) {
    return next(new NotFoundError('인증되지 않았습니다.'));
  }

  const authToken = authFragments[1];
  try {
    const validatedToken = validateJSONToken(authToken);
    req.token = validatedToken;
  } catch (error) {
    return next(new NotFoundError('인증되지 않았습니다.'));
  }
  next();
}



// 함수들을 내보냅니다.
export {
  createAccessToken,
  createRefreshToken,
  validateAccessToken,
  validateRefreshToken,
  isValidPassword,
  storeRefreshToken,
  getStoredRefreshToken,
  checkAuthMiddleware,
  deleteRefreshToken,
  storeLogoutInfo,
  parseAccessToken,
};

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

군자는 어떤 경우를 당하더라도 마음이 너그럽고 평탄하고 소인은 항상 근심에 차 있다. -논어

댓글 ( 0)

댓글 남기기

작성