From 6f9923537eb16a6ada2e20e1e2697af31d8038ac Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Wed, 8 Apr 2026 10:12:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(phase3):=20API=20Key=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20-=20=EC=83=9D=EC=84=B1/=EB=B0=9C=EA=B8=89/=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD/=EC=8A=B9=EC=9D=B8/=EA=B6=8C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드: - AES-256-GCM 암호화 (ApiKey 생성/복호화 조회) - API Key 직접 생성 (ADMIN) + 신청→승인/반려 워크플로우 - 신청 필드 추가 (사용기간, 서비스IP, 서비스용도, 예상요청량) - Permission CRUD (bulk delete+recreate, @Modifying JPQL) - API Key 폐기, expires_at 자동 설정 - ErrorCode 5개 추가 프론트엔드: - MyKeysPage: 키 목록, 상태 배지, 폐기, raw key 모달 - KeyRequestPage: 기간 프리셋/직접선택 토글, 서비스IP, 용도, 예상요청량, API 체크박스 - KeyAdminPage: 신청 검토(필드 노출+기간 조정) + 키 관리(복호화 조회, 권한 편집) Closes #8 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/pages/apikeys/KeyAdminPage.tsx | 772 +++++++++++++++++- frontend/src/pages/apikeys/KeyRequestPage.tsx | 393 ++++++++- frontend/src/pages/apikeys/MyKeysPage.tsx | 267 +++++- frontend/src/services/apiKeyService.ts | 32 + frontend/src/types/apikey.ts | 82 ++ .../apikey/controller/ApiKeyController.java | 114 +++ .../controller/ApiKeyRequestController.java | 91 +++ .../apikey/dto/ApiKeyCreateResponse.java | 10 + .../apikey/dto/ApiKeyDetailResponse.java | 34 + .../apikey/dto/ApiKeyRequestCreateDto.java | 17 + .../apikey/dto/ApiKeyRequestResponse.java | 71 ++ .../apikey/dto/ApiKeyRequestReviewDto.java | 14 + .../connection/apikey/dto/ApiKeyResponse.java | 31 + .../apikey/dto/CreateApiKeyRequest.java | 8 + .../apikey/dto/PermissionResponse.java | 30 + .../apikey/dto/UpdatePermissionsRequest.java | 8 + .../connection/apikey/entity/SnpApiKey.java | 14 + .../apikey/entity/SnpApiKeyRequest.java | 41 +- .../apikey/entity/SnpApiPermission.java | 5 + .../repository/SnpApiKeyRepository.java | 3 + .../SnpApiKeyRequestRepository.java | 9 + .../SnpApiPermissionRepository.java | 10 + .../service/ApiKeyPermissionService.java | 76 ++ .../apikey/service/ApiKeyRequestService.java | 238 ++++++ .../apikey/service/ApiKeyService.java | 129 +++ .../common/exception/ErrorCode.java | 5 + .../connection/common/util/AesEncryptor.java | 79 ++ src/main/resources/application.yml | 2 + 28 files changed, 2578 insertions(+), 7 deletions(-) create mode 100644 frontend/src/services/apiKeyService.ts create mode 100644 frontend/src/types/apikey.ts create mode 100644 src/main/java/com/gcsc/connection/apikey/controller/ApiKeyController.java create mode 100644 src/main/java/com/gcsc/connection/apikey/controller/ApiKeyRequestController.java create mode 100644 src/main/java/com/gcsc/connection/apikey/dto/ApiKeyCreateResponse.java create mode 100644 src/main/java/com/gcsc/connection/apikey/dto/ApiKeyDetailResponse.java create mode 100644 src/main/java/com/gcsc/connection/apikey/dto/ApiKeyRequestCreateDto.java create mode 100644 src/main/java/com/gcsc/connection/apikey/dto/ApiKeyRequestResponse.java create mode 100644 src/main/java/com/gcsc/connection/apikey/dto/ApiKeyRequestReviewDto.java create mode 100644 src/main/java/com/gcsc/connection/apikey/dto/ApiKeyResponse.java create mode 100644 src/main/java/com/gcsc/connection/apikey/dto/CreateApiKeyRequest.java create mode 100644 src/main/java/com/gcsc/connection/apikey/dto/PermissionResponse.java create mode 100644 src/main/java/com/gcsc/connection/apikey/dto/UpdatePermissionsRequest.java create mode 100644 src/main/java/com/gcsc/connection/apikey/service/ApiKeyPermissionService.java create mode 100644 src/main/java/com/gcsc/connection/apikey/service/ApiKeyRequestService.java create mode 100644 src/main/java/com/gcsc/connection/apikey/service/ApiKeyService.java create mode 100644 src/main/java/com/gcsc/connection/common/util/AesEncryptor.java diff --git a/frontend/src/pages/apikeys/KeyAdminPage.tsx b/frontend/src/pages/apikeys/KeyAdminPage.tsx index 21f557f..0df4f22 100644 --- a/frontend/src/pages/apikeys/KeyAdminPage.tsx +++ b/frontend/src/pages/apikeys/KeyAdminPage.tsx @@ -1,8 +1,776 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { ApiKey, ApiKeyDetail, ApiKeyRequest } from '../../types/apikey'; +import type { ServiceInfo, ServiceApi } from '../../types/service'; +import { + getAllKeys, + getKeyDetail, + revokeKey, + getAllRequests, + reviewRequest, + getPermissions, + updatePermissions, +} from '../../services/apiKeyService'; +import { getServices, getServiceApis } from '../../services/serviceService'; + +const STATUS_BADGE: Record = { + ACTIVE: 'bg-green-100 text-green-800', + PENDING: 'bg-yellow-100 text-yellow-800', + REVOKED: 'bg-red-100 text-red-800', + EXPIRED: 'bg-gray-100 text-gray-800', + INACTIVE: 'bg-gray-100 text-gray-800', + APPROVED: 'bg-green-100 text-green-800', + REJECTED: 'bg-red-100 text-red-800', +}; + +const METHOD_COLOR: Record = { + GET: 'bg-green-100 text-green-800', + POST: 'bg-blue-100 text-blue-800', + PUT: 'bg-orange-100 text-orange-800', + DELETE: 'bg-red-100 text-red-800', +}; + +const formatDateTime = (dateStr: string | null): string => { + if (!dateStr) return '-'; + return new Date(dateStr).toLocaleString('ko-KR'); +}; + +interface AllApisMap { + services: ServiceInfo[]; + apisByService: Record; + apiById: Record; +} + const KeyAdminPage = () => { + const [activeTab, setActiveTab] = useState<'requests' | 'keys'>('requests'); + + // Shared state + const [allApisMap, setAllApisMap] = useState({ services: [], apisByService: {}, apiById: {} }); + const [error, setError] = useState(null); + + // Requests tab state + const [requests, setRequests] = useState([]); + const [requestsLoading, setRequestsLoading] = useState(true); + const [isReviewModalOpen, setIsReviewModalOpen] = useState(false); + const [selectedRequest, setSelectedRequest] = useState(null); + const [adjustedApiIds, setAdjustedApiIds] = useState>(new Set()); + const [reviewComment, setReviewComment] = useState(''); + const [adjustedFromDate, setAdjustedFromDate] = useState(''); + const [adjustedToDate, setAdjustedToDate] = useState(''); + + // Keys tab state + const [allKeys, setAllKeys] = useState([]); + const [keysLoading, setKeysLoading] = useState(true); + const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + const [selectedKeyDetail, setSelectedKeyDetail] = useState(null); + const [isPermissionModalOpen, setIsPermissionModalOpen] = useState(false); + const [permissionKeyId, setPermissionKeyId] = useState(null); + const [permissionKeyName, setPermissionKeyName] = useState(''); + const [permissionApiIds, setPermissionApiIds] = useState>(new Set()); + const [detailCopied, setDetailCopied] = useState(false); + + // Raw key modal (after approve) + const [rawKeyModal, setRawKeyModal] = useState<{ keyName: string; rawKey: string } | null>(null); + const [rawKeyCopied, setRawKeyCopied] = useState(false); + + const fetchAllApis = useCallback(async () => { + try { + const servicesRes = await getServices(); + if (servicesRes.success && servicesRes.data) { + const services = servicesRes.data; + const apisByService: Record = {}; + const apiById: Record = {}; + + await Promise.all( + services.map(async (service) => { + const apisRes = await getServiceApis(service.serviceId); + if (apisRes.success && apisRes.data) { + apisByService[service.serviceId] = apisRes.data; + apisRes.data.forEach((api) => { + apiById[api.apiId] = { ...api, serviceName: service.serviceName }; + }); + } + }), + ); + + setAllApisMap({ services, apisByService, apiById }); + } + } catch { + // Silently fail for API map loading + } + }, []); + + const fetchRequests = useCallback(async () => { + try { + setRequestsLoading(true); + const res = await getAllRequests(); + if (res.success && res.data) { + setRequests(res.data); + } + } catch { + setError('신청 목록을 불러오는데 실패했습니다.'); + } finally { + setRequestsLoading(false); + } + }, []); + + const fetchKeys = useCallback(async () => { + try { + setKeysLoading(true); + const res = await getAllKeys(); + if (res.success && res.data) { + setAllKeys(res.data); + } + } catch { + setError('키 목록을 불러오는데 실패했습니다.'); + } finally { + setKeysLoading(false); + } + }, []); + + useEffect(() => { + fetchAllApis(); + fetchRequests(); + fetchKeys(); + }, [fetchAllApis, fetchRequests, fetchKeys]); + + // --- Requests tab handlers --- + + const handleOpenReview = (req: ApiKeyRequest) => { + setSelectedRequest(req); + setAdjustedApiIds(new Set(req.requestedApiIds)); + setReviewComment(''); + setAdjustedFromDate(req.usageFromDate ? req.usageFromDate.split('T')[0] : ''); + setAdjustedToDate(req.usageToDate ? req.usageToDate.split('T')[0] : ''); + setError(null); + setIsReviewModalOpen(true); + }; + + const handleCloseReview = () => { + setIsReviewModalOpen(false); + setSelectedRequest(null); + setError(null); + }; + + const handleToggleReviewApi = (apiId: number) => { + setAdjustedApiIds((prev) => { + const next = new Set(prev); + if (next.has(apiId)) { + next.delete(apiId); + } else { + next.add(apiId); + } + return next; + }); + }; + + const handleReviewSubmit = async (status: 'APPROVED' | 'REJECTED') => { + if (!selectedRequest) return; + setError(null); + + try { + const res = await reviewRequest(selectedRequest.requestId, { + status, + reviewComment: reviewComment || undefined, + adjustedApiIds: status === 'APPROVED' ? Array.from(adjustedApiIds) : undefined, + adjustedFromDate: status === 'APPROVED' && adjustedFromDate ? adjustedFromDate : undefined, + adjustedToDate: status === 'APPROVED' && adjustedToDate ? adjustedToDate : undefined, + }); + + if (res.success) { + handleCloseReview(); + await fetchRequests(); + await fetchKeys(); + + if (status === 'APPROVED' && res.data) { + setRawKeyModal({ keyName: res.data.keyName, rawKey: res.data.rawKey }); + } + } else { + setError(res.message || '검토 처리에 실패했습니다.'); + } + } catch { + setError('검토 처리에 실패했습니다.'); + } + }; + + // --- Keys tab handlers --- + + const handleViewDetail = async (key: ApiKey) => { + setError(null); + try { + const res = await getKeyDetail(key.apiKeyId); + if (res.success && res.data) { + setSelectedKeyDetail(res.data); + setDetailCopied(false); + setIsDetailModalOpen(true); + } else { + setError(res.message || '키 상세 정보를 불러오는데 실패했습니다.'); + } + } catch { + setError('키 상세 정보를 불러오는데 실패했습니다.'); + } + }; + + const handleCopyDecryptedKey = async () => { + if (!selectedKeyDetail) return; + await navigator.clipboard.writeText(selectedKeyDetail.decryptedKey); + setDetailCopied(true); + setTimeout(() => setDetailCopied(false), 2000); + }; + + const handleOpenPermissions = async (key: ApiKey) => { + setError(null); + setPermissionKeyId(key.apiKeyId); + setPermissionKeyName(key.keyName); + + try { + const res = await getPermissions(key.apiKeyId); + if (res.success && res.data) { + setPermissionApiIds(new Set(res.data.map((p) => p.apiId))); + } else { + setPermissionApiIds(new Set()); + } + setIsPermissionModalOpen(true); + } catch { + setError('권한 정보를 불러오는데 실패했습니다.'); + } + }; + + const handleTogglePermissionApi = (apiId: number) => { + setPermissionApiIds((prev) => { + const next = new Set(prev); + if (next.has(apiId)) { + next.delete(apiId); + } else { + next.add(apiId); + } + return next; + }); + }; + + const handleSavePermissions = async () => { + if (permissionKeyId === null) return; + setError(null); + + try { + const res = await updatePermissions(permissionKeyId, { + apiIds: Array.from(permissionApiIds), + }); + if (res.success) { + setIsPermissionModalOpen(false); + setPermissionKeyId(null); + } else { + setError(res.message || '권한 저장에 실패했습니다.'); + } + } catch { + setError('권한 저장에 실패했습니다.'); + } + }; + + const handleRevokeKey = async (key: ApiKey) => { + if (!window.confirm(`'${key.keyName}' 키를 폐기하시겠습니까? 이 작업은 되돌릴 수 없습니다.`)) return; + + try { + const res = await revokeKey(key.apiKeyId); + if (res.success) { + await fetchKeys(); + } else { + setError(res.message || '키 폐기에 실패했습니다.'); + } + } catch { + setError('키 폐기에 실패했습니다.'); + } + }; + + const handleCopyRawKey = async () => { + if (!rawKeyModal) return; + await navigator.clipboard.writeText(rawKeyModal.rawKey); + setRawKeyCopied(true); + setTimeout(() => setRawKeyCopied(false), 2000); + }; + + // --- Render helpers --- + + const renderApiCheckboxes = ( + selectedIds: Set, + onToggle: (apiId: number) => void, + ) => { + return allApisMap.services.map((service) => { + const apis = allApisMap.apisByService[service.serviceId] || []; + if (apis.length === 0) return null; + + return ( +
+

