From cbe5cb4607f4863bb71f6cce7ae92a7c6180233e Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 5 Dec 2025 17:46:22 +0900 Subject: [PATCH] =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 5 + backend-node/src/middleware/authMiddleware.ts | 13 +- frontend/lib/api/client.ts | 201 ++++++++++++++++-- 3 files changed, 199 insertions(+), 20 deletions(-) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d214c19a..d36ad8c3 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -8,6 +8,7 @@ import path from "path"; import config from "./config/environment"; import { logger } from "./utils/logger"; import { errorHandler } from "./middleware/errorHandler"; +import { refreshTokenIfNeeded } from "./middleware/authMiddleware"; // 라우터 임포트 import authRoutes from "./routes/authRoutes"; @@ -168,6 +169,10 @@ const limiter = rateLimit({ }); app.use("/api/", limiter); +// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용) +// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함 +app.use("/api/", refreshTokenIfNeeded); + // 헬스 체크 엔드포인트 app.get("/health", (req, res) => { res.status(200).json({ diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index a54c64c6..6d8c7bda 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -54,16 +54,17 @@ export const authenticateToken = ( next(); } catch (error) { - logger.error( - `인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})` - ); + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.error(`인증 실패: ${errorMessage} (${req.ip})`); + // 토큰 만료 에러인지 확인 + const isTokenExpired = errorMessage.includes("만료"); + res.status(401).json({ success: false, error: { - code: "INVALID_TOKEN", - details: - error instanceof Error ? error.message : "토큰 검증에 실패했습니다.", + code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN", + details: errorMessage || "토큰 검증에 실패했습니다.", }, }); } diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 7dc811c9..f4a3ccf7 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -58,6 +58,18 @@ const TokenManager = { return null; }, + setToken: (token: string): void => { + if (typeof window !== "undefined") { + localStorage.setItem("authToken", token); + } + }, + + removeToken: (): void => { + if (typeof window !== "undefined") { + localStorage.removeItem("authToken"); + } + }, + isTokenExpired: (token: string): boolean => { try { const payload = JSON.parse(atob(token.split(".")[1])); @@ -66,8 +78,147 @@ const TokenManager = { return true; } }, + + // 토큰이 곧 만료되는지 확인 (30분 이내) + isTokenExpiringSoon: (token: string): boolean => { + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const expiryTime = payload.exp * 1000; + const currentTime = Date.now(); + const thirtyMinutes = 30 * 60 * 1000; // 30분 + return expiryTime - currentTime < thirtyMinutes && expiryTime > currentTime; + } catch { + return false; + } + }, + + // 토큰 만료까지 남은 시간 (밀리초) + getTimeUntilExpiry: (token: string): number => { + try { + const payload = JSON.parse(atob(token.split(".")[1])); + return payload.exp * 1000 - Date.now(); + } catch { + return 0; + } + }, }; +// 토큰 갱신 중복 방지 플래그 +let isRefreshing = false; +let refreshPromise: Promise | null = null; + +// 토큰 갱신 함수 +const refreshToken = async (): Promise => { + // 이미 갱신 중이면 기존 Promise 반환 + if (isRefreshing && refreshPromise) { + return refreshPromise; + } + + isRefreshing = true; + refreshPromise = (async () => { + try { + const currentToken = TokenManager.getToken(); + if (!currentToken) { + return null; + } + + const response = await axios.post( + `${API_BASE_URL}/auth/refresh`, + {}, + { + headers: { + Authorization: `Bearer ${currentToken}`, + }, + } + ); + + if (response.data?.success && response.data?.data?.token) { + const newToken = response.data.data.token; + TokenManager.setToken(newToken); + console.log("[TokenManager] 토큰 갱신 성공"); + return newToken; + } + return null; + } catch (error) { + console.error("[TokenManager] 토큰 갱신 실패:", error); + return null; + } finally { + isRefreshing = false; + refreshPromise = null; + } + })(); + + return refreshPromise; +}; + +// 자동 토큰 갱신 타이머 +let tokenRefreshTimer: NodeJS.Timeout | null = null; + +// 자동 토큰 갱신 시작 +const startAutoRefresh = (): void => { + if (typeof window === "undefined") return; + + // 기존 타이머 정리 + if (tokenRefreshTimer) { + clearInterval(tokenRefreshTimer); + } + + // 10분마다 토큰 상태 확인 + tokenRefreshTimer = setInterval(async () => { + const token = TokenManager.getToken(); + if (token && TokenManager.isTokenExpiringSoon(token)) { + console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작..."); + await refreshToken(); + } + }, 10 * 60 * 1000); // 10분 + + // 페이지 로드 시 즉시 확인 + const token = TokenManager.getToken(); + if (token && TokenManager.isTokenExpiringSoon(token)) { + refreshToken(); + } +}; + +// 사용자 활동 감지 및 토큰 갱신 +const setupActivityBasedRefresh = (): void => { + if (typeof window === "undefined") return; + + let lastActivity = Date.now(); + const activityThreshold = 5 * 60 * 1000; // 5분 + + const handleActivity = (): void => { + const now = Date.now(); + // 마지막 활동으로부터 5분 이상 지났으면 토큰 상태 확인 + if (now - lastActivity > activityThreshold) { + const token = TokenManager.getToken(); + if (token && TokenManager.isTokenExpiringSoon(token)) { + refreshToken(); + } + } + lastActivity = now; + }; + + // 사용자 활동 이벤트 감지 + ["click", "keydown", "scroll", "mousemove"].forEach((event) => { + // 너무 잦은 호출 방지를 위해 throttle 적용 + let throttleTimer: NodeJS.Timeout | null = null; + window.addEventListener(event, () => { + if (!throttleTimer) { + throttleTimer = setTimeout(() => { + handleActivity(); + throttleTimer = null; + }, 1000); // 1초 throttle + } + }, { passive: true }); + }); +}; + +// 클라이언트 사이드에서 자동 갱신 시작 +if (typeof window !== "undefined") { + startAutoRefresh(); + setupActivityBasedRefresh(); +} + // Axios 인스턴스 생성 export const apiClient = axios.create({ baseURL: API_BASE_URL, @@ -138,9 +289,15 @@ apiClient.interceptors.request.use( // 응답 인터셉터 apiClient.interceptors.response.use( (response: AxiosResponse) => { + // 백엔드에서 보내주는 새로운 토큰 처리 + const newToken = response.headers["x-new-token"]; + if (newToken) { + TokenManager.setToken(newToken); + console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료"); + } return response; }, - (error: AxiosError) => { + async (error: AxiosError) => { const status = error.response?.status; const url = error.config?.url; @@ -153,7 +310,7 @@ apiClient.interceptors.response.use( } // 일반 409 에러는 간단한 로그만 출력 - console.warn("⚠️ 데이터 중복:", { + console.warn("데이터 중복:", { url: url, message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.", }); @@ -161,7 +318,7 @@ apiClient.interceptors.response.use( } // 다른 에러들은 기존처럼 상세 로그 출력 - console.error("❌ API 응답 오류:", { + console.error("API 응답 오류:", { status: status, statusText: error.response?.statusText, url: url, @@ -170,24 +327,40 @@ apiClient.interceptors.response.use( headers: error.config?.headers, }); - // 401 에러 시 상세 정보 출력 - if (status === 401) { - console.error("🚨 401 Unauthorized 오류 상세 정보:", { + // 401 에러 처리 + if (status === 401 && typeof window !== "undefined") { + const errorData = error.response?.data as { error?: { code?: string } }; + const errorCode = errorData?.error?.code; + + console.warn("[Auth] 401 오류 발생:", { url: url, - method: error.config?.method, - headers: error.config?.headers, - requestData: error.config?.data, - responseData: error.response?.data, + errorCode: errorCode, token: TokenManager.getToken() ? "존재" : "없음", }); - } - // 401 에러 시 토큰 제거 및 로그인 페이지로 리다이렉트 - if (status === 401 && typeof window !== "undefined") { - localStorage.removeItem("authToken"); + // 토큰 만료 에러인 경우 갱신 시도 + const originalRequest = error.config as typeof error.config & { _retry?: boolean }; + if (errorCode === "TOKEN_EXPIRED" && originalRequest && !originalRequest._retry) { + console.log("[Auth] 토큰 만료, 갱신 시도..."); + originalRequest._retry = true; + + try { + const newToken = await refreshToken(); + if (newToken && originalRequest) { + originalRequest.headers.Authorization = `Bearer ${newToken}`; + return apiClient.request(originalRequest); + } + } catch (refreshError) { + console.error("[Auth] 토큰 갱신 실패:", refreshError); + } + } + + // 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃 + TokenManager.removeToken(); // 로그인 페이지가 아닌 경우에만 리다이렉트 if (window.location.pathname !== "/login") { + console.log("[Auth] 로그인 페이지로 리다이렉트"); window.location.href = "/login"; } }