토큰 자동 갱신 기능 추가 및 에러 처리 개선
This commit is contained in:
parent
47552bc35c
commit
cbe5cb4607
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 || "토큰 검증에 실패했습니다.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null> | null = null;
|
||||
|
||||
// 토큰 갱신 함수
|
||||
const refreshToken = async (): Promise<string | null> => {
|
||||
// 이미 갱신 중이면 기존 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() ? "존재" : "없음",
|
||||
});
|
||||
|
||||
// 토큰 만료 에러인 경우 갱신 시도
|
||||
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 에러 시 토큰 제거 및 로그인 페이지로 리다이렉트
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
// 토큰 갱신 실패 또는 다른 401 에러인 경우 로그아웃
|
||||
TokenManager.removeToken();
|
||||
|
||||
// 로그인 페이지가 아닌 경우에만 리다이렉트
|
||||
if (window.location.pathname !== "/login") {
|
||||
console.log("[Auth] 로그인 페이지로 리다이렉트");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue