WIP: webhook 작업 임시 커밋 (develop 전환 전 안전 확보)

This commit is contained in:
Rinjae
2026-08-27 10:59:22 +09:00
parent dfb37c18b9
commit e57f2fe72f
11 changed files with 102 additions and 10 deletions
@@ -211,6 +211,7 @@ public class WebhookController {
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1"); ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll()); mav.addObject("eventTypes", eventTypeProvider.getAll());
mav.addObject("userSecretSet", webhook.getUserSecretMasked() != null && !webhook.getUserSecretMasked().isEmpty());
addStepModel(mav, 1); addStepModel(mav, 1);
return mav; return mav;
} }
@@ -310,8 +311,10 @@ public class WebhookController {
result.put("message", "등록된 Webhook이 없습니다."); result.put("message", "등록된 Webhook이 없습니다.");
return result; return result;
} }
Long webhookId = webhook.get().getId();
result.put("success", true); 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; return result;
} }
@@ -16,6 +16,7 @@ public class WebhookDTO implements Serializable {
private Long id; private Long id;
private String targetUrl; private String targetUrl;
private String secretMasked; private String secretMasked;
private String userSecretMasked;
private String createdDate; private String createdDate;
/** 구독 API ID 목록. */ /** 구독 API ID 목록. */
@@ -27,6 +27,13 @@ public class WebhookRegistrationDTO implements Serializable {
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.") @Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
private String targetUrl; private String targetUrl;
/**
* 사용자 지정 Secret(선택). Webhook 발송 시 요청 헤더에 그대로 echo 된다.
* 수정 시 공란이면 기존 값을 유지한다({@code WebhookService#update} 참조).
*/
@Length(max = 500, message = "값은 500자를 초과할 수 없습니다.")
private String userSecret;
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */ /** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
private List<String> eventTypes = new ArrayList<>(); private List<String> eventTypes = new ArrayList<>();
@@ -13,6 +13,7 @@ import org.mapstruct.Mapping;
public interface WebhookMapper { public interface WebhookMapper {
@Mapping(target = "secretMasked", ignore = true) @Mapping(target = "secretMasked", ignore = true)
@Mapping(target = "userSecretMasked", ignore = true)
@Mapping(target = "apiIds", ignore = true) @Mapping(target = "apiIds", ignore = true)
@Mapping(target = "eventTypes", ignore = true) @Mapping(target = "eventTypes", ignore = true)
WebhookDTO toDto(WebhookRequest entity); WebhookDTO toDto(WebhookRequest entity);
@@ -48,6 +48,10 @@ public class WebhookRequest implements Serializable {
@Column(name = "SECRET", length = 500) @Column(name = "SECRET", length = 500)
private String secret; private String secret;
/** 사용자가 지정한 값. admin 발송 시 요청 헤더에 그대로 echo 된다 — SECRET 과 동일 이유로 평문 저장. */
@Column(name = "USER_SECRET", length = 500)
private String userSecret;
@Column(name = "CREATED_BY", length = 200) @Column(name = "CREATED_BY", length = 200)
private String createdBy; private String createdBy;
@@ -70,6 +70,7 @@ public class WebhookService {
request.setOrgId(orgId); request.setOrgId(orgId);
request.setTargetUrl(dto.getTargetUrl().trim()); request.setTargetUrl(dto.getTargetUrl().trim());
request.setSecret(secret); request.setSecret(secret);
request.setUserSecret(normalizeUserSecret(dto.getUserSecret()));
WebhookRequest saved = requestRepository.save(request); WebhookRequest saved = requestRepository.save(request);
persistChildren(saved.getId(), dto); persistChildren(saved.getId(), dto);
@@ -79,12 +80,17 @@ public class WebhookService {
/** /**
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입. * URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
* userSecret 은 공란으로 제출되면 기존 값을 유지한다(마스킹 표시라 재입력 없이는 원본을 알 수 없으므로).
*/ */
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) { public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
WebhookRequest request = loadOwned(id, orgId); WebhookRequest request = loadOwned(id, orgId);
validate(dto); validate(dto);
request.setTargetUrl(dto.getTargetUrl().trim()); request.setTargetUrl(dto.getTargetUrl().trim());
String userSecret = normalizeUserSecret(dto.getUserSecret());
if (userSecret != null) {
request.setUserSecret(userSecret);
}
requestRepository.save(request); requestRepository.save(request);
apiRepository.deleteByWebhookReqId(id); apiRepository.deleteByWebhookReqId(id);
@@ -128,6 +134,14 @@ public class WebhookService {
return loadOwned(id, orgId).getSecret(); 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) { 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<String> dedup(List<String> values) { private List<String> dedup(List<String> values) {
if (values == null) { if (values == null) {
return java.util.Collections.emptyList(); return java.util.Collections.emptyList();
@@ -174,6 +196,8 @@ public class WebhookService {
private WebhookDTO toDetailDto(WebhookRequest request) { private WebhookDTO toDetailDto(WebhookRequest request) {
WebhookDTO dto = webhookMapper.toDto(request); WebhookDTO dto = webhookMapper.toDto(request);
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK); dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
dto.setUserSecretMasked(request.getUserSecret() == null || request.getUserSecret().isEmpty()
? "" : SECRET_MASK);
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream() dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
.map(WebhookRequestApi::getApiId) .map(WebhookRequestApi::getApiId)
@@ -756,7 +756,7 @@ $wh-bg-soft: #f9f9f9;
.secret-action-row { .secret-action-row {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
@@ -767,23 +767,23 @@ $wh-bg-soft: #f9f9f9;
.secret-box { .secret-box {
flex: 1; flex: 1;
max-width: 250px; min-width: 0;
height: 48px; min-height: 48px;
background-color: #efefef; background-color: #efefef;
border-radius: 10px; border-radius: 10px;
padding: 0 20px; padding: 12px 20px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; font-size: 13px;
font-size: 14px; line-height: 1.4;
color: #4e5968; color: #4e5968;
font-weight: 500; font-weight: 500;
box-sizing: border-box; box-sizing: border-box;
letter-spacing: 1px; word-break: break-all;
white-space: normal;
border: 1px solid #DFDFDF; border: 1px solid #DFDFDF;
@media (max-width: 576px) { @media (max-width: 576px) {
max-width: 100%;
width: 100%; width: 100%;
flex: none; flex: none;
} }
@@ -210,10 +210,17 @@
<td><code>Content-Type</code></td> <td><code>Content-Type</code></td>
<td>application/json</td> <td>application/json</td>
</tr> </tr>
<tr>
<td><code>X-Webhook-Secret</code></td>
<td>Webhook 관리 화면에서 직접 설정한 값 — <strong>설정한 경우에만</strong> 전송(선택)</td>
</tr>
</tbody> </tbody>
</table> </table>
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다. <p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
</p> </p>
<p class="oauth2-2legged__warning"><code>X-Webhook-Secret</code>은 서명 검증과 무관합니다.
Webhook 신청/수정 시 입력한 값을 그대로 echo 하는 헤더로, 수신측에서 추가로 값을 대조하고 싶을 때만
사용하세요(값을 설정하지 않았다면 이 헤더는 아예 오지 않습니다).</p>
</div> </div>
<div class="oauth2-2legged__code-panel"> <div class="oauth2-2legged__code-panel">
@@ -222,10 +229,11 @@
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CHECK_START"</span>, <span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CHECK_START"</span>,
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>, <span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>, <span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"DB 점검으로 15분간 서비스가 중단됩니다."</span>,
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ] <span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
} }
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre> <span class="o2leg-g"># eventId: 발송 건 고유 ID · message: 관리자가 입력한 안내 문구(자유텍스트, 생략될 수 있음) · data: 영향 API 목록</span></pre>
</div> </div>
</div> </div>
</section> </section>
@@ -88,6 +88,18 @@
</div> </div>
</div> </div>
<!-- 사용자 지정 Secret -->
<div class="webhook-info-group">
<span class="group-label">사용자 지정 Secret</span>
<div class="secret-action-row" th:if="*{userSecretMasked != null and !#strings.isEmpty(userSecretMasked)}">
<div class="secret-box" id="userSecretMasked" th:text="*{userSecretMasked}">************</div>
<th:block sec:authorize="hasRole('ROLE_WEBHOOK')">
<button type="button" class="btn-reveal-action" id="btnRevealUserSecret">조회</button>
</th:block>
</div>
<div class="input-display-box" th:unless="*{userSecretMasked != null and !#strings.isEmpty(userSecretMasked)}">미설정</div>
</div>
</div> </div>
</div> </div>
@@ -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 재발급 // Secret 재발급
var btnRegen = document.getElementById('btnRegenSecret'); var btnRegen = document.getElementById('btnRegenSecret');
if (btnRegen) btnRegen.addEventListener('click', function () { if (btnRegen) btnRegen.addEventListener('click', function () {
@@ -106,6 +106,16 @@
<p class="field-help-red">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p> <p class="field-help-red">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div> </div>
<!-- 사용자 지정 Secret -->
<div class="s1-field">
<label class="s1-label">사용자 지정 Secret (선택)</label>
<input type="text" id="userSecret" th:field="*{userSecret}" class="s1-input"
placeholder="발송 요청 헤더에 그대로 포함될 값" autocomplete="off" maxlength="500">
<p class="field-error" th:if="${#fields.hasErrors('userSecret')}" th:errors="*{userSecret}">오류</p>
<p class="field-help-red" th:if="${userSecretSet}">현재 값이 설정되어 있습니다. 공란으로 두면 기존 값이 유지되고, 값을 입력하면 교체됩니다.</p>
<p class="field-help" th:unless="${userSecretSet}">입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요.</p>
</div>
<!-- 알림 이벤트 --> <!-- 알림 이벤트 -->
<div class="s1-field"> <div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label> <label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
@@ -80,6 +80,15 @@
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p> <p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div> </div>
<!-- 사용자 지정 Secret -->
<div class="s1-field">
<label class="s1-label">사용자 지정 Secret (선택)</label>
<input type="text" id="userSecret" th:field="*{userSecret}" class="s1-input"
placeholder="발송 요청 헤더에 그대로 포함될 값" autocomplete="off" maxlength="500">
<p class="field-error" th:if="${#fields.hasErrors('userSecret')}" th:errors="*{userSecret}">오류</p>
<p class="field-help">입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요.</p>
</div>
<!-- 알림 이벤트 --> <!-- 알림 이벤트 -->
<div class="s1-field"> <div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label> <label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>