From 2460938431f22a0f10fe2b82e96ad28a5d8b49f1 Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 6 Mar 2025 15:39:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cs/package.json | 2 + apps/cs/src/pages/auth/login/api/login.ts | 95 +++++++++++++++++++ .../src/pages/auth/login/lib/token-manager.ts | 61 ++++++++++++ apps/cs/src/pages/auth/login/model/login.ts | 8 ++ apps/cs/src/shared/api/client.ts | 72 ++++++-------- apps/cs/src/shared/config/api.ts | 8 ++ apps/cs/src/shared/config/env.ts | 7 ++ apps/cs/src/shared/config/index.ts | 2 + pnpm-lock.yaml | 15 +++ 9 files changed, 225 insertions(+), 45 deletions(-) create mode 100644 apps/cs/src/pages/auth/login/api/login.ts create mode 100644 apps/cs/src/pages/auth/login/lib/token-manager.ts create mode 100644 apps/cs/src/pages/auth/login/model/login.ts create mode 100644 apps/cs/src/shared/config/api.ts create mode 100644 apps/cs/src/shared/config/env.ts create mode 100644 apps/cs/src/shared/config/index.ts diff --git a/apps/cs/package.json b/apps/cs/package.json index 83d21f8d1..0abb67d9d 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -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", diff --git a/apps/cs/src/pages/auth/login/api/login.ts b/apps/cs/src/pages/auth/login/api/login.ts new file mode 100644 index 000000000..29af04cb2 --- /dev/null +++ b/apps/cs/src/pages/auth/login/api/login.ts @@ -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 { + 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 { + 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(); diff --git a/apps/cs/src/pages/auth/login/lib/token-manager.ts b/apps/cs/src/pages/auth/login/lib/token-manager.ts new file mode 100644 index 000000000..226cfc843 --- /dev/null +++ b/apps/cs/src/pages/auth/login/lib/token-manager.ts @@ -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, + 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(); diff --git a/apps/cs/src/pages/auth/login/model/login.ts b/apps/cs/src/pages/auth/login/model/login.ts new file mode 100644 index 000000000..2fe9acc49 --- /dev/null +++ b/apps/cs/src/pages/auth/login/model/login.ts @@ -0,0 +1,8 @@ +export interface ILogin { + userId: string; + password: string; +} + +export interface LoginService { + login(payload: ILogin): Promise; +} diff --git a/apps/cs/src/shared/api/client.ts b/apps/cs/src/shared/api/client.ts index a65db0681..2a7d58988 100644 --- a/apps/cs/src/shared/api/client.ts +++ b/apps/cs/src/shared/api/client.ts @@ -1,15 +1,6 @@ import axios from "axios"; -import type { AxiosInstance, InternalAxiosRequestConfig } from "axios"; - -interface ApiResponse { - 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( + public async get>( url: string, - config?: InternalAxiosRequestConfig, - ): Promise> { - const response = await this.axiosInstance.get>(url, config); - return response.data; + config?: AxiosRequestConfig, + ): Promise { + const response = await this.axiosInstance.get(url, config); + return response; } - public async post( + public async post, D = unknown>( url: string, - data?: RequestData, - config?: InternalAxiosRequestConfig, - ): Promise> { - const response = await this.axiosInstance.post>( - url, - data, - config, - ); - return response.data; + data?: D, + config?: AxiosRequestConfig, + ): Promise { + const response = await this.axiosInstance.post(url, data, config); + return response; } - public async put( + public async put, D = unknown>( url: string, - data?: RequestData, - config?: InternalAxiosRequestConfig, - ): Promise> { - const response = await this.axiosInstance.put>( - url, - data, - config, - ); - return response.data; + data?: D, + config?: AxiosRequestConfig, + ): Promise { + const response = await this.axiosInstance.put(url, data, config); + return response; } - public async delete( + public async delete>( url: string, - config?: InternalAxiosRequestConfig, - ): Promise> { - const response = await this.axiosInstance.delete>( - url, - config, - ); - return response.data; + config?: AxiosRequestConfig, + ): Promise { + const response = await this.axiosInstance.delete(url, config); + return response; } } diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts new file mode 100644 index 000000000..594157d09 --- /dev/null +++ b/apps/cs/src/shared/config/api.ts @@ -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; diff --git a/apps/cs/src/shared/config/env.ts b/apps/cs/src/shared/config/env.ts new file mode 100644 index 000000000..13bf870a3 --- /dev/null +++ b/apps/cs/src/shared/config/env.ts @@ -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"; diff --git a/apps/cs/src/shared/config/index.ts b/apps/cs/src/shared/config/index.ts new file mode 100644 index 000000000..cd83447d5 --- /dev/null +++ b/apps/cs/src/shared/config/index.ts @@ -0,0 +1,2 @@ +export * from "./api"; +export * from "./env"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fd30db6d..d5121f601 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: chart.js: specifier: ^4.4.8 version: 4.4.8 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 next: specifier: 15.1.7 version: 15.1.7(react-dom@18.3.1)(react@18.3.1) @@ -64,6 +67,9 @@ importers: '@faker-js/faker': specifier: ^9.5.0 version: 9.5.0 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@types/node': specifier: ^22.13.1 version: 22.13.5 @@ -2837,6 +2843,10 @@ packages: resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} dev: true + /@types/js-cookie@3.0.6: + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + dev: true + /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -5118,6 +5128,11 @@ packages: engines: {node: '>=10'} dev: true + /js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}