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) 로직:
로그인 및 토큰 생성:
- 사용자가 로그인하면, 서버는 **접근 토큰(Access Token)**과 **갱신 토큰(Refresh Token)**을 생성합니다.
- 생성된 두 토큰을 프론트엔드(클라이언트)로 반환합니다.
토큰 저장:
- **접근 토큰(Access Token)**은 클라이언트 측(예: 브라우저의 메모리나 쿠키)에 저장되어, 서버와의 인증 요청에 사용됩니다.
- **갱신 토큰(Refresh Token)**은 보안이 중요한 데이터이므로 서버 측의 Redis와 같은 인메모리 데이터 저장소에 저장합니다. Redis를 사용하는 이유는 빠른 속도와 가벼운 특성 덕분에 효율적인 토큰 관리가 가능하기 때문입니다.
토큰 유효 기간 설정:
- **접근 토큰(Access Token)**의 유효 기간은 보통 1시간으로 설정됩니다.
- **갱신 토큰(Refresh Token)**의 유효 기간은 보통 2주로 설정됩니다.
접근 토큰 만료 및 재발급:
- **접근 토큰(Access Token)**은 서버와의 인증 요청 시 사용되며, 1시간 동안 유효합니다.
- 만약 접근 토큰이 만료되면, 클라이언트는 **갱신 토큰(Refresh Token)**을 사용해 서버에 새로운 접근 토큰을 요청합니다.
갱신 토큰 인증 및 재발급:
- 서버는 클라이언트로부터 받은 **갱신 토큰(Refresh Token)**을 Redis에 저장된 토큰과 비교하여 일치하는지 확인합니다.
- 토큰이 일치하면, 서버는 새로운 **접근 토큰(Access Token)**과 **갱신 토큰(Refresh Token)**을 생성하여 Redis에 갱신된 토큰을 저장합니다.
- 생성된 토큰들은 다시 클라이언트로 전송됩니다.
토큰 오류 처리:
- 갱신 토큰이 유효하지 않거나 만료되었을 경우, 또는 다른 오류가 발생하면 사용자를 로그아웃 처리하여 재인증을 요구합니다.
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, };
댓글 ( 0)
댓글 남기기