diff --git a/apps/cs/app/api/proxy/[...url]/route.ts b/apps/cs/app/api/proxy/[...url]/route.ts index abd3ae007..0929ca57c 100644 --- a/apps/cs/app/api/proxy/[...url]/route.ts +++ b/apps/cs/app/api/proxy/[...url]/route.ts @@ -2,6 +2,7 @@ import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, + COOKIE_DOMAIN, REFRESH_TOKEN, SERVER_API_URL, SITE_ID, @@ -60,18 +61,11 @@ async function handler( [AUTH_USER_ID]: userId, }); - const forwardedHost = request.headers.get("x-forwarded-host"); - const host = request.headers.get("host"); - const actualHost = forwardedHost || host || new URL(request.url).hostname.toString(); - - // 운영 환경에서 domain 설정 - const domain = actualHost.includes("hasuon.or.kr") ? ".hasuon.or.kr" : actualHost; - if (refreshToken) { - cookieStore.set(REFRESH_TOKEN, refreshToken, { + response.cookies.set(REFRESH_TOKEN, refreshToken, { httpOnly: true, sameSite: "strict", - domain: domain, + domain: getCookieDomain(), path: "/", }); } @@ -91,7 +85,6 @@ async function handler( } } - export async function POST( request: Request, props: { params: Promise<{ url: string[] }> }, @@ -107,3 +100,14 @@ export async function PUT( const params = await props.params; return handler(request, params, "PUT"); } + +function getCookieDomain(): string | undefined { + if (typeof window === "undefined") return undefined; + + if (COOKIE_DOMAIN) return COOKIE_DOMAIN; + + const { hostname } = window.location; + if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; + + return hostname; +} diff --git a/apps/cs/src/app/providers/app-provider.tsx b/apps/cs/src/app/providers/app-provider.tsx index efd1f308e..31d14ac79 100644 --- a/apps/cs/src/app/providers/app-provider.tsx +++ b/apps/cs/src/app/providers/app-provider.tsx @@ -14,8 +14,9 @@ import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog"; import { PromptProvider, StoreProvider } from "@/shared/providers"; +const queryClient = new QueryClient(); + const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { - const queryClient = new QueryClient(); return ( diff --git a/apps/cs/src/entities/auth/api/login-service.ts b/apps/cs/src/entities/auth/api/login-service.ts index 671c8bb4c..6922efbbe 100644 --- a/apps/cs/src/entities/auth/api/login-service.ts +++ b/apps/cs/src/entities/auth/api/login-service.ts @@ -1,96 +1,105 @@ -import { AxiosError } from "axios"; import { ENDPOINT, - JWT_EXPIRATION_TIME, + CLAIM_NAME, ACCESS_TOKEN, AUTH_USER_ID, - CLAIM_NAME, } from "@/shared/config"; -import type { LoginService, ILogin } from "../model/login-interface"; -import { tokenManager } from "../lib/token-manager"; +import type { ILogin } from "../model/login-interface"; +import { + setToken, + clearAuthToken, + setupRefreshTimer, +} from "../lib/token-manager"; +import { getRemainingMs } from "../lib/jwt-utils"; -export class LoginApiService implements LoginService { - private static instance: LoginApiService; - - private constructor() {} - - public static getInstance(): LoginApiService { - if (!LoginApiService.instance) { - LoginApiService.instance = new LoginApiService(); - } - return LoginApiService.instance; - } - - public async login(payload: ILogin): Promise { - try { - const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { - method: "POST", - body: JSON.stringify({ - userId: payload.userId, - password: payload.password, - }), - }); - - const data = await response.json(); - console.log('data ', data) - const accessToken = data[ACCESS_TOKEN]; - const tokenId = data[AUTH_USER_ID]; - - if (!accessToken || !tokenId) { - if (data.message == 'Invalid Credentials' ) { - throw new Error('아이디와 비밀번호가 일치하지 않습니다.'); - } else if (data.message == 'User not found') { - throw new Error('존재하지 않는 아이디입니다.'); - } else if (data.message == 'User is not active') { - throw new Error('활성화되지 않은 계정입니다.'); - } else if (data.message == 'User is not verified') { - throw new Error('이메일 인증이 필요합니다.'); - } else { - throw new Error(data.message); - } - } - - tokenManager.setToken(CLAIM_NAME, accessToken); - tokenManager.setToken(AUTH_USER_ID, tokenId); - tokenManager.setupRefreshTimer( - () => this.silentRefresh(), - JWT_EXPIRATION_TIME, - ); - } catch (error) { - if (error instanceof AxiosError) { - throw new Error(`Login 실패 ${error.response?.data?.message}`); - } - throw new Error(`Login 실패 ${error}`); - } - } - - public async silentRefresh(): Promise { - try { - const response = await fetch( - `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, - { method: "PUT" }, - ); - - const data = await response.json(); - const accessToken = data[ACCESS_TOKEN]; - const tokenId = data[AUTH_USER_ID]; - - if (!accessToken || !tokenId) { - throw new Error("Missing required authentication tokens"); - } - - tokenManager.setToken(CLAIM_NAME, accessToken); - tokenManager.setToken(AUTH_USER_ID, tokenId); - } catch (error) { - tokenManager.clearAuthToken(); - if (error instanceof AxiosError) { - throw new Error( - `Silent refresh failed: ${error.response?.data?.message}`, - ); - } - throw new Error(`Silent refresh failed: ${error}`); - } +function resolveLoginError(message: string | undefined): string { + switch (message) { + case "Invalid Credentials": + return "아이디와 비밀번호가 일치하지 않습니다."; + case "User not found": + return "존재하지 않는 아이디입니다."; + case "User is not active": + return "활성화되지 않은 계정입니다."; + case "User is not verified": + return "이메일 인증이 필요합니다."; + default: + return message ?? "로그인에 실패했습니다."; } } -export const LoginApi = LoginApiService.getInstance(); +export async function login(payload: ILogin): Promise { + const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(resolveLoginError(data?.message)); + } + + const accessToken = data[ACCESS_TOKEN] as string | undefined; + const tokenId = data[AUTH_USER_ID] as string | undefined; + + if (!accessToken || !tokenId) { + throw new Error("인증 토큰을 받지 못했습니다."); + } + + setToken(CLAIM_NAME, accessToken); + setToken(AUTH_USER_ID, tokenId); + + const remainingMs = getRemainingMs(accessToken); + if (remainingMs > 0) { + setupRefreshTimer(silentRefresh, remainingMs); + } +} + +export async function silentRefresh(): Promise { + console.log(`[silentRefresh] Called at ${new Date().toISOString()}`); + try { + const response = await fetch( + `/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, + { method: "PUT" }, + ); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data?.message ?? "토큰 갱신에 실패했습니다."); + } + + const accessToken = data[ACCESS_TOKEN] as string | undefined; + const tokenId = data[AUTH_USER_ID] as string | undefined; + + if (!accessToken || !tokenId) { + throw new Error("갱신된 인증 토큰을 받지 못했습니다."); + } + + setToken(CLAIM_NAME, accessToken); + setToken(AUTH_USER_ID, tokenId); + + const remainingMs = getRemainingMs(accessToken); + if (remainingMs > 0) { + setupRefreshTimer(silentRefresh, remainingMs); + } + } catch (error) { + console.error( + `[silentRefresh] Failed at ${new Date().toISOString()}`, + error, + ); + clearAuthToken(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } + // re-throw하지 않음: setupRefreshTimer 콜백의 catch와 이중 처리 방지 + } +} + +export async function logout(): Promise { + clearAuthToken(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } +} diff --git a/apps/cs/src/entities/auth/index.ts b/apps/cs/src/entities/auth/index.ts index 97e142225..b4cb7d202 100644 --- a/apps/cs/src/entities/auth/index.ts +++ b/apps/cs/src/entities/auth/index.ts @@ -1,2 +1,2 @@ -export { LoginApi } from "./api/login-service"; +export { login, silentRefresh, logout } from "./api/login-service"; export { AuthWrapper } from "./lib/auth-wrapper"; diff --git a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx index 57ae75c34..ba6627d8a 100644 --- a/apps/cs/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/cs/src/entities/auth/lib/auth-wrapper.tsx @@ -1,47 +1,80 @@ "use client"; import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { isAxiosError } from "axios"; import { client } from "@/shared/api"; import type { IUserInfo } from "@/shared/store"; -import { getCookie, useUserStore } from "@/shared/lib"; +import { useUserStore } from "@/shared/lib"; import { isAtLoginRedirectUrl } from "@common/network"; import { ENDPOINT, AUTH_USER_ID, AUTH_LOGIN_REDIRECT_URL, + CLAIM_NAME, } from "@/shared/config"; +import { getToken, setupRefreshTimer } from "./token-manager"; +import { getRemainingMs } from "./jwt-utils"; +import { silentRefresh } from "../api/login-service"; const AuthWrapper = ({ children }: { children: React.ReactNode }) => { - const { userInfo, setUserInfo } = useUserStore((state) => state); - - async function authCheck() { - if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { - return; - } - - const userId = getCookie(AUTH_USER_ID); - if (!userId) { - throw new Error("Missing required authentication tokens"); - } - - const response = await client.get( - `${ENDPOINT.USER_SERVICE.USERS}/info/me`, - ); - if (!userInfo) setUserInfo(response.data); - } + const router = useRouter(); + const userInfo = useUserStore((state) => state.userInfo); + const setUserInfo = useUserStore((state) => state.setUserInfo); useEffect(() => { - (async () => { - try { - await authCheck(); - } catch (error) { - console.error(error); - setUserInfo(null); - if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { - window.location.assign(AUTH_LOGIN_REDIRECT_URL); - } + // 페이지 새로고침 시 silentRefresh 타이머 복구 + const token = getToken(CLAIM_NAME); + if (token) { + const remainingMs = getRemainingMs(token); + if (remainingMs > 0) { + console.log( + `[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`, + ); + setupRefreshTimer(silentRefresh, remainingMs); } - })(); + } + + if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return; + + async function authCheck() { + const userId = getToken(AUTH_USER_ID); + if (!userId) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + return; + } + + if (userInfo) return; + + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/info/me`, + ); + setUserInfo(response.data); + } + + authCheck().catch((error) => { + // 401은 @common/network error-interceptor에서 이미 리다이렉트 처리됨 + if (isAxiosError(error) && error.response?.status === 401) return; + + console.error(error); + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + }); + }, []); + + // silentRefresh 실패 시 auth:logout 이벤트를 수신해 로그인으로 이동 + useEffect(() => { + function handleAuthLogout() { + setUserInfo(null); + if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) { + router.replace(AUTH_LOGIN_REDIRECT_URL); + } + } + + window.addEventListener("auth:logout", handleAuthLogout); + return () => window.removeEventListener("auth:logout", handleAuthLogout); }, []); return <>{children}; diff --git a/apps/cs/src/entities/auth/lib/jwt-utils.ts b/apps/cs/src/entities/auth/lib/jwt-utils.ts new file mode 100644 index 000000000..05d2b0ff8 --- /dev/null +++ b/apps/cs/src/entities/auth/lib/jwt-utils.ts @@ -0,0 +1,32 @@ +interface JwtPayload { + exp?: number; + [key: string]: unknown; +} + +/** + * JWT 토큰의 exp 클레임(Unix timestamp, 초 단위)을 반환합니다. + * 파싱 실패 시 null을 반환합니다. + */ +export function getJwtExpiry(token: string): number | null { + try { + const base64Payload = token.split(".")[1]; + if (!base64Payload) return null; + + const payload = JSON.parse(atob(base64Payload)) as JwtPayload; + return typeof payload.exp === "number" ? payload.exp : null; + } catch { + return null; + } +} + +/** + * 토큰 만료 1분 전까지 남은 시간(ms)을 반환합니다. + * 이미 만료됐거나 파싱 실패 시 0을 반환합니다. + */ +export function getRemainingMs(token: string): number { + const exp = getJwtExpiry(token); + if (!exp) return 0; + + const remainingMs = exp * 1000 - Date.now() - 60_000; + return Math.max(0, remainingMs); +} diff --git a/apps/cs/src/entities/auth/lib/token-manager.ts b/apps/cs/src/entities/auth/lib/token-manager.ts index dd9aea352..bef544141 100644 --- a/apps/cs/src/entities/auth/lib/token-manager.ts +++ b/apps/cs/src/entities/auth/lib/token-manager.ts @@ -1,82 +1,62 @@ -import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config"; +import { AUTH_USER_ID, CLAIM_NAME, COOKIE_DOMAIN } from "@/shared/config"; import { getCookie, setCookie, removeCookie } from "@/shared/lib"; -export class TokenManager { - private static instance: TokenManager; - private refreshTimer: NodeJS.Timeout | null = null; +let refreshTimer: ReturnType | null = null; - private constructor() {} - - public static getInstance(): TokenManager { - if (!TokenManager.instance) { - TokenManager.instance = new TokenManager(); - } - return TokenManager.instance; - } - - public getToken(key: string): string | undefined { - return getCookie(key); - } - - public setToken(key: string, value: string): void { - const domain = this.getCookieDomain(); - - setCookie(key, value, { - secure: window.location.protocol === "https:", - sameSite: "lax", - domain: domain, - path: "/", // 모든 경로에서 접근 가능 - }); - } - - - private getCookieDomain(): string | undefined { - const hostname = window.location.hostname; - - // 개발 환경에서는 localhost 도메인 사용 - if (hostname === "localhost" || hostname === "127.0.0.1") { - return undefined; // localhost에서는 도메인 설정하지 않음 - } - - // 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용 - if (hostname.includes("hasuon.or.kr")) { - return ".hasuon.or.kr"; - } - - // 기타 환경에서는 현재 호스트명 사용 - return hostname; - } - - public removeToken(key: string): void { - const domain = this.getCookieDomain(); - removeCookie(key, { domain, path: "/" }); - } - - public setupRefreshTimer( - callback: () => Promise, - expirationTime: number, - ): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - } - - this.refreshTimer = setTimeout(() => { - callback().catch((error) => { - console.error("Silent refresh failed:", error); - this.clearAuthToken(); - }); - }, expirationTime - 60000); - } - - public clearAuthToken(): void { - if (this.refreshTimer) { - clearTimeout(this.refreshTimer); - this.refreshTimer = null; - } - - this.removeToken(CLAIM_NAME); - this.removeToken(AUTH_USER_ID); - } +export function getToken(key: string): string | undefined { + return getCookie(key); } -export const tokenManager = TokenManager.getInstance(); +export function setToken(key: string, value: string): void { + const domain = getCookieDomain(); + const isHttps = + typeof window !== "undefined" && window.location.protocol === "https:"; + + setCookie(key, value, { + secure: isHttps, + sameSite: "lax", + domain, + path: "/", + }); +} + +export function removeToken(key: string): void { + const domain = getCookieDomain(); + removeCookie(key, { domain, path: "/" }); +} + +export function setupRefreshTimer( + callback: () => Promise, + delayMs: number, +): void { + if (refreshTimer) { + clearTimeout(refreshTimer); + } + + refreshTimer = setTimeout(() => { + callback().catch((error) => { + console.error("Silent refresh timer callback failed:", error); + }); + }, delayMs); +} + +export function clearAuthToken(): void { + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + + removeToken(CLAIM_NAME); + removeToken(AUTH_USER_ID); +} + +function getCookieDomain(): string | undefined { + if (typeof window === "undefined") return undefined; + + if (COOKIE_DOMAIN) return COOKIE_DOMAIN; + + const { hostname } = window.location; + if (hostname === "localhost" || hostname === "127.0.0.1") return undefined; + + return hostname; +} diff --git a/apps/cs/src/entities/auth/model/login-interface.ts b/apps/cs/src/entities/auth/model/login-interface.ts index 5aaf53044..012f81457 100644 --- a/apps/cs/src/entities/auth/model/login-interface.ts +++ b/apps/cs/src/entities/auth/model/login-interface.ts @@ -2,8 +2,3 @@ export interface ILogin { userId: string; password: string; } - -export interface LoginService { - login(payload: ILogin): Promise; - silentRefresh(): Promise; -} diff --git a/apps/cs/src/pages/auth/login/ui/login-page.tsx b/apps/cs/src/pages/auth/login/ui/login-page.tsx index fd1e2f809..90de72f4c 100644 --- a/apps/cs/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cs/src/pages/auth/login/ui/login-page.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import Image from "next/image"; import { images } from "@common/assets"; -import { LoginApi } from "@/entities/auth"; +import { login } from "@/entities/auth"; import "./login.css"; function LoginPage() { @@ -18,7 +18,7 @@ function LoginPage() { const password = formData.get("password") as string; try { - await LoginApi.login({ userId, password }); + await login({ userId, password }); window.location.href = "/dashboard"; } catch (error) { const message = diff --git a/apps/cs/src/shared/config/env.ts b/apps/cs/src/shared/config/env.ts index 95d1a4477..5e1392b4d 100644 --- a/apps/cs/src/shared/config/env.ts +++ b/apps/cs/src/shared/config/env.ts @@ -11,3 +11,4 @@ export const ACCESS_TOKEN = export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; +export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;