[st] refactor: 인증 처리 로직 공통 패키지로 수정
This commit is contained in:
@@ -12,8 +12,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
|
||||
import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout";
|
||||
import { MenuApi } from "@/entities/menu";
|
||||
import { tokenManager } from "@/entities/auth";
|
||||
import type { MenuItem } from "@/entities/menu";
|
||||
import { MenuApi } from "@/entities/menu";
|
||||
import { BaseLayout } from "@common/components";
|
||||
import { getConfig } from "@common/config";
|
||||
|
||||
@@ -162,6 +163,7 @@ export default function WithBaseLayout({
|
||||
return (
|
||||
<BaseLayout
|
||||
queryClient={queryClient}
|
||||
tokenManager={tokenManager}
|
||||
currentPath={currentPath}
|
||||
handleMenuClick={handleMenuClick}
|
||||
>
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/auth": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/config": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@rsone/components": "workspace:*",
|
||||
"@faker-js/faker": "^9.9.0",
|
||||
"@fontsource-variable/noto-sans-kr": "^5.2.5",
|
||||
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
||||
@@ -26,7 +29,6 @@
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@rsone/components": "workspace:*",
|
||||
"@tanstack/react-query": "^5.67.1",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
ENDPOINT,
|
||||
JWT_EXPIRATION_TIME,
|
||||
} from "@/shared/config";
|
||||
import { getCookie } from "@/shared/lib";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
|
||||
import { tokenManager } from "../lib/token-manager";
|
||||
import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager";
|
||||
import type { ILogin,LoginService } from "../model/login-interface";
|
||||
|
||||
import { clearTabStorePersist } from "@/store/tab-store";
|
||||
@@ -30,10 +30,8 @@ export class LoginApiService implements LoginService {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userId: payload.userId,
|
||||
password: payload.password,
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
@@ -49,12 +47,16 @@ export class LoginApiService implements LoginService {
|
||||
clearTabStorePersist();
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
tokenManager.setupRefreshTimer(
|
||||
() => this.silentRefresh(),
|
||||
JWT_EXPIRATION_TIME,
|
||||
);
|
||||
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}`);
|
||||
@@ -78,10 +80,18 @@ export class LoginApiService implements LoginService {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(
|
||||
() => this.silentRefresh(),
|
||||
remainingMs,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
tokenManager.clearAuthToken();
|
||||
clearAuthToken();
|
||||
if (error instanceof AxiosError) {
|
||||
throw new Error(
|
||||
`Silent refresh failed: ${error.response?.data?.message}`,
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { LoginApi } from "./api/login-service";
|
||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||
export { tokenManager } from "./lib/token-manager";
|
||||
@@ -1,85 +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";
|
||||
|
||||
import { clearTabStorePersist } from "@/store/tab-store";
|
||||
export const tokenManager = createTokenManager(authConfig);
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
clearTabStorePersist();
|
||||
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,17 @@
|
||||
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",
|
||||
};
|
||||
@@ -13,3 +13,4 @@ export const REFRESH_TOKEN =
|
||||
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
export const ST_SERVICE_URL =
|
||||
process.env.NEXT_PUBLIC_ST_SERVICE_URL || "http://localhost:8080";
|
||||
export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
|
||||
@@ -24,11 +24,7 @@
|
||||
],
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@common/components": ["../../../packages/common/components/dist/"],
|
||||
"@common/config": ["../../../packages/common/config/dist/"],
|
||||
"@common/network": ["../../../packages/common/network/"],
|
||||
"@rsone/components": ["../../../packages/rsone/dist/"]
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# 서비스별 인증 처리 리팩토링 가이드
|
||||
|
||||
## 1) 문서 목적
|
||||
|
||||
여러 서비스 앱에서 중복 구현된 인증 로직을 공통 모듈(`@common/auth`) 기반으로 통일하기 위한 표준 가이드
|
||||
|
||||
핵심 원칙:
|
||||
|
||||
- 인증 정책은 공통 모듈에 모은다.
|
||||
- 서비스별 차이는 설정(`authConfig`)으로만 표현한다.
|
||||
- 토큰 갱신 타이머는 JWT `exp` 기준으로 계산한다.
|
||||
|
||||
## 2) 공통 아키텍처
|
||||
|
||||
인증 관련 책임을 아래처럼 분리한다.
|
||||
|
||||
- `shared/config/env.ts`: 환경 변수 노출
|
||||
- `shared/config/auth.ts`: 서비스별 인증 키/도메인 설정
|
||||
- `entities/auth/lib/token-manager.ts`: 공통 토큰 매니저 생성/re-export
|
||||
- `entities/auth/api/*`: 로그인/토큰 갱신 API 오케스트레이션
|
||||
- 레이아웃: BaseLayout 컴포넌트에 `tokenManager` 주입
|
||||
|
||||
## 3) 단계별 리팩토링 방법
|
||||
|
||||
### 3-1. 환경 변수 정의 통일 (`shared/config/env.ts`)
|
||||
|
||||
서비스마다 이름이 다른 인증 환경 변수를 한 곳에서 노출한다.
|
||||
|
||||
예시 코드:
|
||||
|
||||
```ts
|
||||
export const CLAIM_NAME =
|
||||
process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const AUTH_USER_ID =
|
||||
process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
|
||||
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 COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN;
|
||||
```
|
||||
|
||||
### 3-2. 서비스별 인증 설정 분리 (`shared/config/auth.ts`)
|
||||
|
||||
하드코딩된 키명을 제거하고 `AuthConfig`로 매핑한다.
|
||||
|
||||
예시 코드:
|
||||
|
||||
```ts
|
||||
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,
|
||||
claimName: CLAIM_NAME,
|
||||
cookieDomain: COOKIE_DOMAIN,
|
||||
accessToken: ACCESS_TOKEN,
|
||||
refreshToken: REFRESH_TOKEN,
|
||||
};
|
||||
```
|
||||
|
||||
### 3-3. 토큰 매니저 공통화 (`entities/auth/lib/token-manager.ts`)
|
||||
|
||||
서비스 고유 `TokenManager` 구현을 제거하고 `createTokenManager`를 사용한다.
|
||||
|
||||
예시 코드:
|
||||
|
||||
```ts
|
||||
import { authConfig } from "@/shared/config/auth";
|
||||
import { createTokenManager } from "@common/auth";
|
||||
|
||||
export const tokenManager = createTokenManager(authConfig);
|
||||
|
||||
export const {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearAuthToken,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
} = tokenManager;
|
||||
```
|
||||
|
||||
### 3-4. 로그인 성공 처리 표준화 (`entities/auth/api/login-service.ts`)
|
||||
|
||||
로그인 성공 시 토큰 저장과 자동 갱신 타이머 설정을 한 흐름으로 고정한다.
|
||||
|
||||
예시 코드:
|
||||
|
||||
```ts
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import { setToken, setupRefreshTimer } from "../lib/token-manager";
|
||||
|
||||
async function onLoginSuccess(params: {
|
||||
accessToken: string;
|
||||
tokenId: string;
|
||||
refresh: () => Promise<void>;
|
||||
}) {
|
||||
setToken(authConfig.claimName, params.accessToken);
|
||||
setToken(authConfig.authUserId, params.tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(params.accessToken);
|
||||
if (remainingMs <= 0) return;
|
||||
|
||||
setupRefreshTimer(params.refresh, remainingMs);
|
||||
}
|
||||
```
|
||||
|
||||
### 3-5. 토큰 갱신 실패 처리 표준화
|
||||
|
||||
토큰 갱신 실패 시 인증 토큰을 일괄 정리한다.
|
||||
|
||||
예시 코드:
|
||||
|
||||
```ts
|
||||
import { clearAuthToken } from "../lib/token-manager";
|
||||
|
||||
async function silentRefresh() {
|
||||
try {
|
||||
...
|
||||
} catch (error) {
|
||||
clearAuthToken();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3-6. 공통 레이아웃/쉘에 토큰 매니저 주입
|
||||
|
||||
공통 컴포넌트가 인증 기능을 직접 사용할 수 있도록 주입한다.
|
||||
|
||||
예시 코드:
|
||||
|
||||
```tsx
|
||||
import { tokenManager } from "@/entities/auth";
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<BaseLayout
|
||||
tokenManager={tokenManager}
|
||||
...
|
||||
>
|
||||
{children}
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3-7. 배럴 파일 re-export 정리 (`entities/auth/index.ts`)
|
||||
|
||||
외부에서 인증 엔티티를 일관된 경로로 사용하도록 re-export한다.
|
||||
|
||||
예시 코드:
|
||||
|
||||
```ts
|
||||
export { LoginApi } from "./api/login-service";
|
||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||
export { tokenManager } from "./lib/token-manager";
|
||||
```
|
||||
|
||||
## 4) 서비스별 적용 체크리스트
|
||||
|
||||
각 서비스 담당자는 아래 항목을 모두 충족해야 한다.
|
||||
|
||||
- `authConfig`에 서비스별 키명이 정확히 매핑되어 있다.
|
||||
- 서비스 내부 커스텀 TokenManager 클래스가 제거되었다.
|
||||
- 로그인/갱신 로직이 `getRemainingMs` 기반 타이머를 사용한다.
|
||||
- BaseLayout 컴포넌트에 `tokenManager`가 주입되어 있다.
|
||||
- 외부 참조 경로가 `entities/auth` 배럴 경로로 통일되어 있다.
|
||||
@@ -13,7 +13,7 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./index.css": "./dist/index.css"
|
||||
|
||||
+39
-39
@@ -1,41 +1,41 @@
|
||||
{
|
||||
"name": "@rsone/components",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./index.css": "./dist/index.css"
|
||||
"name": "@rsone/components",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/components": "workspace:*",
|
||||
"realgrid": "^2.9.5",
|
||||
"realgrid-react": "^0.9.7",
|
||||
"jszip": "^3.10.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "8.3.6",
|
||||
"typescript": "~5.6.2"
|
||||
}
|
||||
}
|
||||
"./index.css": "./dist/index.css"
|
||||
},
|
||||
"files": [
|
||||
"dist/**"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/components": "workspace:*",
|
||||
"realgrid": "^2.9.5",
|
||||
"realgrid-react": "^0.9.7",
|
||||
"jszip": "^3.10.1",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "8.3.6",
|
||||
"typescript": "~5.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+7417
-5823
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user