[as] refactor: loginService -> authService

This commit is contained in:
2026-05-28 16:56:15 +09:00
parent 97df2c47de
commit 69bd924cd2
12 changed files with 7900 additions and 4383 deletions
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/auth": "workspace:*",
"@common/components": "workspace:*",
"@common/network": "workspace:*",
"@dnd-kit/core": "^6.3.1",
@@ -0,0 +1,79 @@
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
import { getRemainingMs } from "@common/auth";
import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager";
import type { AuthPayload, AuthService } from "../model/auth-interface";
async function login(payload: AuthPayload): Promise<void> {
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("인증 토큰을 받지 못했습니다.");
}
setToken(CLAIM_NAME, accessToken);
setToken(AUTH_USER_ID, tokenId);
const remainingMs = getRemainingMs(accessToken);
if (remainingMs > 0) {
setupRefreshTimer(silentRefresh, remainingMs);
}
}
async function silentRefresh(): Promise<void> {
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"));
}
}
}
async function logout(): Promise<void> {
clearAuthToken();
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("auth:logout"));
}
}
export const authService: AuthService = {
login,
silentRefresh,
logout,
};
@@ -1,92 +0,0 @@
import { AxiosError } from "axios";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
ENDPOINT,
JWT_EXPIRATION_TIME,
} from "@/shared/config";
import { tokenManager } from "../lib/token-manager";
import type { ILogin, LoginService } from "../model/login-interface";
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<void> {
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<void> {
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}`);
}
}
}
export const LoginApi = LoginApiService.getInstance();
+2 -1
View File
@@ -1,2 +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";
+56 -27
View File
@@ -1,42 +1,71 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { isAxiosError } from "axios";
import { client } from "@/shared/api";
import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, ENDPOINT } from "@/shared/config";
import { getCookie, useUserStore } from "@/shared/lib";
import { 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<IUserInfo>(`${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);
}
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<IUserInfo>(`${ENDPOINT.USER_SERVICE.USERS}/info/me`);
setUserInfo(response.data);
}
authCheck().catch((error) => {
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);
}
});
}, []);
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}</>;
+13 -80
View File
@@ -1,82 +1,15 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, removeCookie,setCookie } from "@/shared/lib";
import { authConfig } from "@/shared/config/auth";
import { createTokenManager } from "@common/auth";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
export const tokenManager = createTokenManager(authConfig);
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<void>,
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 const tokenManager = TokenManager.getInstance();
export const {
getToken,
setToken,
removeToken,
setupRefreshTimer,
clearAuthToken,
getAccessToken,
getRefreshToken,
getUserId,
} = tokenManager;
@@ -0,0 +1,10 @@
export interface AuthPayload {
userId: string;
password: string;
}
export interface AuthService {
login(payload: AuthPayload): Promise<void>;
silentRefresh(): Promise<void>;
logout(): Promise<void>;
}
@@ -1,9 +0,0 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
@@ -3,7 +3,7 @@
import { useState } from "react";
import Image from "next/image";
import { LoginApi } from "@/entities/auth";
import { authService } from "@/entities/auth";
import { images } from "@common/assets";
import "./login.css";
@@ -20,7 +20,7 @@ function LoginPage() {
const password = formData.get("password") as string;
try {
await LoginApi.login({ userId, password });
await authService.login({ userId, password });
window.location.href = "/";
} catch (error) {
const message = error instanceof Error ? error.message : "로그인에 실패했습니다.";
+11
View File
@@ -0,0 +1,11 @@
import type { AuthConfig } from "@common/auth";
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, COOKIE_DOMAIN, REFRESH_TOKEN } from "./env";
export const authConfig: AuthConfig = {
authUserId: AUTH_USER_ID || "token-id",
claimName: CLAIM_NAME || "Authorization",
cookieDomain: COOKIE_DOMAIN,
accessToken: ACCESS_TOKEN || "access-token",
refreshToken: REFRESH_TOKEN || "refresh-token",
};
+1
View File
@@ -8,3 +8,4 @@ export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "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;
+7725 -4172
View File
File diff suppressed because it is too large Load Diff