generated from gc/template-java-maven
Merge pull request 'feat(phase3): API Key 관리 - 생성/발급/신청/승인/권한' (#14) from feature/ISSUE-8-phase3-apikey into develop
This commit is contained in:
커밋
d0a3bc1a27
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
32
frontend/src/services/apiKeyService.ts
Normal file
32
frontend/src/services/apiKeyService.ts
Normal file
@ -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);
|
||||
82
frontend/src/types/apikey.ts
Normal file
82
frontend/src/types/apikey.ts
Normal file
@ -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=
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user