{service.serviceName}

+
+ {apis.map((api) => ( + + ))} +
+
+ ); + }); + }; + return (
-

API Key Admin

-

Coming soon

+

API Key 관리

+ + {error && !isReviewModalOpen && !isDetailModalOpen && !isPermissionModalOpen && ( +
{error}
+ )} + + {/* Tabs */} +
+ + +
+ + {/* Tab: 신청 관리 */} + {activeTab === 'requests' && ( +
+ {requestsLoading ? ( +
로딩 중...
+ ) : ( +
+ + + + + + + + + + + + + + {requests.map((req) => ( + + + + + + + + + + ))} + {requests.length === 0 && ( + + + + )} + +
RequesterKey NamePurposeStatusAPIsCreated AtActions
{req.userName}{req.keyName} + {req.purpose || '-'} + + + {req.status} + + {req.requestedApiIds.length}개{formatDateTime(req.createdAt)} + {req.status === 'PENDING' && ( + + )} +
+ 신청 내역이 없습니다. +
+
+ )} +
+ )} + + {/* Tab: 키 관리 */} + {activeTab === 'keys' && ( +
+ {keysLoading ? ( +
로딩 중...
+ ) : ( +
+ + + + + + + + + + + + + + {allKeys.map((key) => ( + + + + + + + + + + ))} + {allKeys.length === 0 && ( + + + + )} + +
Key NamePrefixUserStatusLast UsedCreatedActions
{key.keyName}{key.apiKeyPrefix}{key.maskedKey} + + {key.status} + + {formatDateTime(key.lastUsedAt)}{formatDateTime(key.createdAt)} +
+ + + {key.status === 'ACTIVE' && ( + + )} +
+
+ 등록된 API Key가 없습니다. +
+
+ )} +
+ )} + + {/* Review Modal */} + {isReviewModalOpen && selectedRequest && ( +
+
+
+

신청 검토

+
+
+ {error && ( +
{error}
+ )} + +
+
+ +

{selectedRequest.userName}

+
+
+ +

{selectedRequest.keyName}

+
+
+ {selectedRequest.purpose && ( +
+ +

{selectedRequest.purpose}

+
+ )} + +
+ {selectedRequest.serviceIp && ( +
+ +

{selectedRequest.serviceIp}

+
+ )} + {selectedRequest.servicePurpose && ( +
+ +

{selectedRequest.servicePurpose}

+
+ )} + {selectedRequest.dailyRequestEstimate != null && ( +
+ +

{Number(selectedRequest.dailyRequestEstimate).toLocaleString()}건

+
+ )} +
+ +
+ +
+ setAdjustedFromDate(e.target.value)} + className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" /> + ~ + setAdjustedToDate(e.target.value)} + className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" /> +
+
+ +
+ +
+ {renderApiCheckboxes(adjustedApiIds, handleToggleReviewApi)} +
+
+ +
+ +