feat: 로그인 로직 구현

This commit is contained in:
2025-03-06 15:39:25 +09:00
parent 86b0a644cf
commit 2460938431
9 changed files with 225 additions and 45 deletions
+2
View File
@@ -14,6 +14,7 @@
"@tanstack/react-table": "^8.21.2",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"js-cookie": "^3.0.5",
"next": "15.1.7",
"react-chartjs-2": "^5.3.0",
"tailwindcss-animate": "^1.0.7"
@@ -21,6 +22,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@faker-js/faker": "^9.5.0",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"eslint": "^9",
"eslint-config-next": "15.1.7",
+95
View File
@@ -0,0 +1,95 @@
import { AxiosError } from "axios";
import { client } from "@/shared/api";
import {
ACCESS_TOKEN,
CLAIM_NAME,
ENDPOINT,
JWT_EXPIRATION_TIME,
REFRESH_TOKEN,
TOKEN_ID,
} from "@/shared/config";
import { tokenManager } from "../lib/token-manager";
import type { LoginService, ILogin } from "../model/login";
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 client.post(ENDPOINT.USER_SERVICE.LOGIN, {
userId: payload.userId,
password: payload.password,
});
const accessToken = response.headers[ACCESS_TOKEN];
const refreshToken = response.headers[REFRESH_TOKEN];
const tokenId = response.headers[TOKEN_ID];
if (!refreshToken || !accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(ACCESS_TOKEN, accessToken);
tokenManager.setToken(TOKEN_ID, tokenId);
tokenManager.setRefreshToken(refreshToken);
tokenManager.setupRefreshTimer(
() => this.silentRefresh(),
JWT_EXPIRATION_TIME,
);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(
`Login failed: ${error.response?.data?.message || error.message}`,
);
}
throw error;
}
}
public async silentRefresh(): Promise<void> {
try {
const refreshToken = tokenManager.getRefreshToken();
if (!refreshToken) {
throw new Error("No refresh token available");
}
const response = await client.put(
ENDPOINT.USER_SERVICE.SILENT_REFRESH,
{},
{
headers: {
[CLAIM_NAME]: refreshToken,
},
},
);
const accessToken = response.headers[ACCESS_TOKEN];
const tokenId = response.headers[TOKEN_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(ACCESS_TOKEN, accessToken);
tokenManager.setToken(TOKEN_ID, tokenId);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(
`Silent refresh failed: ${error.response?.data?.message || error.message}`,
);
}
throw error;
}
}
}
export const LoginApi = LoginApiService.getInstance();
@@ -0,0 +1,61 @@
import axios from "axios";
import Cookies from "js-cookie";
import { CLAIM_NAME, REFRESH_TOKEN, TOKEN_ID } from "@/shared/config";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
private constructor() {}
public static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
public setToken(key: string, value: string): void {
axios.defaults.headers.common[key] = value;
}
public setRefreshToken(token: string): void {
Cookies.set(REFRESH_TOKEN, token, {
httpOnly: true,
secure: true,
sameSite: "lax",
});
}
public getRefreshToken(): string | undefined {
return Cookies.get(REFRESH_TOKEN);
}
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.clearAuth();
});
}, expirationTime - 60000);
}
public clearAuth(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
Cookies.remove(REFRESH_TOKEN);
delete axios.defaults.headers.common[CLAIM_NAME];
delete axios.defaults.headers.common[TOKEN_ID];
}
}
export const tokenManager = TokenManager.getInstance();
@@ -0,0 +1,8 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
}
+27 -45
View File
@@ -1,15 +1,6 @@
import axios from "axios";
import type { AxiosInstance, InternalAxiosRequestConfig } from "axios";
interface ApiResponse<T = unknown> {
data: T;
status: number;
message?: string;
}
interface RequestData {
[key: string]: unknown;
}
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import { SERVER_API_URL, SITE_ID } from "../config";
class Client {
private static instance: Client;
@@ -17,10 +8,12 @@ class Client {
private constructor() {
this.axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
baseURL: SERVER_API_URL,
headers: {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
},
withCredentials: true,
});
}
@@ -31,49 +24,38 @@ class Client {
return Client.instance;
}
public async get<T>(
public async get<T = unknown, R = AxiosResponse<T>>(
url: string,
config?: InternalAxiosRequestConfig,
): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.get<ApiResponse<T>>(url, config);
return response.data;
config?: AxiosRequestConfig,
): Promise<R> {
const response = await this.axiosInstance.get<T, R>(url, config);
return response;
}
public async post<T>(
public async post<T = unknown, R = AxiosResponse<T>, D = unknown>(
url: string,
data?: RequestData,
config?: InternalAxiosRequestConfig,
): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.post<ApiResponse<T>>(
url,
data,
config,
);
return response.data;
data?: D,
config?: AxiosRequestConfig<D>,
): Promise<R> {
const response = await this.axiosInstance.post<T, R>(url, data, config);
return response;
}
public async put<T>(
public async put<T = unknown, R = AxiosResponse<T>, D = unknown>(
url: string,
data?: RequestData,
config?: InternalAxiosRequestConfig,
): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.put<ApiResponse<T>>(
url,
data,
config,
);
return response.data;
data?: D,
config?: AxiosRequestConfig<D>,
): Promise<R> {
const response = await this.axiosInstance.put<T, R>(url, data, config);
return response;
}
public async delete<T>(
public async delete<T = unknown, R = AxiosResponse<T>>(
url: string,
config?: InternalAxiosRequestConfig,
): Promise<ApiResponse<T>> {
const response = await this.axiosInstance.delete<ApiResponse<T>>(
url,
config,
);
return response.data;
config?: AxiosRequestConfig,
): Promise<R> {
const response = await this.axiosInstance.delete<T, R>(url, config);
return response;
}
}
+8
View File
@@ -0,0 +1,8 @@
export const ENDPOINT = {
USER_SERVICE: {
LOGIN: "/user-service/login",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
},
};
export const JWT_EXPIRATION_TIME = 1800000;
+7
View File
@@ -0,0 +1,7 @@
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID;
export const CLAIM_NAME = process.env.CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN = process.env.ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN = process.env.REFRESH_TOKEN || "refresh-token";
export const TOKEN_ID = process.env.TOKEN_ID || "token-id";
+2
View File
@@ -0,0 +1,2 @@
export * from "./api";
export * from "./env";