Merge pull request 'feat(phase3): API Key 관리 - 생성/발급/신청/승인/권한' (#14) from feature/ISSUE-8-phase3-apikey into develop

This commit is contained in:
HYOJIN 2026-04-08 10:14:08 +09:00
커밋 d0a3bc1a27
29개의 변경된 파일2583개의 추가작업 그리고 7개의 파일을 삭제

파일 보기

@ -15,6 +15,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/ko/1.0.0/).
- 헬스체크 상태 조회/이력 API (GET/POST /api/heartbeat) (#7)
- @EnableMethodSecurity + @PreAuthorize 역할 기반 접근 제어 (#7)
- 테넌트/사용자/서비스 관리 프론트엔드 페이지 (CRUD 테이블 + 모달) (#7)
- API Key AES-256-GCM 암호화 생성/복호화 조회 (#8)
- API Key 신청→승인/반려 워크플로우 (#8)
- API Key 신청 필드 (사용기간, 서비스IP, 서비스용도, 하루 예상 요청량) (#8)
- API Key Permission 관리 API (#8)
- API Key 관리 프론트엔드 (신청/검토/키관리/권한편집) (#8)
## [2026-04-07]

파일 보기

@ -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<string, string> = {
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<string, string> = {
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<number, ServiceApi[]>;
apiById: Record<number, ServiceApi & { serviceName: string }>;
}
const KeyAdminPage = () => {
const [activeTab, setActiveTab] = useState<'requests' | 'keys'>('requests');
// Shared state
const [allApisMap, setAllApisMap] = useState<AllApisMap>({ services: [], apisByService: {}, apiById: {} });
const [error, setError] = useState<string | null>(null);
// Requests tab state
const [requests, setRequests] = useState<ApiKeyRequest[]>([]);
const [requestsLoading, setRequestsLoading] = useState(true);
const [isReviewModalOpen, setIsReviewModalOpen] = useState(false);
const [selectedRequest, setSelectedRequest] = useState<ApiKeyRequest | null>(null);
const [adjustedApiIds, setAdjustedApiIds] = useState<Set<number>>(new Set());
const [reviewComment, setReviewComment] = useState('');
const [adjustedFromDate, setAdjustedFromDate] = useState('');
const [adjustedToDate, setAdjustedToDate] = useState('');
// Keys tab state
const [allKeys, setAllKeys] = useState<ApiKey[]>([]);
const [keysLoading, setKeysLoading] = useState(true);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedKeyDetail, setSelectedKeyDetail] = useState<ApiKeyDetail | null>(null);
const [isPermissionModalOpen, setIsPermissionModalOpen] = useState(false);
const [permissionKeyId, setPermissionKeyId] = useState<number | null>(null);
const [permissionKeyName, setPermissionKeyName] = useState('');
const [permissionApiIds, setPermissionApiIds] = useState<Set<number>>(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<number, ServiceApi[]> = {};
const apiById: Record<number, ServiceApi & { serviceName: string }> = {};
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<number>,
onToggle: (apiId: number) => void,
) => {
return allApisMap.services.map((service) => {
const apis = allApisMap.apisByService[service.serviceId] || [];
if (apis.length === 0) return null;
return (
<div key={service.serviceId} className="mb-3">
<h4 className="text-sm font-medium text-gray-700 mb-1">{service.serviceName}</h4>
<div className="space-y-1 pl-4">
{apis.map((api) => (
<label
key={api.apiId}
className="flex items-center gap-2 py-0.5 cursor-pointer hover:bg-gray-50 rounded px-1"
>
<input
type="checkbox"
checked={selectedIds.has(api.apiId)}
onChange={() => onToggle(api.apiId)}
className="rounded"
/>
<span
className={`inline-block px-1.5 py-0.5 rounded text-xs font-bold ${
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
}`}
>
{api.apiMethod}
</span>
<span className="font-mono text-sm text-gray-700">{api.apiPath}</span>
<span className="text-sm text-gray-500">- {api.apiName}</span>
</label>
))}
</div>
</div>
);
});
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">API Key Admin</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
<h1 className="text-2xl font-bold text-gray-900 mb-6">API Key </h1>
{error && !isReviewModalOpen && !isDetailModalOpen && !isPermissionModalOpen && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
{/* Tabs */}
<div className="flex border-b mb-6">
<button
onClick={() => setActiveTab('requests')}
className={`px-4 py-2 text-sm font-medium -mb-px ${
activeTab === 'requests'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
<button
onClick={() => setActiveTab('keys')}
className={`px-4 py-2 text-sm font-medium -mb-px ${
activeTab === 'keys'
? 'border-b-2 border-blue-600 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
</button>
</div>
{/* Tab: 신청 관리 */}
{activeTab === 'requests' && (
<div>
{requestsLoading ? (
<div className="text-center py-10 text-gray-500"> ...</div>
) : (
<div className="overflow-x-auto bg-white rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500">Requester</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Key Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Purpose</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">APIs</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Created At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{requests.map((req) => (
<tr key={req.requestId} className="hover:bg-gray-50">
<td className="px-4 py-3">{req.userName}</td>
<td className="px-4 py-3">{req.keyName}</td>
<td className="px-4 py-3 text-gray-500 max-w-[200px] truncate">
{req.purpose || '-'}
</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_BADGE[req.status] || 'bg-gray-100 text-gray-800'
}`}
>
{req.status}
</span>
</td>
<td className="px-4 py-3 text-gray-500">{req.requestedApiIds.length}</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(req.createdAt)}</td>
<td className="px-4 py-3">
{req.status === 'PENDING' && (
<button
onClick={() => handleOpenReview(req)}
className="text-blue-600 hover:text-blue-800 font-medium text-sm"
>
</button>
)}
</td>
</tr>
))}
{requests.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Tab: 키 관리 */}
{activeTab === 'keys' && (
<div>
{keysLoading ? (
<div className="text-center py-10 text-gray-500"> ...</div>
) : (
<div className="overflow-x-auto bg-white rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500">Key Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Prefix</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">User</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Last Used</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Created</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{allKeys.map((key) => (
<tr key={key.apiKeyId} className="hover:bg-gray-50">
<td className="px-4 py-3">{key.keyName}</td>
<td className="px-4 py-3 font-mono text-gray-600">{key.apiKeyPrefix}</td>
<td className="px-4 py-3 text-gray-500">{key.maskedKey}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_BADGE[key.status] || 'bg-gray-100 text-gray-800'
}`}
>
{key.status}
</span>
</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.lastUsedAt)}</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.createdAt)}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
onClick={() => handleViewDetail(key)}
className="text-blue-600 hover:text-blue-800 font-medium text-sm"
>
</button>
<button
onClick={() => handleOpenPermissions(key)}
className="text-purple-600 hover:text-purple-800 font-medium text-sm"
>
</button>
{key.status === 'ACTIVE' && (
<button
onClick={() => handleRevokeKey(key)}
className="text-red-600 hover:text-red-800 font-medium text-sm"
>
</button>
)}
</div>
</td>
</tr>
))}
{allKeys.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
API Key가 .
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Review Modal */}
{isReviewModalOpen && selectedRequest && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900"> </h2>
</div>
<div className="px-6 py-4 space-y-4 overflow-y-auto flex-1">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="text-gray-900">{selectedRequest.userName}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Key Name</label>
<p className="text-gray-900">{selectedRequest.keyName}</p>
</div>
</div>
{selectedRequest.purpose && (
<div>
<label className="block text-sm font-medium text-gray-500"></label>
<p className="text-gray-900">{selectedRequest.purpose}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{selectedRequest.serviceIp && (
<div>
<label className="block text-sm font-medium text-gray-500"> IP</label>
<p className="font-mono text-gray-900">{selectedRequest.serviceIp}</p>
</div>
)}
{selectedRequest.servicePurpose && (
<div>
<label className="block text-sm font-medium text-gray-500"> </label>
<p className="text-gray-900">{selectedRequest.servicePurpose}</p>
</div>
)}
{selectedRequest.dailyRequestEstimate != null && (
<div>
<label className="block text-sm font-medium text-gray-500"> </label>
<p className="text-gray-900">{Number(selectedRequest.dailyRequestEstimate).toLocaleString()}</p>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> ( )</label>
<div className="flex items-center gap-2">
<input type="date" value={adjustedFromDate}
onChange={(e) => setAdjustedFromDate(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" />
<span className="text-gray-500">~</span>
<input type="date" value={adjustedToDate}
onChange={(e) => setAdjustedToDate(e.target.value)}
className="border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
API ({adjustedApiIds.size} )
</label>
<div className="border rounded-lg p-3 max-h-60 overflow-y-auto">
{renderApiCheckboxes(adjustedApiIds, handleToggleReviewApi)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> </label>
<textarea
value={reviewComment}
onChange={(e) => setReviewComment(e.target.value)}
rows={3}
placeholder="검토 의견을 입력하세요"
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end gap-2">
<button
type="button"
onClick={handleCloseReview}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
<button
onClick={() => handleReviewSubmit('REJECTED')}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
<button
onClick={() => handleReviewSubmit('APPROVED')}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</div>
</div>
)}
{/* Detail Modal */}
{isDetailModalOpen && selectedKeyDetail && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900"> </h2>
</div>
<div className="px-6 py-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-500">Key Name</label>
<p className="text-gray-900">{selectedKeyDetail.keyName}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">User</label>
<p className="text-gray-900">{selectedKeyDetail.userName}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Prefix</label>
<p className="font-mono text-gray-900">{selectedKeyDetail.apiKeyPrefix}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Status</label>
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_BADGE[selectedKeyDetail.status] || 'bg-gray-100 text-gray-800'
}`}
>
{selectedKeyDetail.status}
</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Expires At</label>
<p className="text-gray-900">{formatDateTime(selectedKeyDetail.expiresAt)}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Last Used</label>
<p className="text-gray-900">{formatDateTime(selectedKeyDetail.lastUsedAt)}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">Created At</label>
<p className="text-gray-900">{formatDateTime(selectedKeyDetail.createdAt)}</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500 mb-1">Decrypted Key</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-gray-100 px-3 py-2 rounded-lg text-sm font-mono break-all">
{selectedKeyDetail.decryptedKey}
</code>
<button
onClick={handleCopyDecryptedKey}
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg text-sm font-medium"
>
{detailCopied ? '복사됨!' : '복사'}
</button>
</div>
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end">
<button
onClick={() => {
setIsDetailModalOpen(false);
setSelectedKeyDetail(null);
}}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</div>
</div>
)}
{/* Permission Modal */}
{isPermissionModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">
- {permissionKeyName}
</h2>
</div>
<div className="px-6 py-4 overflow-y-auto flex-1">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm mb-4">{error}</div>
)}
<p className="text-sm text-gray-500 mb-3">
{permissionApiIds.size} API
</p>
<div className="border rounded-lg p-3">
{renderApiCheckboxes(permissionApiIds, handleTogglePermissionApi)}
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsPermissionModalOpen(false);
setPermissionKeyId(null);
setError(null);
}}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
<button
onClick={handleSavePermissions}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</div>
</div>
)}
{/* Raw Key Modal (after approve) */}
{rawKeyModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">API Key </h2>
</div>
<div className="px-6 py-4 space-y-4">
<div className="p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
. .
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label>
<p className="text-gray-900">{rawKeyModal.keyName}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-gray-100 px-3 py-2 rounded-lg text-sm font-mono break-all">
{rawKeyModal.rawKey}
</code>
<button
onClick={handleCopyRawKey}
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg text-sm font-medium"
>
{rawKeyCopied ? '복사됨!' : '복사'}
</button>
</div>
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end">
<button
onClick={() => {
setRawKeyModal(null);
setRawKeyCopied(false);
}}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</div>
</div>
)}
</div>
);
};

파일 보기

@ -1,8 +1,397 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import type { ServiceInfo, ServiceApi } from '../../types/service';
import { getServices, getServiceApis } from '../../services/serviceService';
import { createKeyRequest } from '../../services/apiKeyService';
const METHOD_COLOR: Record<string, string> = {
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 KeyRequestPage = () => {
const navigate = useNavigate();
const [services, setServices] = useState<ServiceInfo[]>([]);
const [serviceApisMap, setServiceApisMap] = useState<Record<number, ServiceApi[]>>({});
const [expandedServices, setExpandedServices] = useState<Set<number>>(new Set());
const [selectedApiIds, setSelectedApiIds] = useState<Set<number>>(new Set());
const [keyName, setKeyName] = useState('');
const [purpose, setPurpose] = useState('');
const [serviceIp, setServiceIp] = useState('');
const [servicePurpose, setServicePurpose] = useState('');
const [dailyRequestEstimate, setDailyRequestEstimate] = useState('');
const [usagePeriodMode, setUsagePeriodMode] = useState<'preset' | 'custom'>('preset');
const [usageFromDate, setUsageFromDate] = useState('');
const [usageToDate, setUsageToDate] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
const fetchData = async () => {
try {
setIsLoading(true);
const servicesRes = await getServices();
if (servicesRes.success && servicesRes.data) {
const activeServices = servicesRes.data.filter((s) => s.isActive);
setServices(activeServices);
const apisMap: Record<number, ServiceApi[]> = {};
await Promise.all(
activeServices.map(async (service) => {
const apisRes = await getServiceApis(service.serviceId);
if (apisRes.success && apisRes.data) {
apisMap[service.serviceId] = apisRes.data.filter((a) => a.isActive);
}
}),
);
setServiceApisMap(apisMap);
} else {
setError(servicesRes.message || '서비스 목록을 불러오는데 실패했습니다.');
}
} catch {
setError('서비스 목록을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const handleToggleService = (serviceId: number) => {
setExpandedServices((prev) => {
const next = new Set(prev);
if (next.has(serviceId)) {
next.delete(serviceId);
} else {
next.add(serviceId);
}
return next;
});
};
const handleToggleApi = (apiId: number) => {
setSelectedApiIds((prev) => {
const next = new Set(prev);
if (next.has(apiId)) {
next.delete(apiId);
} else {
next.add(apiId);
}
return next;
});
};
const handleToggleAllServiceApis = (serviceId: number) => {
const apis = serviceApisMap[serviceId] || [];
const allSelected = apis.every((a) => selectedApiIds.has(a.apiId));
setSelectedApiIds((prev) => {
const next = new Set(prev);
apis.forEach((a) => {
if (allSelected) {
next.delete(a.apiId);
} else {
next.add(a.apiId);
}
});
return next;
});
};
const handlePresetPeriod = (months: number) => {
const from = new Date();
const to = new Date();
to.setMonth(to.getMonth() + months);
setUsageFromDate(from.toISOString().split('T')[0]);
setUsageToDate(to.toISOString().split('T')[0]);
setUsagePeriodMode('preset');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedApiIds.size === 0) {
setError('최소 하나의 API를 선택해주세요.');
return;
}
if (!usageFromDate || !usageToDate) {
setError('사용 기간을 설정해주세요.');
return;
}
setError(null);
setIsSubmitting(true);
try {
const res = await createKeyRequest({
keyName,
purpose: purpose || undefined,
requestedApiIds: Array.from(selectedApiIds),
serviceIp: serviceIp || undefined,
servicePurpose: servicePurpose || undefined,
dailyRequestEstimate: dailyRequestEstimate ? Number(dailyRequestEstimate) : undefined,
usageFromDate,
usageToDate,
});
if (res.success) {
setSuccess(true);
} else {
setError(res.message || 'API Key 신청에 실패했습니다.');
}
} catch {
setError('API Key 신청에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return <div className="text-center py-10 text-gray-500"> ...</div>;
}
if (success) {
return (
<div className="max-w-lg mx-auto mt-10 text-center">
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<h2 className="text-lg font-semibold text-green-800 mb-2"> </h2>
<p className="text-green-700 text-sm mb-4">
API Key가 .
</p>
<button
onClick={() => navigate('/apikeys/my-keys')}
className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</div>
);
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">API Key Request</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
<h1 className="text-2xl font-bold text-gray-900 mb-6">API Key </h1>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<form onSubmit={handleSubmit}>
<div className="bg-white rounded-lg shadow p-6 mb-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Key Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
required
placeholder="API Key 이름을 입력하세요"
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"> </label>
<textarea
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
rows={2}
placeholder="사용 목적을 입력하세요"
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<div className="flex items-center gap-2 mb-2">
<button type="button" onClick={() => handlePresetPeriod(3)}
disabled={usagePeriodMode === 'custom'}
className={`px-3 py-1.5 text-sm rounded-lg border ${usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50'}`}>
3
</button>
<button type="button" onClick={() => handlePresetPeriod(6)}
disabled={usagePeriodMode === 'custom'}
className={`px-3 py-1.5 text-sm rounded-lg border ${usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50'}`}>
6
</button>
<button type="button" onClick={() => handlePresetPeriod(9)}
disabled={usagePeriodMode === 'custom'}
className={`px-3 py-1.5 text-sm rounded-lg border ${usagePeriodMode === 'custom' ? 'opacity-40 cursor-not-allowed' : 'hover:bg-blue-50'}`}>
9
</button>
<span className="text-gray-400 mx-1">|</span>
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer select-none">
<button type="button"
onClick={() => setUsagePeriodMode(usagePeriodMode === 'custom' ? 'preset' : 'custom')}
className={`relative w-10 h-5 rounded-full transition-colors ${usagePeriodMode === 'custom' ? 'bg-blue-600' : 'bg-gray-300'}`}>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${usagePeriodMode === 'custom' ? 'translate-x-5' : ''}`} />
</button>
</label>
</div>
<div className="flex items-center gap-2">
<input type="date" value={usageFromDate}
onChange={(e) => setUsageFromDate(e.target.value)}
readOnly={usagePeriodMode !== 'custom'}
className={`border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500' : ''}`} />
<span className="text-gray-500">~</span>
<input type="date" value={usageToDate}
onChange={(e) => setUsageToDate(e.target.value)}
readOnly={usagePeriodMode !== 'custom'}
className={`border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none ${usagePeriodMode !== 'custom' ? 'bg-gray-50 text-gray-500' : ''}`} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
IP <span className="text-red-500">*</span>
</label>
<input type="text" value={serviceIp}
onChange={(e) => setServiceIp(e.target.value)}
required placeholder="예: 192.168.1.100"
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" />
<p className="text-xs text-gray-400 mt-1"> API Key로 IP</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<select value={servicePurpose}
onChange={(e) => setServicePurpose(e.target.value)}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none">
<option value=""></option>
<option value="로컬 환경"> </option>
<option value="개발 서버"> </option>
<option value="검증 서버"> </option>
<option value="운영 서버"> </option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<select value={dailyRequestEstimate}
onChange={(e) => setDailyRequestEstimate(e.target.value)}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none">
<option value=""></option>
<option value="100">100 </option>
<option value="500">100~500</option>
<option value="1000">500~1,000</option>
<option value="5000">1,000~5,000</option>
<option value="10000">5,000~10,000</option>
<option value="50000">10,000 </option>
</select>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow mb-6">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">
API <span className="text-sm font-normal text-gray-500">({selectedApiIds.size} )</span>
</h2>
</div>
<div className="divide-y divide-gray-200">
{services.map((service) => {
const apis = serviceApisMap[service.serviceId] || [];
const isExpanded = expandedServices.has(service.serviceId);
const selectedCount = apis.filter((a) => selectedApiIds.has(a.apiId)).length;
const allSelected = apis.length > 0 && apis.every((a) => selectedApiIds.has(a.apiId));
return (
<div key={service.serviceId}>
<div
className="px-6 py-3 flex items-center justify-between cursor-pointer hover:bg-gray-50"
onClick={() => handleToggleService(service.serviceId)}
>
<div className="flex items-center gap-3">
<span className="text-gray-400 text-sm">{isExpanded ? '\u25BC' : '\u25B6'}</span>
<span className="font-medium text-gray-900">{service.serviceName}</span>
{selectedCount > 0 && (
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded-full">
{selectedCount}/{apis.length}
</span>
)}
</div>
<span className="text-sm text-gray-500">{apis.length} API</span>
</div>
{isExpanded && (
<div className="px-6 pb-3">
{apis.length > 0 && (
<div className="mb-2 pl-6">
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={allSelected}
onChange={() => handleToggleAllServiceApis(service.serviceId)}
className="rounded"
/>
</label>
</div>
)}
<div className="space-y-1 pl-6">
{apis.map((api) => (
<label
key={api.apiId}
className="flex items-center gap-2 py-1 cursor-pointer hover:bg-gray-50 rounded px-2"
>
<input
type="checkbox"
checked={selectedApiIds.has(api.apiId)}
onChange={() => handleToggleApi(api.apiId)}
className="rounded"
/>
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-bold ${
METHOD_COLOR[api.apiMethod] || 'bg-gray-100 text-gray-800'
}`}
>
{api.apiMethod}
</span>
<span className="font-mono text-sm text-gray-700">{api.apiPath}</span>
<span className="text-sm text-gray-500">- {api.apiName}</span>
</label>
))}
{apis.length === 0 && (
<p className="text-sm text-gray-400 py-1"> API가 .</p>
)}
</div>
</div>
)}
</div>
);
})}
{services.length === 0 && (
<div className="px-6 py-8 text-center text-gray-400">
.
</div>
)}
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white px-6 py-2 rounded-lg text-sm font-medium"
>
{isSubmitting ? '신청 중...' : '신청하기'}
</button>
</div>
</form>
</div>
);
};

파일 보기

@ -1,8 +1,271 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import type { ApiKey } from '../../types/apikey';
import { getMyKeys, createKey, revokeKey } from '../../services/apiKeyService';
import { useAuth } from '../../hooks/useAuth';
const STATUS_BADGE: Record<string, string> = {
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',
};
const formatDateTime = (dateStr: string | null): string => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('ko-KR');
};
const MyKeysPage = () => {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [keyName, setKeyName] = useState('');
const [rawKeyModal, setRawKeyModal] = useState<{ keyName: string; rawKey: string } | null>(null);
const [copied, setCopied] = useState(false);
const fetchKeys = async () => {
try {
setLoading(true);
const res = await getMyKeys();
if (res.success && res.data) {
setKeys(res.data);
} else {
setError(res.message || 'API Key 목록을 불러오는데 실패했습니다.');
}
} catch {
setError('API Key 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchKeys();
}, []);
const handleRevoke = 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 handleCreateSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
const res = await createKey({ keyName });
if (res.success && res.data) {
setIsCreateModalOpen(false);
setKeyName('');
setRawKeyModal({ keyName: res.data.keyName, rawKey: res.data.rawKey });
await fetchKeys();
} else {
setError(res.message || 'API Key 생성에 실패했습니다.');
}
} catch {
setError('API Key 생성에 실패했습니다.');
}
};
const handleCopyRawKey = async () => {
if (!rawKeyModal) return;
await navigator.clipboard.writeText(rawKeyModal.rawKey);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (loading) {
return <div className="text-center py-10 text-gray-500"> ...</div>;
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">My API Keys</h1>
<p className="mt-2 text-gray-600">Coming soon</p>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">My API Keys</h1>
<div className="flex gap-2">
{!isAdmin && (
<Link
to="/apikeys/request"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
API Key
</Link>
)}
{isAdmin && (
<button
onClick={() => {
setKeyName('');
setError(null);
setIsCreateModalOpen(true);
}}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
API Key
</button>
)}
</div>
</div>
{error && !isCreateModalOpen && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="overflow-x-auto bg-white rounded-lg shadow">
<table className="w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500">Key Name</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Prefix</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Expires At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Last Used At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Created At</th>
<th className="px-4 py-3 text-left font-medium text-gray-500">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{keys.map((key) => (
<tr key={key.apiKeyId} className="hover:bg-gray-50">
<td className="px-4 py-3">{key.keyName}</td>
<td className="px-4 py-3 font-mono text-gray-600">{key.apiKeyPrefix}</td>
<td className="px-4 py-3">
<span
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
STATUS_BADGE[key.status] || 'bg-gray-100 text-gray-800'
}`}
>
{key.status}
</span>
</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.expiresAt)}</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.lastUsedAt)}</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(key.createdAt)}</td>
<td className="px-4 py-3">
{key.status === 'ACTIVE' && (
<button
onClick={() => handleRevoke(key)}
className="text-red-600 hover:text-red-800 font-medium text-sm"
>
</button>
)}
</td>
</tr>
))}
{keys.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
API Key가 .
</td>
</tr>
)}
</tbody>
</table>
</div>
{isCreateModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">API Key </h2>
</div>
<form onSubmit={handleCreateSubmit}>
<div className="px-6 py-4 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label>
<input
type="text"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
required
className="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end gap-2">
<button
type="button"
onClick={() => setIsCreateModalOpen(false)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</form>
</div>
</div>
)}
{rawKeyModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
<div className="px-6 py-4 border-b">
<h2 className="text-lg font-semibold text-gray-900">API Key </h2>
</div>
<div className="px-6 py-4 space-y-4">
<div className="p-3 bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg text-sm">
. .
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Key Name</label>
<p className="text-gray-900">{rawKeyModal.keyName}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<div className="flex items-center gap-2">
<code className="flex-1 bg-gray-100 px-3 py-2 rounded-lg text-sm font-mono break-all">
{rawKeyModal.rawKey}
</code>
<button
onClick={handleCopyRawKey}
className="shrink-0 bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg text-sm font-medium"
>
{copied ? '복사됨!' : '복사'}
</button>
</div>
</div>
</div>
<div className="px-6 py-4 border-t flex justify-end">
<button
onClick={() => {
setRawKeyModal(null);
setCopied(false);
}}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium"
>
</button>
</div>
</div>
</div>
)}
</div>
);
};

파일 보기

@ -0,0 +1,32 @@
import { get, post, put } from './apiClient';
import type {
ApiKey,
ApiKeyDetail,
ApiKeyCreateResponse,
CreateApiKeyRequest,
ApiKeyRequest,
ApiKeyRequestCreateDto,
ApiKeyRequestReviewDto,
Permission,
UpdatePermissionsRequest,
} from '../types/apikey';
// My Keys
export const getMyKeys = () => get<ApiKey[]>('/keys');
export const getAllKeys = () => get<ApiKey[]>('/keys/all');
export const getKeyDetail = (id: number) => get<ApiKeyDetail>(`/keys/${id}`);
export const createKey = (req: CreateApiKeyRequest) => post<ApiKeyCreateResponse>('/keys', req);
export const revokeKey = (id: number) => put<void>(`/keys/${id}/revoke`);
// Requests
export const createKeyRequest = (req: ApiKeyRequestCreateDto) =>
post<ApiKeyRequest>('/keys/requests', req);
export const getMyRequests = () => get<ApiKeyRequest[]>('/keys/requests/my');
export const getAllRequests = () => get<ApiKeyRequest[]>('/keys/requests');
export const reviewRequest = (id: number, req: ApiKeyRequestReviewDto) =>
put<ApiKeyCreateResponse | null>(`/keys/requests/${id}/review`, req);
// Permissions
export const getPermissions = (keyId: number) => get<Permission[]>(`/keys/${keyId}/permissions`);
export const updatePermissions = (keyId: number, req: UpdatePermissionsRequest) =>
put<Permission[]>(`/keys/${keyId}/permissions`, req);

파일 보기

@ -0,0 +1,82 @@
export interface ApiKey {
apiKeyId: number;
keyName: string;
apiKeyPrefix: string;
maskedKey: string;
status: 'PENDING' | 'ACTIVE' | 'INACTIVE' | 'EXPIRED' | 'REVOKED';
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
}
export interface ApiKeyDetail extends ApiKey {
decryptedKey: string;
userId: number;
userName: string;
}
export interface ApiKeyCreateResponse {
apiKeyId: number;
keyName: string;
rawKey: string;
apiKeyPrefix: string;
status: string;
}
export interface CreateApiKeyRequest {
keyName: string;
}
export interface ApiKeyRequest {
requestId: number;
userId: number;
userName: string;
keyName: string;
purpose: string | null;
requestedApiIds: number[];
status: 'PENDING' | 'APPROVED' | 'REJECTED';
reviewedByUserId: number | null;
reviewerName: string | null;
reviewComment: string | null;
reviewedAt: string | null;
createdAt: string;
serviceIp: string | null;
servicePurpose: string | null;
dailyRequestEstimate: number | null;
usageFromDate: string | null;
usageToDate: string | null;
}
export interface ApiKeyRequestCreateDto {
keyName: string;
purpose?: string;
requestedApiIds: number[];
serviceIp?: string;
servicePurpose?: string;
dailyRequestEstimate?: number;
usageFromDate?: string;
usageToDate?: string;
}
export interface ApiKeyRequestReviewDto {
status: 'APPROVED' | 'REJECTED';
reviewComment?: string;
adjustedApiIds?: number[];
adjustedFromDate?: string;
adjustedToDate?: string;
}
export interface Permission {
permissionId: number;
apiId: number;
apiPath: string;
apiMethod: string;
apiName: string;
serviceName: string;
isActive: boolean;
grantedAt: string;
}
export interface UpdatePermissionsRequest {
apiIds: number[];
}

파일 보기

@ -0,0 +1,114 @@
package com.gcsc.connection.apikey.controller;
import com.gcsc.connection.apikey.dto.ApiKeyCreateResponse;
import com.gcsc.connection.apikey.dto.ApiKeyDetailResponse;
import com.gcsc.connection.apikey.dto.ApiKeyResponse;
import com.gcsc.connection.apikey.dto.CreateApiKeyRequest;
import com.gcsc.connection.apikey.dto.PermissionResponse;
import com.gcsc.connection.apikey.dto.UpdatePermissionsRequest;
import com.gcsc.connection.apikey.service.ApiKeyPermissionService;
import com.gcsc.connection.apikey.service.ApiKeyService;
import com.gcsc.connection.common.dto.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* API Key 관리 API
*/
@RestController
@RequestMapping("/api/keys")
@RequiredArgsConstructor
public class ApiKeyController {
private final ApiKeyService apiKeyService;
private final ApiKeyPermissionService apiKeyPermissionService;
/**
* API Key 목록 조회
*/
@GetMapping
public ResponseEntity<ApiResponse<List<ApiKeyResponse>>> getMyKeys() {
Long userId = getCurrentUserId();
List<ApiKeyResponse> keys = apiKeyService.getMyKeys(userId);
return ResponseEntity.ok(ApiResponse.ok(keys));
}
/**
* 전체 API Key 목록 조회 (관리자용)
*/
@GetMapping("/all")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<List<ApiKeyResponse>>> getAllKeys() {
List<ApiKeyResponse> keys = apiKeyService.getAllKeys();
return ResponseEntity.ok(ApiResponse.ok(keys));
}
/**
* API Key 상세 조회 (복호화된 포함, 관리자 전용)
*/
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ApiKeyDetailResponse>> getKeyDetail(@PathVariable Long id) {
ApiKeyDetailResponse detail = apiKeyService.getKeyDetail(id);
return ResponseEntity.ok(ApiResponse.ok(detail));
}
/**
* API Key 생성 (관리자 전용)
*/
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> createKey(
@RequestBody @Valid CreateApiKeyRequest request) {
Long userId = getCurrentUserId();
ApiKeyCreateResponse response = apiKeyService.createKey(userId, request);
return ResponseEntity.ok(ApiResponse.ok(response));
}
/**
* API Key 폐기
*/
@PutMapping("/{id}/revoke")
public ResponseEntity<ApiResponse<Void>> revokeKey(@PathVariable Long id) {
apiKeyService.revokeKey(id);
return ResponseEntity.ok(ApiResponse.ok(null, "API Key가 폐기되었습니다"));
}
/**
* API Key 권한 목록 조회
*/
@GetMapping("/{id}/permissions")
public ResponseEntity<ApiResponse<List<PermissionResponse>>> getPermissions(@PathVariable Long id) {
List<PermissionResponse> permissions = apiKeyPermissionService.getPermissions(id);
return ResponseEntity.ok(ApiResponse.ok(permissions));
}
/**
* API Key 권한 수정 (관리자/매니저 전용)
*/
@PutMapping("/{id}/permissions")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<List<PermissionResponse>>> updatePermissions(
@PathVariable Long id,
@RequestBody @Valid UpdatePermissionsRequest request) {
Long userId = getCurrentUserId();
List<PermissionResponse> permissions = apiKeyPermissionService.updatePermissions(id, userId, request);
return ResponseEntity.ok(ApiResponse.ok(permissions));
}
private Long getCurrentUserId() {
return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
}
}

파일 보기

@ -0,0 +1,91 @@
package com.gcsc.connection.apikey.controller;
import com.gcsc.connection.apikey.dto.ApiKeyCreateResponse;
import com.gcsc.connection.apikey.dto.ApiKeyRequestCreateDto;
import com.gcsc.connection.apikey.dto.ApiKeyRequestResponse;
import com.gcsc.connection.apikey.dto.ApiKeyRequestReviewDto;
import com.gcsc.connection.apikey.service.ApiKeyRequestService;
import com.gcsc.connection.common.dto.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* API Key 신청/심사 API
*/
@RestController
@RequestMapping("/api/keys/requests")
@RequiredArgsConstructor
public class ApiKeyRequestController {
private final ApiKeyRequestService apiKeyRequestService;
/**
* API Key 신청 생성
*/
@PostMapping
public ResponseEntity<ApiResponse<ApiKeyRequestResponse>> createRequest(
@RequestBody @Valid ApiKeyRequestCreateDto request) {
Long userId = getCurrentUserId();
ApiKeyRequestResponse response = apiKeyRequestService.createRequest(userId, request);
return ResponseEntity.ok(ApiResponse.ok(response));
}
/**
* 신청 목록 조회
*/
@GetMapping("/my")
public ResponseEntity<ApiResponse<List<ApiKeyRequestResponse>>> getMyRequests() {
Long userId = getCurrentUserId();
List<ApiKeyRequestResponse> responses = apiKeyRequestService.getMyRequests(userId);
return ResponseEntity.ok(ApiResponse.ok(responses));
}
/**
* 신청 목록 조회 (관리자/매니저용)
*/
@GetMapping
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<List<ApiKeyRequestResponse>>> getRequests(
@RequestParam(required = false) String status) {
List<ApiKeyRequestResponse> responses;
if ("PENDING".equalsIgnoreCase(status)) {
responses = apiKeyRequestService.getPendingRequests();
} else {
responses = apiKeyRequestService.getAllRequests();
}
return ResponseEntity.ok(ApiResponse.ok(responses));
}
/**
* 신청 심사 (승인/거절)
*/
@PutMapping("/{id}/review")
@PreAuthorize("hasAnyRole('ADMIN','MANAGER')")
public ResponseEntity<ApiResponse<ApiKeyCreateResponse>> reviewRequest(
@PathVariable Long id,
@RequestBody @Valid ApiKeyRequestReviewDto request) {
Long reviewerId = getCurrentUserId();
ApiKeyCreateResponse response = apiKeyRequestService.reviewRequest(id, reviewerId, request);
if (response != null) {
return ResponseEntity.ok(ApiResponse.ok(response, "신청이 승인되었습니다"));
}
return ResponseEntity.ok(ApiResponse.ok(null, "신청이 거절되었습니다"));
}
private Long getCurrentUserId() {
return Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName());
}
}

파일 보기

@ -0,0 +1,10 @@
package com.gcsc.connection.apikey.dto;
public record ApiKeyCreateResponse(
Long apiKeyId,
String keyName,
String rawKey,
String apiKeyPrefix,
String status
) {
}

파일 보기

@ -0,0 +1,34 @@
package com.gcsc.connection.apikey.dto;
import com.gcsc.connection.apikey.entity.SnpApiKey;
import java.time.LocalDateTime;
public record ApiKeyDetailResponse(
Long apiKeyId,
String keyName,
String apiKeyPrefix,
String decryptedKey,
String status,
Long userId,
String userName,
LocalDateTime expiresAt,
LocalDateTime lastUsedAt,
LocalDateTime createdAt
) {
public static ApiKeyDetailResponse from(SnpApiKey key, String decryptedKey) {
return new ApiKeyDetailResponse(
key.getApiKeyId(),
key.getKeyName(),
key.getApiKeyPrefix(),
decryptedKey,
key.getStatus().name(),
key.getUser().getUserId(),
key.getUser().getUserName(),
key.getExpiresAt(),
key.getLastUsedAt(),
key.getCreatedAt()
);
}
}

파일 보기

@ -0,0 +1,17 @@
package com.gcsc.connection.apikey.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
public record ApiKeyRequestCreateDto(
@NotBlank String keyName,
String purpose,
List<Long> requestedApiIds,
String serviceIp,
String servicePurpose,
Long dailyRequestEstimate,
String usageFromDate,
String usageToDate
) {
}

파일 보기

@ -0,0 +1,71 @@
package com.gcsc.connection.apikey.dto;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gcsc.connection.apikey.entity.SnpApiKeyRequest;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
public record ApiKeyRequestResponse(
Long requestId,
Long userId,
String userName,
String keyName,
String purpose,
List<Long> requestedApiIds,
String status,
Long reviewedByUserId,
String reviewerName,
String reviewComment,
LocalDateTime reviewedAt,
LocalDateTime createdAt,
String serviceIp,
String servicePurpose,
Long dailyRequestEstimate,
LocalDateTime usageFromDate,
LocalDateTime usageToDate
) {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public static ApiKeyRequestResponse from(SnpApiKeyRequest request) {
List<Long> apiIds = parseApiIds(request.getRequestedApis());
Long reviewerUserId = request.getReviewedBy() != null
? request.getReviewedBy().getUserId() : null;
String reviewerName = request.getReviewedBy() != null
? request.getReviewedBy().getUserName() : null;
return new ApiKeyRequestResponse(
request.getRequestId(),
request.getUser().getUserId(),
request.getUser().getUserName(),
request.getKeyName(),
request.getPurpose(),
apiIds,
request.getStatus().name(),
reviewerUserId,
reviewerName,
request.getReviewComment(),
request.getReviewedAt(),
request.getCreatedAt(),
request.getServiceIp(),
request.getServicePurpose(),
request.getDailyRequestEstimate(),
request.getUsageFromDate(),
request.getUsageToDate()
);
}
private static List<Long> parseApiIds(String json) {
if (json == null || json.isBlank()) {
return Collections.emptyList();
}
try {
return OBJECT_MAPPER.readValue(json, new TypeReference<>() {});
} catch (Exception e) {
return Collections.emptyList();
}
}
}

파일 보기

@ -0,0 +1,14 @@
package com.gcsc.connection.apikey.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
public record ApiKeyRequestReviewDto(
@NotBlank String status,
String reviewComment,
List<Long> adjustedApiIds,
String adjustedFromDate,
String adjustedToDate
) {
}

파일 보기

@ -0,0 +1,31 @@
package com.gcsc.connection.apikey.dto;
import com.gcsc.connection.apikey.entity.SnpApiKey;
import java.time.LocalDateTime;
public record ApiKeyResponse(
Long apiKeyId,
String keyName,
String apiKeyPrefix,
String maskedKey,
String status,
LocalDateTime expiresAt,
LocalDateTime lastUsedAt,
LocalDateTime createdAt
) {
public static ApiKeyResponse from(SnpApiKey key) {
String masked = key.getApiKeyPrefix() + "****...****";
return new ApiKeyResponse(
key.getApiKeyId(),
key.getKeyName(),
key.getApiKeyPrefix(),
masked,
key.getStatus().name(),
key.getExpiresAt(),
key.getLastUsedAt(),
key.getCreatedAt()
);
}
}

파일 보기

@ -0,0 +1,8 @@
package com.gcsc.connection.apikey.dto;
import jakarta.validation.constraints.NotBlank;
public record CreateApiKeyRequest(
@NotBlank String keyName
) {
}

파일 보기

@ -0,0 +1,30 @@
package com.gcsc.connection.apikey.dto;
import com.gcsc.connection.apikey.entity.SnpApiPermission;
import java.time.LocalDateTime;
public record PermissionResponse(
Long permissionId,
Long apiId,
String apiPath,
String apiMethod,
String apiName,
String serviceName,
Boolean isActive,
LocalDateTime grantedAt
) {
public static PermissionResponse from(SnpApiPermission perm) {
return new PermissionResponse(
perm.getPermissionId(),
perm.getApi().getApiId(),
perm.getApi().getApiPath(),
perm.getApi().getApiMethod(),
perm.getApi().getApiName(),
perm.getApi().getService().getServiceName(),
perm.getIsActive(),
perm.getGrantedAt()
);
}
}

파일 보기

@ -0,0 +1,8 @@
package com.gcsc.connection.apikey.dto;
import java.util.List;
public record UpdatePermissionsRequest(
List<Long> apiIds
) {
}

파일 보기

@ -73,4 +73,18 @@ public class SnpApiKey extends BaseEntity {
this.approvedAt = approvedAt;
this.expiresAt = expiresAt;
}
public void revoke() {
this.status = ApiKeyStatus.REVOKED;
}
public void updateLastUsedAt() {
this.lastUsedAt = LocalDateTime.now();
}
public void activate(SnpUser approvedBy) {
this.status = ApiKeyStatus.ACTIVE;
this.approvedBy = approvedBy;
this.approvedAt = LocalDateTime.now();
}
}

파일 보기

@ -57,13 +57,52 @@ public class SnpApiKeyRequest extends BaseEntity {
@Column(name = "review_comment", columnDefinition = "TEXT")
private String reviewComment;
@Column(name = "service_ip", length = 100)
private String serviceIp;
@Column(name = "service_purpose", length = 50)
private String servicePurpose;
@Column(name = "daily_request_estimate")
private Long dailyRequestEstimate;
@Column(name = "usage_from_date")
private LocalDateTime usageFromDate;
@Column(name = "usage_to_date")
private LocalDateTime usageToDate;
@Builder
public SnpApiKeyRequest(SnpUser user, String keyName, String purpose, String requestedApis,
KeyRequestStatus status) {
KeyRequestStatus status, String serviceIp, String servicePurpose,
Long dailyRequestEstimate, LocalDateTime usageFromDate,
LocalDateTime usageToDate) {
this.user = user;
this.keyName = keyName;
this.purpose = purpose;
this.requestedApis = requestedApis;
this.status = status != null ? status : KeyRequestStatus.PENDING;
this.serviceIp = serviceIp;
this.servicePurpose = servicePurpose;
this.dailyRequestEstimate = dailyRequestEstimate;
this.usageFromDate = usageFromDate;
this.usageToDate = usageToDate;
}
public void approve(SnpUser reviewer, String adjustedApis,
LocalDateTime adjustedFromDate, LocalDateTime adjustedToDate) {
this.status = KeyRequestStatus.APPROVED;
this.reviewedBy = reviewer;
this.reviewedAt = LocalDateTime.now();
if (adjustedApis != null) this.requestedApis = adjustedApis;
if (adjustedFromDate != null) this.usageFromDate = adjustedFromDate;
if (adjustedToDate != null) this.usageToDate = adjustedToDate;
}
public void reject(SnpUser reviewer, String comment) {
this.status = KeyRequestStatus.REJECTED;
this.reviewedBy = reviewer;
this.reviewedAt = LocalDateTime.now();
this.reviewComment = comment;
}
}

파일 보기

@ -58,4 +58,9 @@ public class SnpApiPermission {
this.grantedBy = grantedBy;
this.grantedAt = grantedAt != null ? grantedAt : LocalDateTime.now();
}
public void revoke() {
this.isActive = false;
this.revokedAt = LocalDateTime.now();
}
}

파일 보기

@ -3,9 +3,12 @@ package com.gcsc.connection.apikey.repository;
import com.gcsc.connection.apikey.entity.SnpApiKey;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface SnpApiKeyRepository extends JpaRepository<SnpApiKey, Long> {
Optional<SnpApiKey> findByApiKey(String apiKey);
List<SnpApiKey> findByUserUserId(Long userId);
}

파일 보기

@ -1,7 +1,16 @@
package com.gcsc.connection.apikey.repository;
import com.gcsc.connection.apikey.entity.KeyRequestStatus;
import com.gcsc.connection.apikey.entity.SnpApiKeyRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface SnpApiKeyRequestRepository extends JpaRepository<SnpApiKeyRequest, Long> {
List<SnpApiKeyRequest> findByUserUserId(Long userId);
List<SnpApiKeyRequest> findByStatus(KeyRequestStatus status);
List<SnpApiKeyRequest> findAllByOrderByCreatedAtDesc();
}

파일 보기

@ -2,6 +2,16 @@ package com.gcsc.connection.apikey.repository;
import com.gcsc.connection.apikey.entity.SnpApiPermission;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface SnpApiPermissionRepository extends JpaRepository<SnpApiPermission, Long> {
List<SnpApiPermission> findByApiKeyApiKeyId(Long apiKeyId);
@Modifying
@Query("DELETE FROM SnpApiPermission p WHERE p.apiKey.apiKeyId = :apiKeyId")
void deleteByApiKeyApiKeyId(Long apiKeyId);
}

파일 보기

@ -0,0 +1,76 @@
package com.gcsc.connection.apikey.service;
import com.gcsc.connection.apikey.dto.PermissionResponse;
import com.gcsc.connection.apikey.dto.UpdatePermissionsRequest;
import com.gcsc.connection.apikey.entity.SnpApiKey;
import com.gcsc.connection.apikey.entity.SnpApiPermission;
import com.gcsc.connection.apikey.repository.SnpApiKeyRepository;
import com.gcsc.connection.apikey.repository.SnpApiPermissionRepository;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* API Key 권한 관리 서비스
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class ApiKeyPermissionService {
private final SnpApiPermissionRepository snpApiPermissionRepository;
private final SnpApiKeyRepository snpApiKeyRepository;
private final SnpServiceApiRepository snpServiceApiRepository;
/**
* API Key 권한 목록 조회
*/
@Transactional(readOnly = true)
public List<PermissionResponse> getPermissions(Long apiKeyId) {
return snpApiPermissionRepository.findByApiKeyApiKeyId(apiKeyId).stream()
.map(PermissionResponse::from)
.toList();
}
/**
* API Key 권한 수정 (기존 삭제 재생성)
*/
@Transactional
public List<PermissionResponse> updatePermissions(Long apiKeyId, Long grantedByUserId,
UpdatePermissionsRequest request) {
SnpApiKey apiKey = snpApiKeyRepository.findById(apiKeyId)
.orElseThrow(() -> new BusinessException(ErrorCode.API_KEY_NOT_FOUND));
snpApiPermissionRepository.deleteByApiKeyApiKeyId(apiKeyId);
snpApiPermissionRepository.flush();
if (request.apiIds() == null || request.apiIds().isEmpty()) {
return List.of();
}
List<SnpApiPermission> permissions = request.apiIds().stream()
.map(apiId -> snpServiceApiRepository.findById(apiId)
.map(serviceApi -> SnpApiPermission.builder()
.apiKey(apiKey)
.api(serviceApi)
.isActive(true)
.grantedBy(grantedByUserId)
.grantedAt(LocalDateTime.now())
.build())
.orElse(null))
.filter(perm -> perm != null)
.toList();
List<SnpApiPermission> saved = snpApiPermissionRepository.saveAll(permissions);
return saved.stream()
.map(PermissionResponse::from)
.toList();
}
}

파일 보기

@ -0,0 +1,238 @@
package com.gcsc.connection.apikey.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gcsc.connection.apikey.dto.ApiKeyCreateResponse;
import com.gcsc.connection.apikey.dto.ApiKeyRequestCreateDto;
import com.gcsc.connection.apikey.dto.ApiKeyRequestResponse;
import com.gcsc.connection.apikey.dto.ApiKeyRequestReviewDto;
import com.gcsc.connection.apikey.entity.ApiKeyStatus;
import com.gcsc.connection.apikey.entity.KeyRequestStatus;
import com.gcsc.connection.apikey.entity.SnpApiKey;
import com.gcsc.connection.apikey.entity.SnpApiKeyRequest;
import com.gcsc.connection.apikey.entity.SnpApiPermission;
import com.gcsc.connection.apikey.repository.SnpApiKeyRepository;
import com.gcsc.connection.apikey.repository.SnpApiKeyRequestRepository;
import com.gcsc.connection.apikey.repository.SnpApiPermissionRepository;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.common.util.AesEncryptor;
import com.gcsc.connection.service.repository.SnpServiceApiRepository;
import com.gcsc.connection.user.entity.SnpUser;
import com.gcsc.connection.user.repository.SnpUserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* API Key 신청/심사 워크플로우 서비스
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class ApiKeyRequestService {
private static final int RAW_KEY_HEX_LENGTH = 32;
private static final String KEY_PREFIX = "snp_";
private static final int PREFIX_LENGTH = 8;
private final SnpApiKeyRequestRepository snpApiKeyRequestRepository;
private final SnpApiKeyRepository snpApiKeyRepository;
private final SnpApiPermissionRepository snpApiPermissionRepository;
private final SnpServiceApiRepository snpServiceApiRepository;
private final SnpUserRepository snpUserRepository;
private final AesEncryptor aesEncryptor;
private final ObjectMapper objectMapper;
/**
* API Key 신청 생성
*/
@Transactional
public ApiKeyRequestResponse createRequest(Long userId, ApiKeyRequestCreateDto dto) {
SnpUser user = snpUserRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
String requestedApisJson = toJson(dto.requestedApiIds());
LocalDateTime fromDate = dto.usageFromDate() != null
? LocalDate.parse(dto.usageFromDate()).atStartOfDay() : null;
LocalDateTime toDate = dto.usageToDate() != null
? LocalDate.parse(dto.usageToDate()).atStartOfDay() : null;
SnpApiKeyRequest request = SnpApiKeyRequest.builder()
.user(user)
.keyName(dto.keyName())
.purpose(dto.purpose())
.requestedApis(requestedApisJson)
.serviceIp(dto.serviceIp())
.servicePurpose(dto.servicePurpose())
.dailyRequestEstimate(dto.dailyRequestEstimate())
.usageFromDate(fromDate)
.usageToDate(toDate)
.build();
SnpApiKeyRequest saved = snpApiKeyRequestRepository.save(request);
return ApiKeyRequestResponse.from(saved);
}
/**
* 신청 목록 조회
*/
@Transactional(readOnly = true)
public List<ApiKeyRequestResponse> getMyRequests(Long userId) {
return snpApiKeyRequestRepository.findByUserUserId(userId).stream()
.map(ApiKeyRequestResponse::from)
.toList();
}
/**
* 전체 신청 목록 조회 (최신순)
*/
@Transactional(readOnly = true)
public List<ApiKeyRequestResponse> getAllRequests() {
return snpApiKeyRequestRepository.findAllByOrderByCreatedAtDesc().stream()
.map(ApiKeyRequestResponse::from)
.toList();
}
/**
* 대기 중인 신청 목록 조회
*/
@Transactional(readOnly = true)
public List<ApiKeyRequestResponse> getPendingRequests() {
return snpApiKeyRequestRepository.findByStatus(KeyRequestStatus.PENDING).stream()
.map(ApiKeyRequestResponse::from)
.toList();
}
/**
* 신청 심사 (승인/거절)
*/
@Transactional
public ApiKeyCreateResponse reviewRequest(Long requestId, Long reviewerId,
ApiKeyRequestReviewDto dto) {
SnpApiKeyRequest request = snpApiKeyRequestRepository.findById(requestId)
.orElseThrow(() -> new BusinessException(ErrorCode.API_KEY_REQUEST_NOT_FOUND));
if (request.getStatus() != KeyRequestStatus.PENDING) {
throw new BusinessException(ErrorCode.API_KEY_REQUEST_ALREADY_PROCESSED);
}
SnpUser reviewer = snpUserRepository.findById(reviewerId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
if ("APPROVED".equals(dto.status())) {
return approveRequest(request, reviewer, dto);
} else {
request.reject(reviewer, dto.reviewComment());
return null;
}
}
private ApiKeyCreateResponse approveRequest(SnpApiKeyRequest request, SnpUser reviewer,
ApiKeyRequestReviewDto dto) {
String adjustedApisJson = dto.adjustedApiIds() != null && !dto.adjustedApiIds().isEmpty()
? toJson(dto.adjustedApiIds()) : null;
LocalDateTime adjFrom = dto.adjustedFromDate() != null
? LocalDate.parse(dto.adjustedFromDate()).atStartOfDay() : null;
LocalDateTime adjTo = dto.adjustedToDate() != null
? LocalDate.parse(dto.adjustedToDate()).atStartOfDay() : null;
request.approve(reviewer, adjustedApisJson, adjFrom, adjTo);
// API Key 생성
String rawKey = generateRawKey();
String prefix = rawKey.substring(0, PREFIX_LENGTH);
String encryptedKey = aesEncryptor.encrypt(rawKey);
SnpApiKey apiKey = SnpApiKey.builder()
.user(request.getUser())
.apiKey(encryptedKey)
.apiKeyPrefix(prefix)
.keyName(request.getKeyName())
.status(ApiKeyStatus.ACTIVE)
.approvedBy(reviewer)
.approvedAt(LocalDateTime.now())
.expiresAt(request.getUsageToDate())
.build();
SnpApiKey savedKey = snpApiKeyRepository.save(apiKey);
// 권한 생성
List<Long> apiIds = dto.adjustedApiIds() != null && !dto.adjustedApiIds().isEmpty()
? dto.adjustedApiIds()
: parseApiIds(request.getRequestedApis());
createPermissions(savedKey, apiIds, reviewer.getUserId());
return new ApiKeyCreateResponse(
savedKey.getApiKeyId(),
savedKey.getKeyName(),
rawKey,
savedKey.getApiKeyPrefix(),
savedKey.getStatus().name()
);
}
private void createPermissions(SnpApiKey apiKey, List<Long> apiIds, Long grantedBy) {
if (apiIds == null || apiIds.isEmpty()) {
return;
}
List<SnpApiPermission> permissions = apiIds.stream()
.map(apiId -> snpServiceApiRepository.findById(apiId)
.map(serviceApi -> SnpApiPermission.builder()
.apiKey(apiKey)
.api(serviceApi)
.isActive(true)
.grantedBy(grantedBy)
.grantedAt(LocalDateTime.now())
.build())
.orElse(null))
.filter(perm -> perm != null)
.toList();
snpApiPermissionRepository.saveAll(permissions);
}
private String generateRawKey() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[RAW_KEY_HEX_LENGTH / 2];
random.nextBytes(bytes);
StringBuilder hex = new StringBuilder(KEY_PREFIX);
for (byte b : bytes) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
private String toJson(List<Long> apiIds) {
if (apiIds == null || apiIds.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(apiIds);
} catch (JsonProcessingException e) {
log.error("API ID 목록 JSON 변환 실패", e);
return null;
}
}
@SuppressWarnings("unchecked")
private List<Long> parseApiIds(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return objectMapper.readValue(json, List.class);
} catch (JsonProcessingException e) {
log.error("API ID 목록 JSON 파싱 실패", e);
return List.of();
}
}
}

파일 보기

@ -0,0 +1,129 @@
package com.gcsc.connection.apikey.service;
import com.gcsc.connection.apikey.dto.ApiKeyCreateResponse;
import com.gcsc.connection.apikey.dto.ApiKeyDetailResponse;
import com.gcsc.connection.apikey.dto.ApiKeyResponse;
import com.gcsc.connection.apikey.dto.CreateApiKeyRequest;
import com.gcsc.connection.apikey.entity.ApiKeyStatus;
import com.gcsc.connection.apikey.entity.SnpApiKey;
import com.gcsc.connection.apikey.repository.SnpApiKeyRepository;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import com.gcsc.connection.common.util.AesEncryptor;
import com.gcsc.connection.user.entity.SnpUser;
import com.gcsc.connection.user.repository.SnpUserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.util.List;
/**
* API Key 관리 서비스
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class ApiKeyService {
private static final int RAW_KEY_HEX_LENGTH = 32;
private static final String KEY_PREFIX = "snp_";
private static final int PREFIX_LENGTH = 8;
private final SnpApiKeyRepository snpApiKeyRepository;
private final SnpUserRepository snpUserRepository;
private final AesEncryptor aesEncryptor;
/**
* API Key 목록 조회
*/
@Transactional(readOnly = true)
public List<ApiKeyResponse> getMyKeys(Long userId) {
return snpApiKeyRepository.findByUserUserId(userId).stream()
.map(ApiKeyResponse::from)
.toList();
}
/**
* API Key 상세 조회 (복호화된 포함)
*/
@Transactional(readOnly = true)
public ApiKeyDetailResponse getKeyDetail(Long keyId) {
SnpApiKey apiKey = snpApiKeyRepository.findById(keyId)
.orElseThrow(() -> new BusinessException(ErrorCode.API_KEY_NOT_FOUND));
String decryptedKey = aesEncryptor.decrypt(apiKey.getApiKey());
return ApiKeyDetailResponse.from(apiKey, decryptedKey);
}
/**
* API Key 생성
*/
@Transactional
public ApiKeyCreateResponse createKey(Long userId, CreateApiKeyRequest request) {
SnpUser user = snpUserRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
String rawKey = generateRawKey();
String prefix = rawKey.substring(0, PREFIX_LENGTH);
String encryptedKey = aesEncryptor.encrypt(rawKey);
SnpApiKey apiKey = SnpApiKey.builder()
.user(user)
.apiKey(encryptedKey)
.apiKeyPrefix(prefix)
.keyName(request.keyName())
.status(ApiKeyStatus.ACTIVE)
.build();
SnpApiKey saved = snpApiKeyRepository.save(apiKey);
return new ApiKeyCreateResponse(
saved.getApiKeyId(),
saved.getKeyName(),
rawKey,
saved.getApiKeyPrefix(),
saved.getStatus().name()
);
}
/**
* API Key 폐기
*/
@Transactional
public void revokeKey(Long keyId) {
SnpApiKey apiKey = snpApiKeyRepository.findById(keyId)
.orElseThrow(() -> new BusinessException(ErrorCode.API_KEY_NOT_FOUND));
if (apiKey.getStatus() == ApiKeyStatus.REVOKED) {
throw new BusinessException(ErrorCode.API_KEY_ALREADY_REVOKED);
}
apiKey.revoke();
}
/**
* 전체 API Key 목록 조회 (관리자용)
*/
@Transactional(readOnly = true)
public List<ApiKeyResponse> getAllKeys() {
return snpApiKeyRepository.findAll().stream()
.map(ApiKeyResponse::from)
.toList();
}
/**
* 랜덤 API Key 문자열 생성
*/
private String generateRawKey() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[RAW_KEY_HEX_LENGTH / 2];
random.nextBytes(bytes);
StringBuilder hex = new StringBuilder(KEY_PREFIX);
for (byte b : bytes) {
hex.append(String.format("%02x", b));
}
return hex.toString();
}
}

파일 보기

@ -20,6 +20,11 @@ public enum ErrorCode {
SERVICE_CODE_DUPLICATE(409, "SVC002", "이미 존재하는 서비스 코드입니다"),
SERVICE_API_NOT_FOUND(404, "SVC003", "서비스 API를 찾을 수 없습니다"),
HEALTH_CHECK_FAILED(500, "HC001", "헬스체크 실행에 실패했습니다"),
API_KEY_NOT_FOUND(404, "KEY001", "API Key를 찾을 수 없습니다"),
API_KEY_ALREADY_REVOKED(409, "KEY002", "이미 폐기된 API Key입니다"),
API_KEY_REQUEST_NOT_FOUND(404, "KEY003", "API Key 신청을 찾을 수 없습니다"),
API_KEY_REQUEST_ALREADY_PROCESSED(409, "KEY004", "이미 처리된 신청입니다"),
ENCRYPTION_ERROR(500, "KEY005", "암호화 처리 중 오류가 발생했습니다"),
INTERNAL_ERROR(500, "SYS001", "시스템 오류가 발생했습니다");
private final int status;

파일 보기

@ -0,0 +1,79 @@
package com.gcsc.connection.common.util;
import com.gcsc.connection.common.exception.BusinessException;
import com.gcsc.connection.common.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
@Slf4j
@Component
public class AesEncryptor {
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_TAG_LENGTH = 128;
private static final int IV_LENGTH = 12;
private final SecretKeySpec secretKeySpec;
public AesEncryptor(@Value("${app.apikey.aes-secret-key}") String base64Key) {
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
this.secretKeySpec = new SecretKeySpec(keyBytes, "AES");
}
/**
* 평문을 AES-256-GCM으로 암호화하여 Base64 인코딩된 문자열 반환
*/
public String encrypt(String plainText) {
try {
byte[] iv = new byte[IV_LENGTH];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, gcmSpec);
byte[] cipherText = cipher.doFinal(plainText.getBytes());
byte[] combined = new byte[IV_LENGTH + cipherText.length];
System.arraycopy(iv, 0, combined, 0, IV_LENGTH);
System.arraycopy(cipherText, 0, combined, IV_LENGTH, cipherText.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
log.error("암호화 처리 중 오류 발생", e);
throw new BusinessException(ErrorCode.ENCRYPTION_ERROR);
}
}
/**
* Base64 인코딩된 암호문을 복호화하여 평문 반환
*/
public String decrypt(String encryptedText) {
try {
byte[] combined = Base64.getDecoder().decode(encryptedText);
byte[] iv = new byte[IV_LENGTH];
System.arraycopy(combined, 0, iv, 0, IV_LENGTH);
byte[] cipherText = new byte[combined.length - IV_LENGTH];
System.arraycopy(combined, IV_LENGTH, cipherText, 0, cipherText.length);
Cipher cipher = Cipher.getInstance(ALGORITHM);
GCMParameterSpec gcmSpec = new GCMParameterSpec(GCM_TAG_LENGTH, iv);
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, gcmSpec);
byte[] plainText = cipher.doFinal(cipherText);
return new String(plainText);
} catch (Exception e) {
log.error("복호화 처리 중 오류 발생", e);
throw new BusinessException(ErrorCode.ENCRYPTION_ERROR);
}
}
}

파일 보기

@ -62,3 +62,5 @@ app:
secret: c25wLWNvbm5lY3Rpb24tbW9uaXRvcmluZy1qd3Qtc2VjcmV0LWtleS0yMDI2
access-token-expiration: 3600000
refresh-token-expiration: 604800000
apikey:
aes-secret-key: wg9XEbb+7HpqfI3Hs/iDT2JAlay+71+R9PXad35W04c=