diff --git a/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java b/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java index 84b0320..55e87b4 100644 --- a/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java +++ b/src/main/java/com/eactive/apim/portal/djb/webhook/controller/WebhookController.java @@ -211,6 +211,7 @@ public class WebhookController { ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1"); mav.addObject("eventTypes", eventTypeProvider.getAll()); + mav.addObject("userSecretSet", webhook.getUserSecretMasked() != null && !webhook.getUserSecretMasked().isEmpty()); addStepModel(mav, 1); return mav; } @@ -310,8 +311,10 @@ public class WebhookController { result.put("message", "등록된 Webhook이 없습니다."); return result; } + Long webhookId = webhook.get().getId(); result.put("success", true); - result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId())); + result.put("secret", webhookService.getPlainSecret(webhookId, currentOrgId())); + result.put("userSecret", webhookService.getPlainUserSecret(webhookId, currentOrgId())); return result; } diff --git a/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookDTO.java b/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookDTO.java index 99540ad..19bef0b 100644 --- a/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookDTO.java +++ b/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookDTO.java @@ -16,6 +16,7 @@ public class WebhookDTO implements Serializable { private Long id; private String targetUrl; private String secretMasked; + private String userSecretMasked; private String createdDate; /** 구독 API ID 목록. */ diff --git a/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java b/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java index 68b8e2e..a29692d 100644 --- a/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java +++ b/src/main/java/com/eactive/apim/portal/djb/webhook/dto/WebhookRegistrationDTO.java @@ -27,6 +27,13 @@ public class WebhookRegistrationDTO implements Serializable { @Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.") private String targetUrl; + /** + * 사용자 지정 Secret(선택). Webhook 발송 시 요청 헤더에 그대로 echo 된다. + * 수정 시 공란이면 기존 값을 유지한다({@code WebhookService#update} 참조). + */ + @Length(max = 500, message = "값은 500자를 초과할 수 없습니다.") + private String userSecret; + /** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */ private List eventTypes = new ArrayList<>(); diff --git a/src/main/java/com/eactive/apim/portal/djb/webhook/mapper/WebhookMapper.java b/src/main/java/com/eactive/apim/portal/djb/webhook/mapper/WebhookMapper.java index 61ea380..2033a10 100644 --- a/src/main/java/com/eactive/apim/portal/djb/webhook/mapper/WebhookMapper.java +++ b/src/main/java/com/eactive/apim/portal/djb/webhook/mapper/WebhookMapper.java @@ -13,6 +13,7 @@ import org.mapstruct.Mapping; public interface WebhookMapper { @Mapping(target = "secretMasked", ignore = true) + @Mapping(target = "userSecretMasked", ignore = true) @Mapping(target = "apiIds", ignore = true) @Mapping(target = "eventTypes", ignore = true) WebhookDTO toDto(WebhookRequest entity); diff --git a/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequest.java b/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequest.java index 662721e..4cc4c62 100644 --- a/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequest.java +++ b/src/main/java/com/eactive/apim/portal/djb/webhook/repository/entity/WebhookRequest.java @@ -48,6 +48,10 @@ public class WebhookRequest implements Serializable { @Column(name = "SECRET", length = 500) private String secret; + /** 사용자가 지정한 값. admin 발송 시 요청 헤더에 그대로 echo 된다 — SECRET 과 동일 이유로 평문 저장. */ + @Column(name = "USER_SECRET", length = 500) + private String userSecret; + @Column(name = "CREATED_BY", length = 200) private String createdBy; diff --git a/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookService.java b/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookService.java index d41b7e9..f490b25 100644 --- a/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookService.java +++ b/src/main/java/com/eactive/apim/portal/djb/webhook/service/WebhookService.java @@ -70,6 +70,7 @@ public class WebhookService { request.setOrgId(orgId); request.setTargetUrl(dto.getTargetUrl().trim()); request.setSecret(secret); + request.setUserSecret(normalizeUserSecret(dto.getUserSecret())); WebhookRequest saved = requestRepository.save(request); persistChildren(saved.getId(), dto); @@ -79,12 +80,17 @@ public class WebhookService { /** * URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입. + * userSecret 은 공란으로 제출되면 기존 값을 유지한다(마스킹 표시라 재입력 없이는 원본을 알 수 없으므로). */ public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) { WebhookRequest request = loadOwned(id, orgId); validate(dto); request.setTargetUrl(dto.getTargetUrl().trim()); + String userSecret = normalizeUserSecret(dto.getUserSecret()); + if (userSecret != null) { + request.setUserSecret(userSecret); + } requestRepository.save(request); apiRepository.deleteByWebhookReqId(id); @@ -128,6 +134,14 @@ public class WebhookService { return loadOwned(id, orgId).getSecret(); } + /** + * 평문 사용자 지정 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다. + */ + @Transactional(readOnly = true) + public String getPlainUserSecret(Long id, String orgId) { + return loadOwned(id, orgId).getUserSecret(); + } + // ---------------------------------------------------------------- private WebhookRequest loadOwned(Long id, String orgId) { @@ -164,6 +178,14 @@ public class WebhookService { } } + private String normalizeUserSecret(String raw) { + if (raw == null) { + return null; + } + String trimmed = raw.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private List dedup(List values) { if (values == null) { return java.util.Collections.emptyList(); @@ -174,6 +196,8 @@ public class WebhookService { private WebhookDTO toDetailDto(WebhookRequest request) { WebhookDTO dto = webhookMapper.toDto(request); dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK); + dto.setUserSecretMasked(request.getUserSecret() == null || request.getUserSecret().isEmpty() + ? "" : SECRET_MASK); dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream() .map(WebhookRequestApi::getApiId) diff --git a/src/main/resources/static/sass/pages/_webhook.scss b/src/main/resources/static/sass/pages/_webhook.scss index 0ff42c0..fc093df 100644 --- a/src/main/resources/static/sass/pages/_webhook.scss +++ b/src/main/resources/static/sass/pages/_webhook.scss @@ -756,7 +756,7 @@ $wh-bg-soft: #f9f9f9; .secret-action-row { display: flex; - align-items: center; + align-items: flex-start; gap: 10px; width: 100%; @@ -767,23 +767,23 @@ $wh-bg-soft: #f9f9f9; .secret-box { flex: 1; - max-width: 250px; - height: 48px; + min-width: 0; + min-height: 48px; background-color: #efefef; border-radius: 10px; - padding: 0 20px; + padding: 12px 20px; display: flex; align-items: center; - justify-content: center; - font-size: 14px; + font-size: 13px; + line-height: 1.4; color: #4e5968; font-weight: 500; box-sizing: border-box; - letter-spacing: 1px; + word-break: break-all; + white-space: normal; border: 1px solid #DFDFDF; @media (max-width: 576px) { - max-width: 100%; width: 100%; flex: none; } diff --git a/src/main/resources/templates/views/apps/service/webhook-dev-guide.html b/src/main/resources/templates/views/apps/service/webhook-dev-guide.html index 54383df..3584166 100644 --- a/src/main/resources/templates/views/apps/service/webhook-dev-guide.html +++ b/src/main/resources/templates/views/apps/service/webhook-dev-guide.html @@ -210,10 +210,17 @@ Content-Type application/json + + X-Webhook-Secret + Webhook 관리 화면에서 직접 설정한 값 — 설정한 경우에만 전송(선택) +

⚠ 서명 대상은 파싱 전 본문 원문(raw body) 입니다.

+

X-Webhook-Secret은 서명 검증과 무관합니다. + Webhook 신청/수정 시 입력한 값을 그대로 echo 하는 헤더로, 수신측에서 추가로 값을 대조하고 싶을 때만 + 사용하세요(값을 설정하지 않았다면 이 헤더는 아예 오지 않습니다).

@@ -222,10 +229,11 @@ "eventType": "CHECK_START", "eventId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "timestamp": 1723600000000, + "message": "DB 점검으로 15분간 서비스가 중단됩니다.", "data": [ "TESTCASE003S1", "TESTCASE005S1" ] } -# eventId: 발송 건 고유 ID · data: 영향 API 목록 +# eventId: 발송 건 고유 ID · message: 관리자가 입력한 안내 문구(자유텍스트, 생략될 수 있음) · data: 영향 API 목록
diff --git a/src/main/resources/templates/views/apps/webhook/webhookList.html b/src/main/resources/templates/views/apps/webhook/webhookList.html index 6724b3d..54a1d73 100644 --- a/src/main/resources/templates/views/apps/webhook/webhookList.html +++ b/src/main/resources/templates/views/apps/webhook/webhookList.html @@ -88,6 +88,18 @@ + +
+ 사용자 지정 Secret +
+
************
+ + + +
+
미설정
+
+ @@ -176,6 +188,19 @@ }); }); + // 사용자 지정 Secret 조회 + var btnRevealUserSecret = document.getElementById('btnRevealUserSecret'); + if (btnRevealUserSecret) btnRevealUserSecret.addEventListener('click', function () { + openModal('사용자 지정 Secret 조회', '비밀번호 확인 후 값을 표시합니다.', function (pw) { + post('/webhook/verify-secret', pw).then(function (res) { + if (res.success) { + document.getElementById('userSecretMasked').textContent = res.userSecret || ''; + closeModal(); + } else { showError(res.message || '실패했습니다.'); } + }).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); }); + }); + }); + // Secret 재발급 var btnRegen = document.getElementById('btnRegenSecret'); if (btnRegen) btnRegen.addEventListener('click', function () { diff --git a/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html b/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html index 1b100ec..17812ae 100644 --- a/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html +++ b/src/main/resources/templates/views/apps/webhook/webhookModifyStep1.html @@ -106,6 +106,16 @@

이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.

+ +
+ + +

오류

+

현재 값이 설정되어 있습니다. 공란으로 두면 기존 값이 유지되고, 값을 입력하면 교체됩니다.

+

입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요.

+
+
diff --git a/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html b/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html index f99c33a..e61ff9a 100644 --- a/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html +++ b/src/main/resources/templates/views/apps/webhook/webhookRegisterStep1.html @@ -80,6 +80,15 @@

이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.

+ +
+ + +

오류

+

입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요.

+
+