관리자 게이트웨이 연동 및 보안 기능 추가:
- AdminGatewayClient 클래스 추가: GW 인증 차단/리로드 처리 - Client Secret 1회 노출+삭제 로직(AppServiceFacade, MyAppController) 추가 - credentialDetail.html에 비밀정보 분실 대응 UI 및 JS 로직 구현
This commit is contained in:
@@ -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,10 +238,7 @@ 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가 필요합니다.");
|
||||
@@ -239,17 +246,37 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
String orgId = user.getPortalOrg().getId();
|
||||
|
||||
// API Key 즉시 삭제 (승인 프로세스 없이)
|
||||
appServiceFacade.deleteApp(user.getPortalOrg().getId(), clientId);
|
||||
// 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가 삭제되었습니다.");
|
||||
} catch (Exception e) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
||||
}
|
||||
|
||||
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,8 +80,15 @@
|
||||
|
||||
<!-- 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">
|
||||
|
||||
<!-- 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>
|
||||
@@ -82,17 +97,26 @@
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Actual Secret (hidden initially) -->
|
||||
<!-- 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" th:text="${apiKey.clientsecret}">secret-key-67890</div>
|
||||
<button type="button" class="btn-copy-action" th:data-secret="${apiKey.clientsecret}" onclick="copyToClipboardFromButton(this)">
|
||||
<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) {
|
||||
}
|
||||
}).done(function(response) {
|
||||
if (response.success) {
|
||||
// Hide password popup
|
||||
customPopups.hidePasswordInput();
|
||||
|
||||
// Reveal the client secret
|
||||
// 서버가 반환한 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 {
|
||||
// Show error message
|
||||
customPopups.showPasswordError(response.message || '비밀번호가 일치하지 않습니다.');
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
}).fail(function() {
|
||||
customPopups.showPasswordError('오류가 발생했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
});
|
||||
},
|
||||
onCancel: function() {
|
||||
@@ -252,7 +290,8 @@
|
||||
function deleteApiKeyFromButton(button) {
|
||||
var clientId = $(button).data('client-id');
|
||||
|
||||
if (!confirm('정말로 이 인증키를 삭제하시겠습니까?')) {
|
||||
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function(confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -271,13 +310,19 @@
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function(response) {
|
||||
alert(response.msg || 'API Key가 삭제되었습니다.');
|
||||
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) {
|
||||
alert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function() {
|
||||
$('.loading-overlay').hide();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/*]]>*/
|
||||
|
||||
Reference in New Issue
Block a user