diff --git a/src/app/[locale]/(protected)/error.tsx b/src/app/[locale]/(protected)/error.tsx index 1cda06c7..8534082c 100644 --- a/src/app/[locale]/(protected)/error.tsx +++ b/src/app/[locale]/(protected)/error.tsx @@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; * Protected Group Error Boundary * * 특징: - * - DashboardLayout 자동 적용 (사이드바, 헤더 포함) + * - AuthenticatedLayout 자동 적용 (사이드바, 헤더 포함) * - 보호된 경로 내 에러만 포착 * - 인증된 사용자를 위한 친근한 에러 UI */ diff --git a/src/app/[locale]/(protected)/layout.tsx b/src/app/[locale]/(protected)/layout.tsx index 4adad018..d1829cdc 100644 --- a/src/app/[locale]/(protected)/layout.tsx +++ b/src/app/[locale]/(protected)/layout.tsx @@ -1,7 +1,7 @@ "use client"; import { useAuthGuard } from '@/hooks/useAuthGuard'; -import DashboardLayout from '@/layouts/DashboardLayout'; +import AuthenticatedLayout from '@/layouts/AuthenticatedLayout'; import { RootProvider } from '@/contexts/RootProvider'; /** @@ -32,7 +32,7 @@ export default function ProtectedLayout({ // 🎨 모든 하위 페이지에 공통 레이아웃 및 Context 적용 return ( - {children} + {children} ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/loading.tsx b/src/app/[locale]/(protected)/loading.tsx index 6266aa94..7e10b4a3 100644 --- a/src/app/[locale]/(protected)/loading.tsx +++ b/src/app/[locale]/(protected)/loading.tsx @@ -4,10 +4,10 @@ import { PageLoadingSpinner } from '@/components/ui/loading-spinner'; * Protected Group Loading UI * * 특징: - * - DashboardLayout 내에서 표시됨 (사이드바, 헤더 유지) + * - AuthenticatedLayout 내에서 표시됨 (사이드바, 헤더 유지) * - React Suspense 자동 적용 * - 페이지 전환 시 즉각적인 피드백 - * - 대시보드 스타일로 통일 + * - 공통 레이아웃 스타일로 통일 */ export default function ProtectedLoading() { return ( diff --git a/src/app/[locale]/(protected)/not-found.tsx b/src/app/[locale]/(protected)/not-found.tsx index 0a1c0432..6e209c17 100644 --- a/src/app/[locale]/(protected)/not-found.tsx +++ b/src/app/[locale]/(protected)/not-found.tsx @@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; * Protected Group 404 Not Found Page * * 특징: - * - DashboardLayout 자동 적용 (사이드바, 헤더 포함) + * - AuthenticatedLayout 자동 적용 (사이드바, 헤더 포함) * - 인증된 사용자만 볼 수 있음 * - 보호된 경로 내에서 404 발생 시 표시 */ diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index ada33373..e24722a9 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,6 +1,7 @@ 'use client'; import { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react'; +import { performFullLogout } from '@/lib/auth/logout'; // ===== 타입 정의 ===== @@ -54,7 +55,7 @@ interface AuthContextType { updateUser: (userId: string, updates: Partial) => void; deleteUser: (userId: string) => void; getUserByUserId: (userId: string) => User | undefined; - logout: () => void; // ✅ 추가: 로그아웃 + logout: () => Promise; // ✅ 추가: 로그아웃 (완전한 캐시 정리) clearTenantCache: (tenantId: number) => void; // ✅ 추가: 테넌트 캐시 삭제 resetAllData: () => void; } @@ -224,14 +225,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); }; - // ✅ 추가: 로그아웃 함수 - const logout = () => { - if (currentUser?.tenant?.id) { - clearTenantCache(currentUser.tenant.id); - } + // ✅ 추가: 로그아웃 함수 (완전한 캐시 정리) + const logout = async () => { + console.log('[Auth] Starting logout...'); + + // 1. React 상태 초기화 (UI 즉시 반영) setCurrentUser(null); - localStorage.removeItem('mes-currentUser'); - console.log('[Auth] Logged out and cleared tenant cache'); + + // 2. 완전한 로그아웃 수행 (Zustand, sessionStorage, localStorage, 서버 API) + await performFullLogout({ + skipServerLogout: false, // 서버 API 호출 (HttpOnly 쿠키 삭제) + redirectTo: null, // 리다이렉트는 호출하는 곳에서 처리 + }); + + console.log('[Auth] Logout completed'); }; // Context value diff --git a/src/layouts/DashboardLayout.tsx b/src/layouts/AuthenticatedLayout.tsx similarity index 97% rename from src/layouts/DashboardLayout.tsx rename to src/layouts/AuthenticatedLayout.tsx index e8f4023f..43531334 100644 --- a/src/layouts/DashboardLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -35,15 +35,17 @@ import { } from '@/components/ui/dropdown-menu'; import Sidebar from '@/components/layout/Sidebar'; import { useTheme } from '@/contexts/ThemeContext'; +import { useAuth } from '@/contexts/AuthContext'; import { deserializeMenuItems } from '@/lib/utils/menuTransform'; -interface DashboardLayoutProps { +interface AuthenticatedLayoutProps { children: React.ReactNode; } -export default function DashboardLayout({ children }: DashboardLayoutProps) { +export default function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) { const { menuItems, activeMenu, setActiveMenu, setMenuItems, sidebarCollapsed, toggleSidebar, _hasHydrated } = useMenuStore(); const { theme, setTheme } = useTheme(); + const { logout } = useAuth(); const router = useRouter(); const pathname = usePathname(); // 현재 경로 추적 @@ -174,20 +176,18 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { const handleLogout = async () => { try { - // HttpOnly Cookie 방식: Next.js API Route로 프록시 - await fetch('/api/auth/logout', { - method: 'POST', - }); - - // localStorage 정리 - localStorage.removeItem('user'); + // AuthContext의 logout() 호출 (완전한 캐시 정리 수행) + // - Zustand 스토어 초기화 + // - sessionStorage 캐시 삭제 (page_config_*, mes-*) + // - localStorage 사용자 데이터 삭제 + // - 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제) + await logout(); // 로그인 페이지로 리다이렉트 router.push('/login'); } catch (error) { console.error('로그아웃 처리 중 오류:', error); // 에러가 나도 로그인 페이지로 이동 - localStorage.removeItem('user'); router.push('/login'); } }; diff --git a/src/lib/auth/logout.ts b/src/lib/auth/logout.ts new file mode 100644 index 00000000..4a7bece4 --- /dev/null +++ b/src/lib/auth/logout.ts @@ -0,0 +1,225 @@ +/** + * 완전한 로그아웃 처리 + * + * 로그아웃 시 수행하는 작업: + * 1. Zustand 스토어 초기화 (메모리 캐시) + * 2. sessionStorage 캐시 삭제 (page_config_*, mes-*) + * 3. localStorage 사용자 데이터 삭제 (mes-currentUser, mes-users) + * 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제) + * + * @see claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md + */ + +import { useMasterDataStore } from '@/stores/masterDataStore'; +import { useItemStore } from '@/stores/itemStore'; + +// ===== 캐시 삭제 대상 Prefix ===== + +const SESSION_STORAGE_PREFIXES = [ + 'page_config_', // masterDataStore 페이지 구성 캐시 + 'mes-', // TenantAwareCache 테넌트 캐시 +]; + +const LOCAL_STORAGE_KEYS = [ + 'mes-currentUser', // 현재 사용자 정보 + 'mes-users', // 사용자 목록 +]; + +const LOCAL_STORAGE_PREFIXES = [ + 'mes-', // 테넌트 캐시 (mes-{tenantId}-*) +]; + +// ===== 캐시 정리 함수 ===== + +/** + * sessionStorage에서 우리 앱 캐시만 삭제 + * - page_config_* (페이지 구성) + * - mes-* (테넌트 캐시) + */ +export function clearSessionStorageCache(): void { + if (typeof window === 'undefined') return; + + const keysToRemove: string[] = []; + + Object.keys(sessionStorage).forEach((key) => { + if (SESSION_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))) { + keysToRemove.push(key); + } + }); + + keysToRemove.forEach((key) => { + sessionStorage.removeItem(key); + console.log(`[Logout] Cleared sessionStorage: ${key}`); + }); + + if (keysToRemove.length > 0) { + console.log(`[Logout] Cleared ${keysToRemove.length} sessionStorage items`); + } +} + +/** + * localStorage에서 사용자 데이터 및 테넌트 캐시 삭제 + */ +export function clearLocalStorageCache(): void { + if (typeof window === 'undefined') return; + + // 특정 키 삭제 + LOCAL_STORAGE_KEYS.forEach((key) => { + if (localStorage.getItem(key)) { + localStorage.removeItem(key); + console.log(`[Logout] Cleared localStorage: ${key}`); + } + }); + + // Prefix 기반 삭제 + const keysToRemove: string[] = []; + + Object.keys(localStorage).forEach((key) => { + if (LOCAL_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix))) { + keysToRemove.push(key); + } + }); + + keysToRemove.forEach((key) => { + localStorage.removeItem(key); + console.log(`[Logout] Cleared localStorage: ${key}`); + }); + + if (keysToRemove.length > 0) { + console.log(`[Logout] Cleared ${keysToRemove.length} localStorage items`); + } +} + +/** + * Zustand 스토어 초기화 + */ +export function resetZustandStores(): void { + try { + // masterDataStore 초기화 + const masterDataStore = useMasterDataStore.getState(); + masterDataStore.reset(); + console.log('[Logout] Reset masterDataStore'); + + // itemStore 초기화 + const itemStore = useItemStore.getState(); + itemStore.reset(); + console.log('[Logout] Reset itemStore'); + } catch (error) { + console.error('[Logout] Failed to reset Zustand stores:', error); + } +} + +/** + * 서버 로그아웃 API 호출 + * - HttpOnly 쿠키 삭제 (access_token, refresh_token) + */ +export async function callLogoutAPI(): Promise { + try { + const response = await fetch('/api/auth/logout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + console.warn('[Logout] Server logout failed:', response.status); + return false; + } + + console.log('[Logout] Server logout successful'); + return true; + } catch (error) { + console.error('[Logout] Server logout error:', error); + return false; + } +} + +// ===== 통합 로그아웃 함수 ===== + +/** + * 완전한 로그아웃 수행 + * + * 실행 순서: + * 1. Zustand 스토어 초기화 (즉시 UI 반영) + * 2. sessionStorage 캐시 삭제 + * 3. localStorage 사용자 데이터 삭제 + * 4. 서버 로그아웃 API 호출 + * + * @param options.skipServerLogout - 서버 로그아웃 생략 여부 (기본: false) + * @param options.redirectTo - 로그아웃 후 리다이렉트 경로 (기본: null) + * @returns 로그아웃 성공 여부 + */ +export async function performFullLogout(options?: { + skipServerLogout?: boolean; + redirectTo?: string | null; +}): Promise { + const { skipServerLogout = false, redirectTo = null } = options || {}; + + console.log('[Logout] Starting full logout...'); + + try { + // 1. Zustand 스토어 초기화 (즉시 UI 반영) + resetZustandStores(); + + // 2. sessionStorage 캐시 삭제 + clearSessionStorageCache(); + + // 3. localStorage 사용자 데이터 삭제 + clearLocalStorageCache(); + + // 4. 서버 로그아웃 API 호출 (HttpOnly 쿠키 삭제) + if (!skipServerLogout) { + await callLogoutAPI(); + } + + console.log('[Logout] Full logout completed successfully'); + + // 5. 리다이렉트 (선택적) + if (redirectTo && typeof window !== 'undefined') { + window.location.href = redirectTo; + } + + return true; + } catch (error) { + console.error('[Logout] Full logout failed:', error); + return false; + } +} + +// ===== 유틸리티 함수 ===== + +/** + * 현재 캐시 상태 확인 (디버깅용) + */ +export function debugCacheStatus(): void { + if (typeof window === 'undefined') { + console.log('[Debug] Server environment - no cache'); + return; + } + + console.group('[Debug] Cache Status'); + + // sessionStorage + const sessionKeys = Object.keys(sessionStorage).filter((key) => + SESSION_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix)) + ); + console.log('sessionStorage:', sessionKeys); + + // localStorage + const localKeys = Object.keys(localStorage).filter( + (key) => + LOCAL_STORAGE_KEYS.includes(key) || + LOCAL_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix)) + ); + console.log('localStorage:', localKeys); + + // Zustand + const masterDataState = useMasterDataStore.getState(); + console.log('masterDataStore.pageConfigs:', Object.keys(masterDataState.pageConfigs)); + + const itemState = useItemStore.getState(); + console.log('itemStore.items:', itemState.items.length); + + console.groupEnd(); +} \ No newline at end of file diff --git a/src/stores/masterDataStore.ts b/src/stores/masterDataStore.ts index b0fce1cc..2573243d 100644 --- a/src/stores/masterDataStore.ts +++ b/src/stores/masterDataStore.ts @@ -19,6 +19,9 @@ import { fetchPageConfigByType, invalidatePageConfigCache } from '@/lib/api/mast interface MasterDataStore { // === State === + // 현재 테넌트 ID (캐시 격리용) + currentTenantId: number | null; + // 페이지 구성 캐시 (페이지 타입별) pageConfigs: Record; @@ -36,8 +39,12 @@ interface MasterDataStore { // === Actions === + // 테넌트 ID 설정 (로그인 시 호출) + setCurrentTenantId: (tenantId: number | null) => void; + // 페이지 구성 가져오기 (하이브리드 로딩) - fetchPageConfig: (pageType: PageType) => Promise; + // tenantId 파라미터는 선택적 (없으면 currentTenantId 사용) + fetchPageConfig: (pageType: PageType, tenantId?: number) => Promise; // 페이지 구성 캐시 무효화 invalidateConfig: (pageType: PageType) => void; @@ -365,12 +372,19 @@ export const useMasterDataStore = create()( // ===== 초기화 ===== - reset: () => - set( - initialState, - false, - 'reset' - ), + reset: () => { + // 1. 메모리 캐시 초기화 + set(initialState, false, 'reset'); + + // 2. sessionStorage 캐시도 정리 + if (typeof window !== 'undefined') { + const pageTypes: PageType[] = ['item-master', 'quotation', 'sales-order', 'formula', 'pricing']; + pageTypes.forEach((pageType) => { + removeConfigFromSessionStorage(pageType); + }); + console.log('[masterDataStore] Reset: cleared memory and sessionStorage cache'); + } + }, }), { name: 'MasterDataStore',