관리자 게이트웨이 연동 및 보안 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- AdminGatewayClient 클래스 추가: GW 인증 차단/리로드 처리
- Client Secret 1회 노출+삭제 로직(AppServiceFacade, MyAppController) 추가
- credentialDetail.html에 비밀정보 분실 대응 UI 및 JS 로직 구현
This commit is contained in:
Rinjae
2026-07-08 18:34:33 +09:00
parent cad574324e
commit 2a2505d5f7
4 changed files with 281 additions and 81 deletions
@@ -59,6 +59,14 @@
<!-- Credential Info Section -->
<div class="apikey-info-section">
<!-- 인증 방식 -->
<div class="info-row">
<label class="info-label">인증 방식</label>
<div class="info-value">
<div class="info-value-box" th:text="${authType}">OAuth2</div>
</div>
</div>
<!-- Client ID -->
<div class="info-row info-row-with-action">
<label class="info-label">Client ID</label>
@@ -72,27 +80,43 @@
<!-- Client Secret -->
<div class="info-row">
<label class="info-label">Client Secret</label>
<label class="info-label">
Client Secret
<button type="button" class="btn-guide-info" onclick="showLostKeyGuide()" title="비밀정보 안내" aria-label="비밀정보 안내"
style="margin-left:6px;width:18px;height:18px;border-radius:50%;border:1px solid #bbb;background:#f5f5f5;color:#666;font-size:12px;line-height:1;cursor:pointer;padding:0;vertical-align:middle;">?</button>
</label>
<div class="info-value">
<!-- View Button (shown initially) -->
<button type="button" class="btn-view-secret" id="hiddenSecretBox" onclick="showPasswordPrompt()">
<span>조회</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</button>
<!-- Actual Secret (hidden initially) -->
<div id="revealedSecretBox" style="display: none;">
<div class="info-row info-row-with-action" style="margin-bottom: 0;">
<div class="info-value" style="display: flex; gap: 16px; align-items: center;">
<div class="info-value-box with-copy" th:text="${apiKey.clientsecret}">secret-key-67890</div>
<button type="button" class="btn-copy-action" th:data-secret="${apiKey.clientsecret}" onclick="copyToClipboardFromButton(this)">
+ 복사
</button>
<!-- secret이 아직 DB에 존재: 최초 1회 조회 가능 -->
<th:block th:if="${secretAvailable}">
<!-- View Button (shown initially) -->
<button type="button" class="btn-view-secret" id="hiddenSecretBox" onclick="showPasswordPrompt()">
<span>조회</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.35-4.35"></path>
</svg>
</button>
<!-- Actual Secret (hidden initially, 값은 서버 응답으로 JS가 주입) -->
<div id="revealedSecretBox" style="display: none;">
<div class="info-row info-row-with-action" style="margin-bottom: 0;">
<div class="info-value" style="display: flex; gap: 16px; align-items: center;">
<div class="info-value-box with-copy" id="revealedSecretValue"></div>
<button type="button" class="btn-copy-action" id="revealedSecretCopyBtn" onclick="copyToClipboardFromButton(this)">
+ 복사
</button>
</div>
</div>
</div>
</th:block>
<!-- secret이 이미 노출·삭제됨 -->
<div th:unless="${secretAvailable}" class="secret-revealed-notice" style="display:flex;align-items:center;gap:12px;">
<span class="secret-revealed-text" style="color:#888;font-size:14px;">이미 1회 노출되어 삭제된 인증정보입니다.</span>
<button type="button" class="btn-guide-link" onclick="showLostKeyGuide()"
style="background:none;border:none;color:#2b6cb0;text-decoration:underline;cursor:pointer;font-size:13px;padding:0;">분실 시 안내</button>
</div>
</div>
</div>
@@ -178,36 +202,50 @@
<script th:inline="javascript">
/*<![CDATA[*/
// Show password prompt for viewing client secret
var CREDENTIAL_CLIENT_ID = /*[[${apiKey.clientid}]]*/ '';
// 비밀정보 안내(분실 시 조치) 가이드 팝업
function showLostKeyGuide() {
customPopups.showAlert(
'Client Secret은 보안을 위해 <strong>최초 1회만 조회</strong>할 수 있으며,<br>' +
'조회하는 즉시 개발자포탈에서 <strong>영구 삭제</strong>됩니다.<br><br>' +
'값을 분실하셨다면 복구가 불가능하므로,<br>' +
'<strong>인증키를 새로 신청</strong>하여 발급받아 주세요.'
);
}
// Show password prompt for viewing client secret (최초 1회 노출 + 서버측 물리 삭제)
function showPasswordPrompt() {
customPopups.showPasswordInput({
title: 'Client Secret 조회',
message: '보안을 위해 비밀번호를 입력해주세요.',
message: '보안을 위해 비밀번호를 입력해주세요.<br>조회 즉시 값은 영구 삭제됩니다.',
onConfirm: function(password) {
// Verify password via AJAX
$.ajax({
url: /*[[@{/myapikey/verify-password}]]*/ '/myapikey/verify-password',
url: /*[[@{/myapikey/credential/reveal-secret}]]*/ '/myapikey/credential/reveal-secret',
type: 'POST',
data: {password: password},
contentType: 'application/json',
data: JSON.stringify({ clientId: CREDENTIAL_CLIENT_ID, password: password }),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
},
success: function(response) {
if (response.success) {
// Hide password popup
customPopups.hidePasswordInput();
// Reveal the client secret
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else {
// Show error message
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
},
error: function() {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
}
}).done(function(response) {
if (response.success) {
customPopups.hidePasswordInput();
// 서버가 반환한 secret을 화면에 1회 주입
$('#revealedSecretValue').text(response.secret);
$('#revealedSecretCopyBtn').attr('data-secret', response.secret).data('secret', response.secret);
$('#hiddenSecretBox').hide();
$('#revealedSecretBox').fadeIn(300);
} else if (response.alreadyRevealed) {
customPopups.hidePasswordInput();
showLostKeyGuide();
} else {
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
}
}).fail(function() {
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
});
},
onCancel: function() {
@@ -252,31 +290,38 @@
function deleteApiKeyFromButton(button) {
var clientId = $(button).data('client-id');
if (!confirm('정말로 이 인증키를 삭제하시겠습니까?')) {
return;
}
$('.loading-overlay').show();
const requestData = {
clientId: clientId
};
$.ajax({
url: '/myapikey/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function(confirmed) {
if (!confirmed) {
return;
}
}).done(function(response) {
alert(response.msg || 'API Key가 삭제되었습니다.');
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
}).fail(function(jqXHR, textStatus, errorThrown) {
alert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function() {
$('.loading-overlay').hide();
$('.loading-overlay').show();
const requestData = {
clientId: clientId
};
$.ajax({
url: '/myapikey/api_key_delete',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData),
headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
}
}).done(function(response) {
if (response && response.success === false) {
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
return;
}
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function() {
window.location.href = /*[[@{/myapikey}]]*/ '/myapikey';
});
}).fail(function(jqXHR, textStatus, errorThrown) {
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function() {
$('.loading-overlay').hide();
});
});
}