From 2a2505d5f7ef6d4b89e02463f03cd4d32e191216 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Wed, 8 Jul 2026 18:34:33 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EC=9B=A8=EC=9D=B4=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B3=B4=EC=95=88=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80:=20-=20AdminGatewayClient=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80:=20GW=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8/=EB=A6=AC=EB=A1=9C=EB=93=9C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20-=20Client=20Secret=201=ED=9A=8C=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C+=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81(AppServiceFac?= =?UTF-8?q?ade,=20MyAppController)=20=EC=B6=94=EA=B0=80=20-=20credentialDe?= =?UTF-8?q?tail.html=EC=97=90=20=EB=B9=84=EB=B0=80=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EB=B6=84=EC=8B=A4=20=EB=8C=80=EC=9D=91=20UI=20=EB=B0=8F=20JS?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/app/controller/MyAppController.java | 116 ++++++++++-- .../apps/app/service/AdminGatewayClient.java | 54 ++++++ .../apps/app/service/AppServiceFacade.java | 23 +++ .../views/apps/mypage/credentialDetail.html | 169 +++++++++++------- 4 files changed, 281 insertions(+), 81 deletions(-) create mode 100644 src/main/java/com/eactive/apim/portal/apps/app/service/AdminGatewayClient.java diff --git a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java index c01fec2..4809b4a 100644 --- a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java +++ b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java @@ -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 deleteApiKey(@RequestBody Map requestData) { Map 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 revealClientSecret(@RequestBody Map requestData) { + Map 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 요청 이력을 페이지 형태로 조회합니다. * 각 요청의 내용을 사용자 친화적인 형식으로 포맷팅하여 표시합니다. diff --git a/src/main/java/com/eactive/apim/portal/apps/app/service/AdminGatewayClient.java b/src/main/java/com/eactive/apim/portal/apps/app/service/AdminGatewayClient.java new file mode 100644 index 0000000..550233a --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/app/service/AdminGatewayClient.java @@ -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를 호출하는 클라이언트. + * + *

GW 인증서버(TSEAIAU01) 제어는 포털이 직접 하지 않고, broadcast 인프라를 갖춘 admin 에 위임한다. + * admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code djb.admin.base-url}) 에서 조회한다.

+ */ +@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 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); + } +} diff --git a/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java b/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java index b765fec..b73bca1 100644 --- a/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java +++ b/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java @@ -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)를 즉시 삭제합니다. * 승인 프로세스 없이 바로 삭제 처리됩니다. diff --git a/src/main/resources/templates/views/apps/mypage/credentialDetail.html b/src/main/resources/templates/views/apps/mypage/credentialDetail.html index ae24f6a..8334288 100644 --- a/src/main/resources/templates/views/apps/mypage/credentialDetail.html +++ b/src/main/resources/templates/views/apps/mypage/credentialDetail.html @@ -59,6 +59,14 @@
+ +
+ +
+
OAuth2
+
+
+
@@ -72,27 +80,43 @@
- +
- - - -