관리자 게이트웨이 연동 및 보안 기능 추가:
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
@@ -10,6 +10,7 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper;
@@ -21,6 +22,7 @@ import java.util.Map;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -44,6 +46,7 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Slf4j
@Controller
@RequestMapping("/myapikey")
@RequiredArgsConstructor
@@ -77,6 +80,7 @@ public class MyAppController {
private final ApiService apiService;
private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient;
private static final long MAX_APP_ICON_BYTES = 2L * 1024 * 1024; // 2MB
@@ -168,7 +172,13 @@ public class MyAppController {
}
});
// Client Secret은 초기 HTML에 담지 않는다. (비번 확인 후 서버가 1회만 반환)
boolean secretAvailable = StringUtils.isNotBlank(apiKey.getClientsecret());
apiKey.setClientsecret(null);
model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("authType", "OAuth2");
return new ModelAndView(CREDENTIAL_DETAIL);
}
@@ -228,28 +238,45 @@ public class MyAppController {
public Map<String, Object> deleteApiKey(@RequestBody Map<String, String> requestData) {
Map<String, Object> result = new java.util.HashMap<>();
try {
String clientId = requestData.get("clientId");
String type = requestData.get("type");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
result.put("msg", "클라이언트 ID가 필요합니다.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
// API Key 즉시 삭제 (승인 프로세스 없이)
appServiceFacade.deleteApp(user.getPortalOrg().getId(), clientId);
result.put("success", true);
result.put("msg", "API Key가 삭제되었습니다.");
} catch (Exception e) {
String clientId = requestData.get("clientId");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
result.put("msg", "클라이언트 ID가 필요합니다.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String orgId = user.getPortalOrg().getId();
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
result.put("success", false);
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
return result;
}
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
try {
adminGatewayClient.blockClient(clientId);
} catch (Exception e) {
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
result.put("success", false);
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
return result;
}
// 3. GW 차단 성공 시에만 포털 credential 삭제
try {
appServiceFacade.deleteApp(orgId, clientId);
} catch (Exception e) {
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
result.put("success", false);
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
return result;
}
result.put("success", true);
result.put("msg", "API Key가 삭제되었습니다.");
return result;
}
@@ -282,6 +309,57 @@ public class MyAppController {
return result;
}
/**
* Client Secret을 비밀번호 확인 후 1회 노출하고 즉시 DB에서 물리 삭제합니다.
* 보안 정책상 비밀정보는 최초 1회만 제공됩니다.
*
* @param requestData clientId, password 포함
* @return {success, secret} / {success:false, alreadyRevealed:true} / {success:false, message}
*/
@PostMapping("/credential/reveal-secret")
@Secured("ROLE_APP")
@ResponseBody
public Map<String, Object> revealClientSecret(@RequestBody Map<String, String> requestData) {
Map<String, Object> result = new java.util.HashMap<>();
String clientId = requestData.get("clientId");
String password = requestData.get("password");
if (clientId == null || clientId.trim().isEmpty()) {
result.put("success", false);
result.put("message", "클라이언트 ID가 필요합니다.");
return result;
}
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
// 1. 본인 확인 (비밀번호)
if (!appServiceFacade.verifyUserPassword(user, password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
}
// 2. 소유권 확인 + 1회 노출 + 물리 삭제
try {
String secret = appServiceFacade.revealAndDeleteClientSecret(user.getPortalOrg().getId(), clientId);
if (secret == null) {
result.put("success", false);
result.put("alreadyRevealed", true);
result.put("message", "이미 1회 노출되어 삭제된 인증정보입니다.");
return result;
}
result.put("success", true);
result.put("secret", secret);
} catch (Exception e) {
log.error("Client Secret 노출 처리 실패 - clientId={}", clientId, e);
result.put("success", false);
result.put("message", "인증정보 조회 중 오류가 발생했습니다.");
}
return result;
}
/**
* API Key 요청 이력을 페이지 형태로 조회합니다.
* 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다.
@@ -0,0 +1,54 @@
package com.eactive.apim.portal.apps.app.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
/**
* 관리자(admin) 포털의 내부 API를 호출하는 클라이언트.
*
* <p>GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다.
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AdminGatewayClient {
private static final String PROP_GROUP = "Portal";
private static final String PROP_ADMIN_BASE_URL = "admin.base-url";
private static final String DEFAULT_ADMIN_BASE_URL = "http://localhost:39120";
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
private final RestTemplate restTemplate;
private final PortalPropertyService portalPropertyService;
/**
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
*
* @param clientId 차단할 클라이언트 ID
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
*/
public void blockClient(String clientId) {
String baseUrl = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
Map<?, ?> body = response.getBody();
boolean success = body != null && Boolean.TRUE.equals(body.get("success"));
if (!success) {
String msg = body != null ? String.valueOf(body.get("msg")) : "응답 본문 없음";
throw new IllegalStateException("admin clientBlock 처리 실패 - clientId=" + clientId + ", msg=" + msg);
}
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
}
}
@@ -293,6 +293,29 @@ public class AppServiceFacade {
}
}
/**
* Client Secret을 1회 조회하고 그 즉시 DB(PTL_CREDENTIAL)에서 물리 삭제합니다.
* 보안 정책상 비밀정보는 최초 1회만 제공되며, 조회 후에는 복구할 수 없습니다.
*
* @param orgId 조직 ID (소유권 확인용)
* @param clientId 대상 클라이언트 ID
* @return 삭제 전 Client Secret 값. 이미 노출되어 비어있으면 {@code null}
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
*/
public String revealAndDeleteClientSecret(String orgId, String clientId) {
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
String secret = credential.getClientsecret();
if (StringUtils.isBlank(secret)) {
return null; // 이미 1회 노출되어 삭제됨
}
credential.setClientsecret(null); // 물리 삭제 (1회 제공)
credentialRepository.save(credential);
return secret;
}
/**
* API Key(Credential)를 즉시 삭제합니다.
* 승인 프로세스 없이 바로 삭제 처리됩니다.
@@ -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();
});
});
}