3 Commits

Author SHA1 Message Date
Rinjae b7477e10a1 feats/security 브랜치 병합 - 비밀번호 전송 암호화(RSA-OAEP + AES-GCM) 도입
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-09-01 15:08:21 +09:00
Rinjae 11f1dae1f3 Thymeleaf 프래그먼트 경로 표기 오류 수정:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 모든 th:replace/th:include 경로 ~{}로 변경
2026-09-01 15:07:23 +09:00
Rinjae d275aa12c4 Admin GatewayClient 다중 base URL 및 failover 로직 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- admin.base-url 다중 주소 지원 및 순차 failover 구현
- RestTemplate 타임아웃 설정 변경 (연결/읽기: 5초로 단축)
2026-09-01 14:50:39 +09:00
12 changed files with 114 additions and 49 deletions
@@ -1,18 +1,28 @@
package com.eactive.apim.portal.apps.app.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
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>
* admin base URL 은 {@code PTL_PROPERTY} (group={@code Portal}, name={@code admin.base-url}) 에서 조회한다.</p>
*
* <p><b>다중 base URL / failover</b> — 프로퍼티 값에 콤마({@code ,}) 또는 개행으로 여러 admin 주소를 넣을 수 있다.
* 호출은 <b>기재된 순서대로</b> 시도하며, 통신 오류(연결 실패/타임아웃/HTTP 오류)면 다음 주소로 넘어간다.
* 모두 실패하면 마지막 오류를 던진다. 반면 admin 이 정상 응답하면서 {@code success=false} 를 준 것은
* 업무 처리 실패이므로 <b>failover 하지 않고</b> 즉시 실패시킨다(다른 노드도 같은 결과이며 중복 처리 위험).</p>
*/
@Slf4j
@Service
@@ -22,6 +32,8 @@ 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 PROP_ADMIN_BASE_URL_DESC =
"admin(관리자포털) 내부 API base URL. 콤마(,) 또는 줄바꿈으로 여러 개 지정 시 앞에서부터 failover";
private static final String CLIENT_BLOCK_PATH = "/onl/admin/authserver/clientBlock.json?clientId={clientId}";
private final RestTemplate restTemplate;
@@ -30,26 +42,79 @@ public class AdminGatewayClient {
/**
* clientId 의 GW 인증 클라이언트 차단(appstatus=0) + GW 캐시 리로드를 admin 에 요청한다.
*
* <p>base URL 이 여러 개면 앞에서부터 순서대로 시도한다(failover).</p>
*
* @param clientId 차단할 클라이언트 ID
* @throws RuntimeException admin 미응답/네트워크 오류 또는 admin 처리 실패 시 (호출측에서 처리)
* @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");
List<String> baseUrls = resolveBaseUrls();
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
RestClientException lastError = null;
for (int i = 0; i < baseUrls.size(); i++) {
String baseUrl = baseUrls.get(i);
String url = baseUrl + CLIENT_BLOCK_PATH;
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
ResponseEntity<Map> response;
try {
response = restTemplate.postForEntity(url, null, Map.class, clientId);
} catch (RestClientException e) {
// 통신 계층 실패 — 다음 admin 주소로 failover
lastError = e;
log.warn("admin clientBlock 호출 실패({}/{}) - baseUrl={}, clientId={}, cause={}",
i + 1, baseUrls.size(), baseUrl, clientId, e.toString());
continue;
}
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);
// 여기까지 왔으면 admin 이 응답한 것 — 업무 실패는 failover 대상이 아니다.
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 처리 실패 - baseUrl=" + baseUrl + ", clientId=" + clientId + ", msg=" + msg);
}
log.info("admin GW 차단/리로드 위임 성공 - baseUrl={}, clientId={}", baseUrl, clientId);
return;
}
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
throw new IllegalStateException(
"admin clientBlock 호출 실패 - 모든 admin 주소 응답 없음 (" + baseUrls + "), clientId=" + clientId, lastError);
}
/**
* 프로퍼티의 admin base URL 목록을 순서대로 반환한다.
*
* <p>구분자는 콤마({@code ,})와 개행({@code \r\n})이며, 공백 항목과 중복은 제거하고 순서는 보존한다.
* 값이 비어 있거나 유효 항목이 없으면 기본값 하나만 반환한다.</p>
*/
private List<String> resolveBaseUrls() {
String raw = portalPropertyService.getOrCreateProperty(
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, PROP_ADMIN_BASE_URL_DESC);
List<String> urls = parseBaseUrls(raw);
if (urls.isEmpty()) {
log.warn("admin.base-url 프로퍼티가 비어 있어 기본값 사용 - {}", DEFAULT_ADMIN_BASE_URL);
return parseBaseUrls(DEFAULT_ADMIN_BASE_URL);
}
return urls;
}
/**
* 콤마/개행으로 구분된 base URL 문자열을 정규화된 목록으로 파싱한다.
*/
static List<String> parseBaseUrls(String raw) {
Set<String> ordered = new LinkedHashSet<>();
if (raw != null) {
for (String token : raw.split("[,\\r\\n]")) {
String url = stripTrailingSlashes(token.trim());
if (!url.isEmpty()) {
ordered.add(url);
}
}
}
return new ArrayList<>(ordered);
}
/**
@@ -24,11 +24,11 @@ public class RestTemplateConfig {
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
// 연결 타임아웃: 10초
factory.setConnectTimeout(10000);
// 연결 타임아웃: 5초 (admin base-url failover 시 다음 후보로 빨리 넘어가기 위함)
factory.setConnectTimeout(5000);
// 읽기 타임아웃: 30
factory.setReadTimeout(30000);
// 읽기 타임아웃: 5
factory.setReadTimeout(5000);
return new RestTemplate(factory);
}
@@ -1,8 +1,8 @@
<!-- fragment/popup/customPopups.html -->
<div th:fragment="customPopups">
<div th:replace="fragment/popup/customPopup :: #customAlert"></div>
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
<div th:replace="fragment/popup/terminateRequestPopup :: terminateRequestPopup"></div>
<div th:replace="~{fragment/popup/customPopup :: #customAlert}"></div>
<div th:replace="~{fragment/popup/emailValidationPopup :: #emailValidationPopup}"></div>
<div th:replace="~{fragment/popup/customPopup2 :: #customConfirm}"></div>
<div th:replace="~{fragment/popup/passwordInputPopup :: passwordInputPopup}"></div>
<div th:replace="~{fragment/popup/terminateRequestPopup :: terminateRequestPopup}"></div>
</div>
@@ -3,7 +3,7 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/head :: headFragment">
<head th:replace="~{fragment/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -23,7 +23,7 @@
</th:block>
</div>
<footer th:replace="fragment/footer :: footerFragment"></footer>
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
</body>
</html>
@@ -2,7 +2,7 @@
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/djbank/head :: headFragment">
<head th:replace="~{fragment/djbank/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -20,9 +20,9 @@
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
@@ -3,7 +3,7 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/djbank/head :: headFragment">
<head th:replace="~{fragment/djbank/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -18,9 +18,9 @@
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
</body>
</html>
@@ -2,7 +2,7 @@
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/djbank/head :: headFragment">
<head th:replace="~{fragment/djbank/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -18,7 +18,7 @@
<th:block layout:fragment="script">
</th:block>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
</body>
</html>
@@ -2,7 +2,7 @@
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/djbank/head :: headFragment">
<head th:replace="~{fragment/djbank/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -12,7 +12,7 @@
<div class="loading-overlay" style="display: none;">
<div class="loading-spinner"></div>
</div>
<div class="navigation" th:replace="fragment/djbank/header_nav :: headerFragment"></div>
<div class="navigation" th:replace="~{fragment/djbank/header_nav :: headerFragment}"></div>
<div class="container">
<th:block layout:fragment="contentFragment">
</th:block>
@@ -21,9 +21,9 @@
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/djbank/footer :: footerFragment"></footer>
<section th:replace="fragment/popup/customPopups :: customPopups"></section>
<section th:replace="fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup"></section>
<footer th:replace="~{fragment/djbank/footer :: footerFragment}"></footer>
<section th:replace="~{fragment/popup/customPopups :: customPopups}"></section>
<section th:replace="~{fragment/popup/twoFactorAuthPopup :: twoFactorAuthPopup}"></section>
<section layout:fragment="pagePopups"></section>
<script th:src="@{/js/popup/custom-popups.js}"></script>
<script th:src="@{/js/popup/two-factor-auth.js}"></script>
@@ -3,7 +3,7 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/head :: headFragment">
<head th:replace="~{fragment/head :: headFragment}">
<meta charset="utf-8">
</head>
@@ -21,7 +21,7 @@
</th:block>
</div>
<footer th:replace="fragment/footer :: footerFragment"></footer>
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
</body>
</html>
@@ -3,31 +3,31 @@
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head th:replace="fragment/head :: headFragment">
<head th:replace="~{fragment/head :: headFragment}">
<meta charset="utf-8">
</head>
<body>
<!-- header -->
<header th:replace="fragment/admin_header :: headerFragment"></header>
<th:block th:replace="fragment/admin_header :: headerScript"></th:block>
<nav th:replace="fragment/header :: navFragment"></nav>
<header th:replace="~{fragment/admin_header :: headerFragment}"></header>
<th:block th:replace="~{fragment/admin_header :: headerScript}"></th:block>
<nav th:replace="~{fragment/header :: navFragment}"></nav>
<!-- csrf -->
<input id="csrf" type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<div>
<aside th:replace="fragment/menu :: menuFragment"></aside>
<th:block th:replace="fragment/menu :: menuScript"></th:block>
<aside th:replace="~{fragment/menu :: menuFragment}"></aside>
<th:block th:replace="~{fragment/menu :: menuScript}"></th:block>
<section class="content">
<div layout:fragment="contentFragment">
</div>
<th:block layout:fragment="contentScript">
</th:block>
<footer th:replace="fragment/footer :: footerFragment"></footer>
<footer th:replace="~{fragment/footer :: footerFragment}"></footer>
</section>
</div>
</body>
@@ -18,7 +18,7 @@
<div class="example">
<h2>2. 프래그먼트 대체</h2>
<header th:replace="fragment/header :: headerFragment"></header>
<header th:replace="~{fragment/header :: headerFragment}"></header>
</div>
<div class="example">
@@ -104,7 +104,7 @@
<div class="example">
<h2>14. 프래그먼트</h2>
<div th:include="fragment/footer :: footerFragment">여기에 푸터가 들어갑니다</div>
<div th:include="~{fragment/footer :: footerFragment}">여기에 푸터가 들어갑니다</div>
</div>
<div class="example">