[st] refactor: authService 내보내기 방식 변경
This commit is contained in:
@@ -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<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("인증 토큰을 받지 못했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
clearAuthToken();
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService: AuthService = {
|
||||||
|
login,
|
||||||
|
silentRefresh,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
@@ -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<void> {
|
|
||||||
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<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");
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export { LoginApi } from "./api/login-service";
|
export { authService } from "./api/auth-service";
|
||||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||||
export { tokenManager } from "./lib/token-manager";
|
export { tokenManager } from "./lib/token-manager";
|
||||||
|
|||||||
@@ -1,47 +1,85 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
import { client } from "@/shared/api";
|
import { client } from "@/shared/api";
|
||||||
import type { IUserInfo } from "@/shared/store";
|
|
||||||
import { getCookie, useUserStore } from "@/shared/lib";
|
|
||||||
import { isAtLoginRedirectUrl } from "@common/network";
|
|
||||||
import {
|
import {
|
||||||
ENDPOINT,
|
|
||||||
AUTH_USER_ID,
|
|
||||||
AUTH_LOGIN_REDIRECT_URL,
|
AUTH_LOGIN_REDIRECT_URL,
|
||||||
|
AUTH_USER_ID,
|
||||||
|
CLAIM_NAME,
|
||||||
|
ENDPOINT,
|
||||||
} from "@/shared/config";
|
} 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 AuthWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { userInfo, setUserInfo } = useUserStore((state) => state);
|
const router = useRouter();
|
||||||
|
const userInfo = useUserStore((state) => state.userInfo);
|
||||||
async function authCheck() {
|
const setUserInfo = useUserStore((state) => state.setUserInfo);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
// 페이지 새로고침 시 silentRefresh 타이머 복구
|
||||||
try {
|
const token = getToken(CLAIM_NAME);
|
||||||
await authCheck();
|
if (token) {
|
||||||
} catch (error) {
|
const remainingMs = getRemainingMs(token);
|
||||||
console.error(error);
|
if (remainingMs > 0) {
|
||||||
setUserInfo(null);
|
console.log(
|
||||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`,
|
||||||
window.location.assign(AUTH_LOGIN_REDIRECT_URL);
|
);
|
||||||
}
|
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) => {
|
||||||
|
// 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}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
|
import { authService } from "@/entities/auth";
|
||||||
import { images } from "@common/assets";
|
import { images } from "@common/assets";
|
||||||
import { LoginApi } from "@/entities/auth";
|
|
||||||
import "./login.css";
|
import "./login.css";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||||
|
|
||||||
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
const formData = new FormData(e.target as HTMLFormElement);
|
const formData = new FormData(e.target as HTMLFormElement);
|
||||||
const userId = formData.get("id") as string;
|
const userId = formData.get("id") as string;
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
|
|
||||||
await LoginApi.login({ userId, password });
|
try {
|
||||||
window.location.href = "/statistics";
|
await authService.login({ userId, password });
|
||||||
|
window.location.href = "/statistics";
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "로그인에 실패했습니다.";
|
||||||
|
setErrorMessage(message);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,6 +103,11 @@ function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
|
{/* <a href="javascript:fnUserSearch();" className=" pw_message fx"><i className="xi-external-link"></i>비밀번호 또는 아이디를 잃어버리셨나요?</a> */}
|
||||||
</div>
|
</div>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="error_message">
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button type="submit" className="login_btn fx">
|
<button type="submit" className="login_btn fx">
|
||||||
<span>LOGIN</span>
|
<span>LOGIN</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user