토큰 자동 갱신 기능 추가 및 에러 처리 개선
This commit is contained in:
parent
47552bc35c
commit
cbe5cb4607
|
|
@ -8,6 +8,7 @@ import path from "path";
|
||||||
import config from "./config/environment";
|
import config from "./config/environment";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
import { errorHandler } from "./middleware/errorHandler";
|
import { errorHandler } from "./middleware/errorHandler";
|
||||||
|
import { refreshTokenIfNeeded } from "./middleware/authMiddleware";
|
||||||
|
|
||||||
// 라우터 임포트
|
// 라우터 임포트
|
||||||
import authRoutes from "./routes/authRoutes";
|
import authRoutes from "./routes/authRoutes";
|
||||||
|
|
@ -168,6 +169,10 @@ const limiter = rateLimit({
|
||||||
});
|
});
|
||||||
app.use("/api/", limiter);
|
app.use("/api/", limiter);
|
||||||
|
|
||||||
|
// 토큰 자동 갱신 미들웨어 (모든 API 요청에 적용)
|
||||||
|
// 토큰이 1시간 이내에 만료되는 경우 자동으로 갱신하여 응답 헤더에 포함
|
||||||
|
app.use("/api/", refreshTokenIfNeeded);
|
||||||
|
|
||||||
// 헬스 체크 엔드포인트
|
// 헬스 체크 엔드포인트
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
|
|
|
||||||
|
|
@ -54,16 +54,17 @@ export const authenticateToken = (
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
`인증 실패: ${error instanceof Error ? error.message : "Unknown error"} (${req.ip})`
|
logger.error(`인증 실패: ${errorMessage} (${req.ip})`);
|
||||||
);
|
|
||||||
|
|
||||||
|
// 토큰 만료 에러인지 확인
|
||||||
|
const isTokenExpired = errorMessage.includes("만료");
|
||||||
|
|
||||||
res.status(401).json({
|
res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: {
|
error: {
|
||||||
code: "INVALID_TOKEN",
|
code: isTokenExpired ? "TOKEN_EXPIRED" : "INVALID_TOKEN",
|
||||||
details:
|
details: errorMessage || "토큰 검증에 실패했습니다.",
|
||||||
error instanceof Error ? error.message : "토큰 검증에 실패했습니다.",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,18 @@ const TokenManager = {
|
||||||
return null;
|
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 => {
|
isTokenExpired: (token: string): boolean => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||||
|
|
@ -66,8 +78,147 @@ const TokenManager = {
|
||||||
return true;
|
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 인스턴스 생성
|
// Axios 인스턴스 생성
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
|
|
@ -138,9 +289,15 @@ apiClient.interceptors.request.use(
|
||||||
// 응답 인터셉터
|
// 응답 인터셉터
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response: AxiosResponse) => {
|
(response: AxiosResponse) => {
|
||||||
|
// 백엔드에서 보내주는 새로운 토큰 처리
|
||||||
|
const newToken = response.headers["x-new-token"];
|
||||||
|
if (newToken) {
|
||||||
|
TokenManager.setToken(newToken);
|
||||||
|
console.log("[TokenManager] 서버에서 새 토큰 수신, 저장 완료");
|
||||||
|
}
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
(error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
const url = error.config?.url;
|
const url = error.config?.url;
|
||||||
|
|
||||||
|
|
@ -153,7 +310,7 @@ apiClient.interceptors.response.use(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 일반 409 에러는 간단한 로그만 출력
|
// 일반 409 에러는 간단한 로그만 출력
|
||||||
console.warn("⚠️ 데이터 중복:", {
|
console.warn("데이터 중복:", {
|
||||||
url: url,
|
url: url,
|
||||||
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
|
message: (error.response?.data as { message?: string })?.message || "중복된 데이터입니다.",
|
||||||
});
|
});
|
||||||
|
|
@ -161,7 +318,7 @@ apiClient.interceptors.response.use(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다른 에러들은 기존처럼 상세 로그 출력
|
// 다른 에러들은 기존처럼 상세 로그 출력
|
||||||
console.error("❌ API 응답 오류:", {
|
console.error("API 응답 오류:", {
|
||||||
status: status,
|
status: status,
|
||||||
statusText: error.response?.statusText,
|
statusText: error.response?.statusText,
|
||||||
url: url,
|
url: url,
|
||||||
|
|
@ -170,24 +327,40 @@ apiClient.interceptors.response.use(
|
||||||
headers: error.config?.headers,
|
headers: error.config?.headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 401 에러 시 상세 정보 출력
|
// 401 에러 처리
|
||||||
if (status === 401) {
|
if (status === 401 && typeof window !== "undefined") {
|
||||||
console.error("🚨 401 Unauthorized 오류 상세 정보:", {
|
const errorData = error.response?.data as { error?: { code?: string } };
|
||||||
|
const errorCode = errorData?.error?.code;
|
||||||
|
|
||||||
|
console.warn("[Auth] 401 오류 발생:", {
|
||||||
url: url,
|
url: url,
|
||||||
method: error.config?.method,
|
errorCode: errorCode,
|
||||||
headers: error.config?.headers,
|
|
||||||
requestData: error.config?.data,
|
|
||||||
responseData: error.response?.data,
|
|
||||||
token: TokenManager.getToken() ? "존재" : "없음",
|
token: TokenManager.getToken() ? "존재" : "없음",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 401 에러 시 토큰 제거 및 로그인 페이지로 리다이렉트
|
// 토큰 만료 에러인 경우 갱신 시도
|
||||||
if (status === 401 && typeof window !== "undefined") {
|
const originalRequest = error.config as typeof error.config & { _retry?: boolean };
|
||||||
localStorage.removeItem("authToken");
|
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") {
|
if (window.location.pathname !== "/login") {
|
||||||
|
console.log("[Auth] 로그인 페이지로 리다이렉트");
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue