diff --git a/apps/st/src/entities/auth/api/auth-service.ts b/apps/st/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..0bef2fee3 --- /dev/null +++ b/apps/st/src/entities/auth/api/auth-service.ts @@ -0,0 +1,100 @@ +import { + ACCESS_TOKEN, + AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, +} from "@/shared/config"; +import { getCookie } from "@/shared/lib"; +import { getRemainingMs } from "@common/auth"; + +import { + clearAuthToken, + setToken, + setupRefreshTimer, +} from "../lib/token-manager"; +import type { AuthPayload, AuthService } from "../model/auth-interface"; + +import { clearTabStorePersist } from "@/store/tab-store"; + +async function login(payload: AuthPayload): 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(); + + const accessToken = data[ACCESS_TOKEN]; + const tokenId = data[AUTH_USER_ID]; + + if (!accessToken || !tokenId) { + throw new Error("인증 토큰을 받지 못했습니다."); + } + + const prevTokenId = getCookie(AUTH_USER_ID); + if (prevTokenId !== undefined && prevTokenId !== tokenId) { + clearTabStorePersist(); + } + + setToken(CLAIM_NAME, accessToken); + setToken(AUTH_USER_ID, tokenId); + + const remainingMs = getRemainingMs(accessToken); + if (remainingMs > 0) { + setupRefreshTimer(silentRefresh, remainingMs); + } +} + +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]; + const tokenId = data[AUTH_USER_ID]; + + 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")); + } + } +} + +export async function logout(): Promise { + clearAuthToken(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } +} + +export const authService: AuthService = { + login, + silentRefresh, + logout, +}; diff --git a/apps/st/src/entities/auth/api/login-service.ts b/apps/st/src/entities/auth/api/login-service.ts deleted file mode 100644 index 1f7763f56..000000000 --- a/apps/st/src/entities/auth/api/login-service.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { AxiosError } from "axios"; - -import { - ACCESS_TOKEN, - AUTH_USER_ID, - CLAIM_NAME, - ENDPOINT, -} from "@/shared/config"; -import { getCookie } from "@/shared/lib"; -import { getRemainingMs } from "@common/auth"; - -import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager"; -import type { ILogin,LoginService } from "../model/login-interface"; - -import { clearTabStorePersist } from "@/store/tab-store"; - -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", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - - 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"); - } - - const prevTokenId = getCookie(AUTH_USER_ID); - if (prevTokenId !== undefined && prevTokenId !== tokenId) { - clearTabStorePersist(); - } - - setToken(CLAIM_NAME, accessToken); - setToken(AUTH_USER_ID, tokenId); - - const remainingMs = getRemainingMs(accessToken); - if (remainingMs > 0) { - setupRefreshTimer( - () => this.silentRefresh(), - remainingMs, - ); - } - } catch (error) { - if (error instanceof AxiosError) { - throw new Error(`Login failed: ${error.response?.data?.message}`); - } - throw new Error(`Login failed: ${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"); - } - - setToken(CLAIM_NAME, accessToken); - setToken(AUTH_USER_ID, tokenId); - - const remainingMs = getRemainingMs(accessToken); - if (remainingMs > 0) { - setupRefreshTimer( - () => this.silentRefresh(), - remainingMs, - ); - } - } catch (error) { - clearAuthToken(); - if (error instanceof AxiosError) { - throw new Error( - `Silent refresh failed: ${error.response?.data?.message}`, - ); - } - throw new Error(`Silent refresh failed: ${error}`); - } - } -} - -export const LoginApi = LoginApiService.getInstance(); diff --git a/apps/st/src/entities/auth/index.ts b/apps/st/src/entities/auth/index.ts index a505636b6..7f0d0081a 100644 --- a/apps/st/src/entities/auth/index.ts +++ b/apps/st/src/entities/auth/index.ts @@ -1,3 +1,3 @@ -export { LoginApi } from "./api/login-service"; +export { authService } from "./api/auth-service"; export { AuthWrapper } from "./lib/auth-wrapper"; -export { tokenManager } from "./lib/token-manager"; \ No newline at end of file +export { tokenManager } from "./lib/token-manager"; diff --git a/apps/st/src/entities/auth/lib/auth-wrapper.tsx b/apps/st/src/entities/auth/lib/auth-wrapper.tsx index c58581c2b..534e1c776 100644 --- a/apps/st/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/st/src/entities/auth/lib/auth-wrapper.tsx @@ -1,47 +1,85 @@ "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 { isAtLoginRedirectUrl } from "@common/network"; import { - ENDPOINT, - AUTH_USER_ID, AUTH_LOGIN_REDIRECT_URL, + AUTH_USER_ID, + CLAIM_NAME, + ENDPOINT, } from "@/shared/config"; +import { useUserStore } from "@/shared/lib"; +import type { IUserInfo } from "@/shared/store"; +import { getRemainingMs } from "@common/auth"; +import { isAtLoginRedirectUrl } from "@common/network"; + +import { authService } from "../api/auth-service"; + +import { getToken, setupRefreshTimer } from "./token-manager"; 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(authService.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/st/src/entities/auth/model/auth-interface.ts b/apps/st/src/entities/auth/model/auth-interface.ts new file mode 100644 index 000000000..9f76aaf35 --- /dev/null +++ b/apps/st/src/entities/auth/model/auth-interface.ts @@ -0,0 +1,10 @@ +export interface AuthPayload { + userId: string; + password: string; +} + +export interface AuthService { + login(payload: AuthPayload): Promise; + silentRefresh(): Promise; + logout(): Promise; +} diff --git a/apps/st/src/entities/auth/model/login-interface.ts b/apps/st/src/entities/auth/model/login-interface.ts deleted file mode 100644 index 5aaf53044..000000000 --- a/apps/st/src/entities/auth/model/login-interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface ILogin { - userId: string; - password: string; -} - -export interface LoginService { - login(payload: ILogin): Promise; - silentRefresh(): Promise; -} diff --git a/apps/st/src/pages/auth/login/ui/login-page.tsx b/apps/st/src/pages/auth/login/ui/login-page.tsx index 9f42d1423..c81a6cdc4 100644 --- a/apps/st/src/pages/auth/login/ui/login-page.tsx +++ b/apps/st/src/pages/auth/login/ui/login-page.tsx @@ -1,20 +1,34 @@ "use client"; +import { useState } from "react"; import Image from "next/image"; + +import { authService } from "@/entities/auth"; import { images } from "@common/assets"; -import { LoginApi } from "@/entities/auth"; + import "./login.css"; function LoginPage() { + const [errorMessage, setErrorMessage] = useState(""); + const handleSubmit = async (e: React.SubmitEvent) => { e.preventDefault(); + setErrorMessage(""); const formData = new FormData(e.target as HTMLFormElement); const userId = formData.get("id") as string; const password = formData.get("password") as string; - await LoginApi.login({ userId, password }); - window.location.href = "/statistics"; + try { + await authService.login({ userId, password }); + window.location.href = "/statistics"; + } catch (error) { + const message = + error instanceof Error + ? error.message + : "로그인에 실패했습니다."; + setErrorMessage(message); + } }; return ( @@ -89,6 +103,11 @@ function LoginPage() { {/* 비밀번호 또는 아이디를 잃어버리셨나요? */} + {errorMessage && ( +
+ {errorMessage} +
+ )}