- 품목 상세/수정 페이지 파일 다운로드 기능 개선 - DynamicItemForm 파일 업로드 UI/UX 개선 (시방서, 인정서) - BendingDiagramSection 조립/절곡 부품 전개도 통합 - API proxy route 품목 타입별 라우팅 개선 - ItemListClient 파일 다운로드 유틸리티 적용 - 품목코드 중복 체크 및 다이얼로그 추가 문서화: - DynamicItemForm 훅 분리 계획서 추가 (2161줄 → 900줄 목표) - 백엔드 API 마이그레이션 문서 추가 - 대용량 파일 처리 전략 가이드 추가 - 테넌트 데이터 격리 감사 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
10 KiB
10 KiB
~# 테넌트 데이터 격리 보안 강화 계획서
개요
| 항목 | 내용 |
|---|---|
| 목적 | 테넌트 간 데이터 오염/유출 방지 |
| 배경 | 로그아웃 후 캐시 잔존, 캐시 키 tenant.id 미포함 문제 발견 |
| 범위 | 프론트엔드 캐시 관리, API 프록시 검증 |
| 작성일 | 2025-12-12 |
우선순위 및 구현 범위
| 우선순위 | 항목 | 이유 | 예상 공수 | 상태 |
|---|---|---|---|---|
| 1 (필수) | 로그아웃 시 캐시 완전 정리 | 백엔드가 막을 수 없음 | 1시간 | ✅ 완료 (2025-12-14) |
| 2 (필수) | 캐시 키에 tenant.id 추가 | 백엔드가 막을 수 없음 | 2시간 | ⬜ |
| 3 (권장) | API 프록시 tenant.id 검증 | 이중 방어 (Defense in Depth) | 2시간 | ⬜ |
| 4 (선택) | tenant.id 주기적 검증 | 추가 안전장치 | 1시간 | ⬜ |
1. 로그아웃 시 캐시 완전 정리
1.1 현재 문제
로그아웃 시:
✅ HttpOnly 쿠키 삭제 (access_token, refresh_token)
❌ sessionStorage 캐시 잔존 (page_config_*)
❌ Zustand 메모리 캐시 잔존 (itemStore, masterDataStore)
❌ localStorage 사용자 데이터 잔존 (mes-currentUser)
1.2 구현 계획
파일: src/lib/auth/logout.ts (신규 생성)
/**
* 완전한 로그아웃 수행
* - Zustand 스토어 초기화
* - sessionStorage 캐시 삭제
* - localStorage 사용자 데이터 삭제
* - 서버 로그아웃 API 호출
*/
export async function performFullLogout(): Promise<void> {
// 1. Zustand 스토어 초기화
// 2. sessionStorage 우리 앱 캐시만 삭제
// 3. localStorage 사용자 데이터 삭제
// 4. 서버 로그아웃 API 호출
// 5. 로그인 페이지로 리다이렉트
}
수정 파일 목록
| 파일 | 수정 내용 |
|---|---|
src/lib/auth/logout.ts |
신규 생성 - 통합 로그아웃 함수 |
src/contexts/AuthContext.tsx |
logout 함수에서 performFullLogout 호출 |
src/stores/masterDataStore.ts |
reset() 함수가 sessionStorage도 정리하도록 수정 |
src/stores/itemStore.ts |
reset() 함수 확인 (메모리만 사용 시 수정 불필요) |
src/layouts/AuthenticatedLayout.tsx |
handleLogout에서 AuthContext.logout() 호출 (기존 DashboardLayout.tsx) |
1.3 삭제 대상 캐시
| 저장소 | Prefix | 설명 |
|---|---|---|
| sessionStorage | page_config_* |
페이지 구성 캐시 |
| sessionStorage | mes-* |
테넌트 캐시 (TenantAwareCache) |
| localStorage | mes-currentUser |
현재 사용자 정보 |
| localStorage | mes-users |
사용자 목록 |
| Zustand | itemStore | 품목 메모리 캐시 |
| Zustand | masterDataStore | 페이지 구성 메모리 캐시 |
1.4 주의사항
// ❌ 잘못된 구현: 전체 삭제 (다른 앱 데이터 삭제 위험)
sessionStorage.clear();
// ✅ 올바른 구현: 우리 앱 prefix만 삭제
const prefixes = ['page_config_', 'mes-'];
Object.keys(sessionStorage).forEach(key => {
if (prefixes.some(p => key.startsWith(p))) {
sessionStorage.removeItem(key);
}
});
2. 캐시 키에 tenant.id 추가
2.1 현재 문제
// 현재: tenant.id 없음
const key = `page_config_item-master`;
// 문제: 다른 tenant 사용자가 같은 브라우저 사용 시 캐시 충돌
// tenant 100 사용자 → page_config_item-master 저장
// tenant 200 사용자 → 같은 키에서 tenant 100 데이터 읽음 ⚠️
2.2 구현 계획
변경 전/후 비교
| 구분 | 변경 전 | 변경 후 |
|---|---|---|
| 캐시 키 형식 | page_config_item-master |
page_config_282_item-master |
| tenant.id 소스 | 없음 | 파라미터로 전달 |
수정 파일 목록
| 파일 | 수정 내용 |
|---|---|
src/stores/masterDataStore.ts |
캐시 키에 tenantId 포함 |
src/components/*/ |
fetchPageConfig 호출 시 tenantId 전달 |
2.3 tenant.id 전달 방식 결정
옵션 비교
| 옵션 | 장점 | 단점 | 권장 |
|---|---|---|---|
| A. 파라미터 전달 | 명시적, 추적 용이 | 호출부 전부 수정 필요 | ✅ |
| B. Zustand 상태 추가 | 한 곳에서 관리 | store 간 의존성 발생 | |
| C. TenantAwareCache 활용 | 이미 구현됨 | 생성자에 tenantId 필요 | ✅ |
권장: 옵션 A + C 조합
// masterDataStore.ts
import { TenantAwareCache } from '@/lib/cache/TenantAwareCache';
fetchPageConfig: async (pageType: PageType, tenantId: number) => {
const cache = new TenantAwareCache(tenantId, sessionStorage, CACHE_TTL);
// 캐시 조회 (tenant.id 자동 검증)
const cached = cache.get<PageConfig>(`page_config_${pageType}`);
if (cached) return cached;
// API 조회 후 캐시 저장
const config = await fetchPageConfigByType(pageType);
if (config) {
cache.set(`page_config_${pageType}`, config);
}
return config;
}
2.4 마이그레이션 전략
// 배포 시 기존 캐시 자동 정리 (일회성)
function migrateOldCache() {
const oldPatterns = [
'page_config_item-master',
'page_config_quotation',
'page_config_sales-order',
'page_config_formula',
'page_config_pricing',
];
oldPatterns.forEach(key => {
if (sessionStorage.getItem(key)) {
sessionStorage.removeItem(key);
console.log(`[Migration] Removed old cache: ${key}`);
}
});
}
3. API 프록시 tenant.id 검증 (권장)
3.1 현재 문제
// src/app/api/proxy/[...path]/route.ts
const backendUrl = `${API_URL}/api/v1/${params.path.join('/')}`;
// → URL에 tenantId가 있어도 검증 없이 백엔드로 전달
3.2 구현 계획
// JWT 디코딩 라이브러리 설치
npm install jwt-decode
// 프록시에서 검증 추가
import { jwtDecode } from 'jwt-decode';
async function proxyRequest(...) {
const token = request.cookies.get('access_token')?.value;
const decoded = jwtDecode<{ tenant_id: number }>(token);
// URL에서 tenantId 추출
const urlTenantMatch = pathStr.match(/tenants\/(\d+)/);
if (urlTenantMatch) {
const urlTenantId = parseInt(urlTenantMatch[1]);
// 불일치 시 차단
if (urlTenantId !== decoded.tenant_id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
}
3.3 수정 파일
| 파일 | 수정 내용 |
|---|---|
package.json |
jwt-decode 의존성 추가 |
src/app/api/proxy/[...path]/route.ts |
tenant.id 검증 로직 추가 |
4. tenant.id 주기적 검증 (선택)
4.1 구현 계획
// src/contexts/AuthContext.tsx
useEffect(() => {
const validateTenant = async () => {
const response = await fetch('/api/auth/check');
const data = await response.json();
if (data.tenant?.id !== currentUser?.tenant?.id) {
console.error('Tenant mismatch! Forcing logout.');
logout();
}
};
const interval = setInterval(validateTenant, 5 * 60 * 1000); // 5분
return () => clearInterval(interval);
}, [currentUser?.tenant?.id]);
체크리스트
Phase 1: 로그아웃 캐시 정리 (필수) ✅ 완료 (2025-12-14)
src/lib/auth/logout.ts생성- Zustand 스토어 초기화 함수 호출 (
resetZustandStores) - sessionStorage prefix 기반 삭제 (
clearSessionStorageCache) - localStorage 사용자 데이터 삭제 (
clearLocalStorageCache) - 서버 로그아웃 API 호출 (
callLogoutAPI) - 리다이렉트 옵션 지원 (
redirectTo파라미터)
- Zustand 스토어 초기화 함수 호출 (
src/stores/masterDataStore.ts수정- reset() 함수에 sessionStorage 정리 추가
src/contexts/AuthContext.tsx수정- logout 함수에서 performFullLogout 호출
- logout 함수 타입
Promise<void>로 변경
src/layouts/AuthenticatedLayout.tsx수정 (2025-12-14 추가)- 직접 API 호출 → AuthContext.logout() 호출로 변경
- 파일명 DashboardLayout.tsx → AuthenticatedLayout.tsx 변경
- 테스트 ✅ 완료 (2025-12-14)
- 로그아웃 후 sessionStorage 비어있는지 확인
- 로그아웃 후 Zustand DevTools에서 초기화 확인
- 다른 계정 로그인 시 이전 데이터 안 보이는지 확인
Phase 2: 캐시 키 tenant.id 추가 (필수)
src/stores/masterDataStore.ts수정- TenantAwareCache import
- fetchPageConfig 시그니처에 tenantId 추가
- 캐시 키 생성 시 tenantId 포함
- getConfigFromSessionStorage 함수 수정
- setConfigToSessionStorage 함수 수정
- 호출부 수정
- fetchPageConfig 호출하는 모든 컴포넌트에서 tenantId 전달
- 마이그레이션
- 기존 형식 캐시 자동 정리 로직 추가
- 테스트
- sessionStorage에 tenant.id 포함된 키 생성 확인
- 다른 tenant 캐시와 격리되는지 확인
Phase 3: API 프록시 검증 (권장)
npm install jwt-decode실행src/app/api/proxy/[...path]/route.ts수정- JWT 디코딩 함수 추가
- URL tenant.id 추출 로직 추가
- 불일치 시 403 반환 로직 추가
- 테스트
- 정상 요청 통과 확인
- URL 조작 시 403 반환 확인
Phase 4: 주기적 검증 (선택)
src/contexts/AuthContext.tsx수정- 5분 간격 검증 useEffect 추가
- 테스트
- localStorage 조작 시 강제 로그아웃 확인
예상 부작용 및 대응
| 부작용 | 심각도 | 대응 |
|---|---|---|
| 재로그인 시 캐시 miss로 느려짐 | 🟢 낮음 | 수용 (로그아웃은 빈번하지 않음) |
| 기존 캐시 무효화 | 🟢 낮음 | 마이그레이션 로직으로 자동 정리 |
| 호출부 수정 필요 | 🟡 중간 | 점진적 적용 가능 |
완료 기준
- ✅ 로그아웃 후 sessionStorage, Zustand 캐시 완전 삭제
- ✅ 다른 tenant 사용자 로그인 시 이전 데이터 노출 없음
- ✅ 캐시 키에 tenant.id 포함되어 격리됨
- ✅ (권장) API 프록시에서 tenant.id 불일치 시 차단