WIP: webhook 작업 임시 커밋 (develop 전환 전 안전 확보)
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
+4
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user