레거시 암호화 컨트롤러 제거 - 내부 API 토큰 초기화 로직 추가 - 정규식 성능 최적화
This commit is contained in:
@@ -37,7 +37,7 @@ public class AdminGatewayClient {
|
|||||||
String baseUrl = portalPropertyService.getOrCreateProperty(
|
String baseUrl = portalPropertyService.getOrCreateProperty(
|
||||||
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
PROP_GROUP, PROP_ADMIN_BASE_URL, DEFAULT_ADMIN_BASE_URL, "admin(관리자포털) 내부 API base URL");
|
||||||
|
|
||||||
String url = baseUrl.replaceAll("/+$", "") + CLIENT_BLOCK_PATH;
|
String url = stripTrailingSlashes(baseUrl) + CLIENT_BLOCK_PATH;
|
||||||
|
|
||||||
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
// 네트워크/HTTP 오류는 RestTemplate 이 예외로 던진다.
|
||||||
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
ResponseEntity<Map> response = restTemplate.postForEntity(url, null, Map.class, clientId);
|
||||||
@@ -51,4 +51,18 @@ public class AdminGatewayClient {
|
|||||||
|
|
||||||
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
log.info("admin GW 차단/리로드 위임 성공 - clientId={}", clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base URL 끝의 {@code '/'} 를 모두 걷어낸다.
|
||||||
|
*
|
||||||
|
* <p>{@code replaceAll("/+$", "")} 은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||||
|
* 선형 스캔으로 대체했다. 동작은 동일하다.</p>
|
||||||
|
*/
|
||||||
|
private static String stripTrailingSlashes(String url) {
|
||||||
|
int end = url.length();
|
||||||
|
while (end > 0 && url.charAt(end - 1) == '/') {
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
return url.substring(0, end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-258
@@ -1,258 +0,0 @@
|
|||||||
package com.eactive.apim.portal.common.migration;
|
|
||||||
|
|
||||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
|
||||||
import com.eactive.apim.portal.jpa.PersonalDataEncryptConverter;
|
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [임시] 레거시 평문 데이터를 {@link PersonalDataEncryptConverter} 규칙으로 일괄 정규화(암호화)하는 운영 도구.
|
|
||||||
*
|
|
||||||
* <p>배경: {@code @Convert} 컬럼이 평문으로 저장된 레거시 행은, derived query가 검색값을 암호화하면서
|
|
||||||
* 평문 DB값과 불일치해 검색/로그인이 실패한다. 컨버터는 읽기에서 평문/암호문을 자동 구분하고
|
|
||||||
* 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는
|
|
||||||
* 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.</p>
|
|
||||||
*
|
|
||||||
* <p>보안: PTL_PROPERTY {@code Portal / migration.internal.allow-ips} 허용 IP 목록(콤마 구분,
|
|
||||||
* 기본 loopback)에 포함된 IP 의 직접 호출만 허용한다 ({@code MenuInternalController} 모델).
|
|
||||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 호출이 불가하므로, 실행 전 property 에 호출자 IP 를
|
|
||||||
* 추가하고 작업 완료 후 원복한다. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다.
|
|
||||||
* 기본은 dry-run(미변경)이며, 실제 실행은 {@code dryRun=false}를 명시해야 한다.
|
|
||||||
* 작업 완료 후 이 클래스는 제거한다.</p>
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* # 미리보기(변경 안 함)
|
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy'
|
|
||||||
* # 실제 실행 (PII 컬럼)
|
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false'
|
|
||||||
* # audit 컬럼(created_by/last_modified_by, 19개 테이블)까지 포함
|
|
||||||
* curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
|
|
||||||
* </pre>
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/internal/migration")
|
|
||||||
public class LegacyEncryptionMigrationController {
|
|
||||||
|
|
||||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */
|
|
||||||
private static final List<TargetTable> PII_TARGETS = Arrays.asList(
|
|
||||||
new TargetTable("PTL_USER", Arrays.asList("login_id", "email_addr", "phone_number", "mobile_number")),
|
|
||||||
new TargetTable("PTL_MESSAGE_REQUEST", Arrays.asList("email", "phone")),
|
|
||||||
new TargetTable("tseairm02", Arrays.asList("cphnno", "emad", "ofctelno")),
|
|
||||||
new TargetTable("PTL_USER_LOG", Arrays.asList("login_id")),
|
|
||||||
new TargetTable("PTL_TWO_FACTOR_AUTH", Arrays.asList("recipient")),
|
|
||||||
new TargetTable("PTL_USER_INVITATION", Arrays.asList("INVITATION_MOBILE"))
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */
|
|
||||||
private static final List<String> AUDIT_TABLES = Arrays.asList(
|
|
||||||
"PTL_USER", "ptl_faq", "ptl_org",
|
|
||||||
"DJB_APISTATUS_INCIDENT", "DJB_APISTATUS_INCIDENT_TIMELINE", "DJB_APISTATUS_INCIDENT_API",
|
|
||||||
"ptl_file", "PTL_MESSAGE_TEMPLATE", "ptl_notice", "ptl_terms",
|
|
||||||
"ptl_user_privacy_policy_agreement", "ptl_approval_line",
|
|
||||||
"PTL_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application",
|
|
||||||
"PTL_MENU_ITEM", "PTL_MENU_PLACEMENT", "PTL_ROLE", "PTL_ROLE_AUTHORITY"
|
|
||||||
);
|
|
||||||
private static final List<String> AUDIT_COLUMNS = Arrays.asList("created_by", "last_modified_by");
|
|
||||||
|
|
||||||
static final String PROP_GROUP = "Portal";
|
|
||||||
static final String PROP_ALLOW_IPS = "migration.internal.allow-ips";
|
|
||||||
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final PortalPropertyService portalPropertyService;
|
|
||||||
private final PersonalDataEncryptConverter converter = new PersonalDataEncryptConverter();
|
|
||||||
|
|
||||||
public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource,
|
|
||||||
PortalPropertyService portalPropertyService) {
|
|
||||||
// EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다.
|
|
||||||
this.jdbcTemplate = new JdbcTemplate(emsDataSource);
|
|
||||||
this.portalPropertyService = portalPropertyService;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/encrypt-legacy")
|
|
||||||
@Transactional("transactionManager")
|
|
||||||
public Map<String, Object> encryptLegacy(HttpServletRequest request,
|
|
||||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
|
||||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
|
||||||
assertAllowedIp(request);
|
|
||||||
assertNotBypass();
|
|
||||||
|
|
||||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
|
||||||
if (includeAudit) {
|
|
||||||
for (String table : AUDIT_TABLES) {
|
|
||||||
targets.add(new TargetTable(table, AUDIT_COLUMNS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Map<String, Object>> results = new ArrayList<>();
|
|
||||||
int totalChanged = 0;
|
|
||||||
int totalSkipped = 0;
|
|
||||||
for (TargetTable target : targets) {
|
|
||||||
for (String column : target.columns) {
|
|
||||||
Map<String, Object> r = processColumn(target.table, column, dryRun);
|
|
||||||
results.add(r);
|
|
||||||
totalChanged += (int) r.get("changed");
|
|
||||||
totalSkipped += (int) r.get("skipped");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object> response = new LinkedHashMap<>();
|
|
||||||
response.put("mode", dryRun ? "dry-run (변경 없음)" : "executed");
|
|
||||||
response.put("damoMode", resolveDamoMode());
|
|
||||||
response.put("includeAudit", includeAudit);
|
|
||||||
response.put("totalChanged", totalChanged);
|
|
||||||
// 정규화 결과가 빈 값이라 UPDATE 를 생략한 건수. 0 이 아니면 원인 조사 후 진행할 것.
|
|
||||||
response.put("totalSkipped", totalSkipped);
|
|
||||||
response.put("results", results);
|
|
||||||
log.info("[레거시 암호화 마이그레이션] mode={} includeAudit={} totalChanged={} totalSkipped={}",
|
|
||||||
dryRun ? "dry-run" : "executed", includeAudit, totalChanged, totalSkipped);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveDamoMode() {
|
|
||||||
if (converter.isBypassMode()) {
|
|
||||||
return "BYPASS";
|
|
||||||
}
|
|
||||||
return converter.isFakeMode() ? "FAKE" : "REAL";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단일 (테이블, 컬럼)의 고유값을 정규화하고, 값이 바뀌는 경우에만 UPDATE.
|
|
||||||
*/
|
|
||||||
private Map<String, Object> processColumn(String table, String column, boolean dryRun) {
|
|
||||||
Map<String, Object> r = new LinkedHashMap<>();
|
|
||||||
r.put("table", table);
|
|
||||||
r.put("column", column);
|
|
||||||
|
|
||||||
List<String> values;
|
|
||||||
try {
|
|
||||||
values = jdbcTemplate.queryForList(
|
|
||||||
"SELECT DISTINCT " + column + " FROM " + table + " WHERE " + column + " IS NOT NULL",
|
|
||||||
String.class);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[마이그레이션] 조회 실패 table={} column={} : {}", table, column, e.toString());
|
|
||||||
r.put("distinct", 0);
|
|
||||||
r.put("changed", 0);
|
|
||||||
r.put("skipped", 0);
|
|
||||||
r.put("error", e.getMessage());
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
int changed = 0;
|
|
||||||
int skipped = 0;
|
|
||||||
for (String value : values) {
|
|
||||||
String normalized;
|
|
||||||
try {
|
|
||||||
// 평문 → 인코딩, 이미 인코딩 → 동일값 (멱등)
|
|
||||||
normalized = converter.convertToDatabaseColumn(converter.convertToEntityAttribute(value));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[마이그레이션] 정규화 실패 table={} column={} : {}", table, column, e.toString());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ((normalized == null || normalized.isEmpty()) && !value.isEmpty()) {
|
|
||||||
// 방어: 원본이 비어있지 않은데 정규화 결과가 빈 값 → 절대 UPDATE 하지 않음 (데이터 소실 방지)
|
|
||||||
skipped++;
|
|
||||||
log.warn("[마이그레이션] 정규화 결과가 빈 값 — UPDATE 생략 table={} column={} valueLen={}",
|
|
||||||
table, column, value.length());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (normalized != null && !normalized.equals(value)) {
|
|
||||||
if (!dryRun) {
|
|
||||||
jdbcTemplate.update(
|
|
||||||
"UPDATE " + table + " SET " + column + " = ? WHERE " + column + " = ?",
|
|
||||||
normalized, value);
|
|
||||||
}
|
|
||||||
changed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.put("distinct", values.size());
|
|
||||||
r.put("changed", changed);
|
|
||||||
r.put("skipped", skipped);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* damo-manager 가 bypass 모드면 실행 자체를 거부한다.
|
|
||||||
* bypass 에서는 {@code encrypt} 가 무변환(원문 그대로)이라 정규화가 평문→평문 no-op 이 되어
|
|
||||||
* 마이그레이션이 의미가 없고, "완료"로 오인될 위험이 있다. real/fake 모드로 기동 후 실행해야 한다.
|
|
||||||
*/
|
|
||||||
private void assertNotBypass() {
|
|
||||||
if (converter.isBypassMode()) {
|
|
||||||
log.warn("[마이그레이션] bypass 모드 실행 거부 — 암복호화가 무변환이라 마이그레이션이 무의미함");
|
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT,
|
|
||||||
"damo-manager 가 bypass 모드입니다. 암복호화가 무변환(원문 그대로)이라 마이그레이션이 무의미하므로 거부합니다. "
|
|
||||||
+ "real/fake 모드(-Ddamo-manager.enabled=true)로 기동한 뒤 실행하세요.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PTL_PROPERTY({@code Portal / migration.internal.allow-ips}) 허용 IP 목록 검사.
|
|
||||||
* 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. ({@code MenuInternalController} 모델)
|
|
||||||
* 운영 서버는 bind IP 가 NIC IP 라 loopback 기본값으로는 호출 불가 — 실행 전 property 에
|
|
||||||
* 호출자 IP 를 추가하고 완료 후 원복한다.
|
|
||||||
*/
|
|
||||||
private void assertAllowedIp(HttpServletRequest request) {
|
|
||||||
String remote = canonicalize(request.getRemoteAddr());
|
|
||||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
|
||||||
|
|
||||||
Set<String> allowed = Arrays.stream(resolveAllowIps().split(","))
|
|
||||||
.map(String::trim)
|
|
||||||
.filter(ip -> !ip.isEmpty())
|
|
||||||
.map(LegacyEncryptionMigrationController::canonicalize)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
if (viaProxy || !allowed.contains(remote)) {
|
|
||||||
log.warn("[마이그레이션] 비허용 접근 차단 remoteAddr={} viaProxy={} xff={}",
|
|
||||||
StringMaskingUtil.maskIpAddress(remote), viaProxy,
|
|
||||||
StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For")));
|
|
||||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
|
||||||
"허용되지 않은 접근입니다. (PTL_PROPERTY " + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveAllowIps() {
|
|
||||||
try {
|
|
||||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
|
|
||||||
DEFAULT_ALLOW_IPS, "레거시 암호화 마이그레이션 내부 API 허용 IP 목록(콤마 구분)");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("[마이그레이션] 허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
|
||||||
return DEFAULT_ALLOW_IPS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** IPv6 loopback 표기 통일 */
|
|
||||||
private static String canonicalize(String ip) {
|
|
||||||
return "0:0:0:0:0:0:0:1".equals(ip) ? "::1" : ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final class TargetTable {
|
|
||||||
final String table;
|
|
||||||
final List<String> columns;
|
|
||||||
|
|
||||||
TargetTable(String table, List<String> columns) {
|
|
||||||
this.table = table;
|
|
||||||
this.columns = columns;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import javax.naming.NamingException;
|
import javax.naming.NamingException;
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
@@ -110,9 +111,22 @@ public class BaseDatasourceConfiguration {
|
|||||||
return builder
|
return builder
|
||||||
.dataSource(dataSource)
|
.dataSource(dataSource)
|
||||||
// entity-package 는 콤마로 복수 지정 가능 (gateway 는 online-core + 포털 통계 엔티티 패키지)
|
// entity-package 는 콤마로 복수 지정 가능 (gateway 는 online-core + 포털 통계 엔티티 패키지)
|
||||||
.packages(prop.getEntityPackage().split("\\s*,\\s*"))
|
.packages(splitPackages(prop.getEntityPackage()))
|
||||||
.persistenceUnit(persistenceUnit)
|
.persistenceUnit(persistenceUnit)
|
||||||
.properties(properties)
|
.properties(properties)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 콤마로 나열된 엔티티 패키지를 분리한다. 앞뒤 공백은 걷어내고 빈 토큰은 버린다.
|
||||||
|
*
|
||||||
|
* <p>구분자 정규식 {@code "\\s*,\\s*"} 은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||||
|
* 단순 분리 + trim 으로 대체했다.</p>
|
||||||
|
*/
|
||||||
|
private static String[] splitPackages(String entityPackage) {
|
||||||
|
return Arrays.stream(entityPackage.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(pkg -> !pkg.isEmpty())
|
||||||
|
.toArray(String[]::new);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.eactive.apim.portal.config;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내부 API 공유 토큰 초기화 — 포탈 기동 시 1회.
|
||||||
|
*
|
||||||
|
* <p>PTL_PROPERTY {@code Portal / internal.api.header-name}, {@code internal.api.token} 이 없으면
|
||||||
|
* 이 시점에 생성된다(토큰은 SecureRandom 난수). eapim-admin 은 이 값을 <b>읽기만</b> 하므로,
|
||||||
|
* 토큰을 처음 만드는 주체는 포탈 한 곳으로 고정된다.</p>
|
||||||
|
*
|
||||||
|
* <p>{@link ApplicationReadyEvent} 를 쓰는 이유: 데이터소스/JTA 초기화가 끝난 뒤여야 프로퍼티를 저장할 수 있다.
|
||||||
|
* WAR 배포(WebLogic)에서도 동일하게 발생한다.</p>
|
||||||
|
*
|
||||||
|
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InternalApiTokenInitializer {
|
||||||
|
|
||||||
|
private final InternalApiTokenService internalApiTokenService;
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void initialize() {
|
||||||
|
internalApiTokenService.initialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,15 +33,13 @@ import org.springframework.web.filter.ForwardedHeaderFilter;
|
|||||||
* → getHeader("X-Forwarded-For") = null (필터가 제거 → viaProxy 판정이 false)
|
* → getHeader("X-Forwarded-For") = null (필터가 제거 → viaProxy 판정이 false)
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
* 즉 {@code MenuInternalController#isAllowed} / {@code LegacyEncryptionMigrationController#assertAllowedIp}
|
* 즉 {@code MenuInternalController#isAllowedIp} 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다.
|
||||||
* 의 "프록시 경유 거부 + loopback 허용" 가드가 무력화된다. 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로
|
* 필터를 제외해 두면 이 경로만은 <b>원 소켓 IP</b> 로 검사되므로 기존 가드가 설계대로 동작한다.
|
||||||
* 검사되므로 기존 가드가 설계대로 동작한다.
|
|
||||||
*
|
*
|
||||||
* <p><b>전제</b>: {@code framework} 는 신뢰 프록시 목록이 없어 헤더를 무조건 신뢰한다. 앞단(OHS)에서 인바운드
|
* <p><b>전제</b>: {@code framework} 는 신뢰 프록시 목록이 없어 헤더를 무조건 신뢰한다. 앞단(OHS)에서 인바운드
|
||||||
* {@code X-Forwarded-*} / {@code Forwarded} 를 제거한 뒤 재설정해야 하며, WAS 포트로의 직접 접근 경로도 차단해야 한다.
|
* {@code X-Forwarded-*} / {@code Forwarded} 를 제거한 뒤 재설정해야 하며, WAS 포트로의 직접 접근 경로도 차단해야 한다.
|
||||||
*
|
*
|
||||||
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
|
* @see com.eactive.apim.portal.djb.menu.MenuInternalController
|
||||||
* @see com.eactive.apim.portal.common.migration.LegacyEncryptionMigrationController
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
|
|||||||
@@ -107,8 +107,12 @@ public class PortalConfigSecurity {
|
|||||||
.logoutSuccessHandler(logoutSuccessHandler))
|
.logoutSuccessHandler(logoutSuccessHandler))
|
||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
.csrfTokenRepository(csrfTokenRepository)
|
.csrfTokenRepository(csrfTokenRepository)
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
// /_proxy 는 대응 핸들러가 없어 예외를 해제했다. 경로가 부활하면 아래를 되살릴 것.
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
// .ignoringRequestMatchers(new AntPathRequestMatcher("/_proxy/**/*"))
|
||||||
|
// /internal/migration 은 LegacyEncryptionMigrationController 제거와 함께 삭제됨.
|
||||||
|
// /internal/menu 는 브라우저 세션이 없는 서버간 호출(admin → portal)이라 CSRF 토큰을 실을 수 없다.
|
||||||
|
// 대신 InternalApiTokenService 의 공유 토큰 헤더 + 허용 IP 목록으로 통제한다.
|
||||||
|
// (커스텀 헤더는 cross-site form POST 로 위조할 수 없어 CSRF 경로가 차단된다)
|
||||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
|
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
|
||||||
)
|
)
|
||||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.eactive.apim.portal.djb.menu;
|
package com.eactive.apim.portal.djb.menu;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
|
||||||
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
||||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -19,14 +20,20 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
|
* 메뉴 캐시 내부 API — eapim-admin 의 reload 명령 수신용.
|
||||||
*
|
*
|
||||||
* <p>가드: PTL_PROPERTY {@code Portal / menu.internal.allow-ips} 허용 IP 목록
|
* <p>가드는 두 겹이다.</p>
|
||||||
* (기본 loopback) + X-Forwarded-For 동반 요청 거부
|
* <ol>
|
||||||
* ({@code LegacyEncryptionMigrationController.assertLocalOnly} 모델).
|
* <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}. 헤더명/토큰은 PTL_PROPERTY
|
||||||
* 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
|
* {@code Portal / internal.api.header-name}, {@code internal.api.token} 으로 관리하며
|
||||||
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.
|
* 토큰은 포탈 최초 기동 시 자동 생성된다. 이 경로는 CSRF 예외 대상
|
||||||
* CSRF 는 PortalConfigSecurity 에서 {@code /internal/menu/**} 예외 처리.</p>
|
* (PortalConfigSecurity {@code /internal/menu/**})이라, 커스텀 헤더 요구가 CSRF 를 대신한다 —
|
||||||
|
* 브라우저의 cross-site form POST 는 커스텀 헤더를 붙일 수 없다.</li>
|
||||||
|
* <li><b>허용 IP 목록</b> — PTL_PROPERTY {@code Portal / menu.internal.allow-ips}(기본 loopback)
|
||||||
|
* + X-Forwarded-For 동반 요청 거부. 허용 목록은 {@link IpAddressMatcher} 규칙을 따라 정확 일치 외에
|
||||||
|
* IPv4 CIDR({@code 172.30.1.0/24}) 과 옥텟 와일드카드({@code 172.30.*.*}) 를 지원한다.</li>
|
||||||
|
* </ol>
|
||||||
*
|
*
|
||||||
* <pre>curl -X POST http://127.0.0.1:39130/internal/menu/reload</pre>
|
* <pre>curl -X POST -H 'X-Internal-Token: <PTL_PROPERTY Portal/internal.api.token>' \
|
||||||
|
* http://127.0.0.1:39130/internal/menu/reload</pre>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@@ -43,14 +50,18 @@ public class MenuInternalController {
|
|||||||
|
|
||||||
private final MenuService menuService;
|
private final MenuService menuService;
|
||||||
private final PortalPropertyService portalPropertyService;
|
private final PortalPropertyService portalPropertyService;
|
||||||
|
private final InternalApiTokenService internalApiTokenService;
|
||||||
|
|
||||||
@PostMapping("/reload")
|
@PostMapping("/reload")
|
||||||
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
|
public ResponseEntity<Map<String, Object>> reload(HttpServletRequest request) {
|
||||||
if (!isAllowed(request)) {
|
if (!isAllowedIp(request)) {
|
||||||
Map<String, Object> denied = new LinkedHashMap<>();
|
return denied(HttpStatus.FORBIDDEN,
|
||||||
denied.put("result", "DENIED");
|
"허용되지 않은 접근입니다. (" + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
||||||
denied.put("message", "허용되지 않은 접근입니다. (menu.internal.allow-ips 확인)");
|
}
|
||||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(denied);
|
if (!hasValidToken(request)) {
|
||||||
|
return denied(HttpStatus.UNAUTHORIZED,
|
||||||
|
"내부 API 토큰이 유효하지 않습니다. (" + InternalApiTokenService.PROP_GROUP + "/"
|
||||||
|
+ InternalApiTokenService.PROP_TOKEN + " 확인)");
|
||||||
}
|
}
|
||||||
|
|
||||||
MenuService.MenuSnapshot snapshot = menuService.reload();
|
MenuService.MenuSnapshot snapshot = menuService.reload();
|
||||||
@@ -63,12 +74,31 @@ public class MenuInternalController {
|
|||||||
return ResponseEntity.ok(body);
|
return ResponseEntity.ok(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ResponseEntity<Map<String, Object>> denied(HttpStatus status, String message) {
|
||||||
|
Map<String, Object> denied = new LinkedHashMap<>();
|
||||||
|
denied.put("result", "DENIED");
|
||||||
|
denied.put("message", message);
|
||||||
|
return ResponseEntity.status(status).body(denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 토큰 헤더 검사. 헤더명은 PTL_PROPERTY 로 바뀔 수 있으므로 매 요청 조회한다(호출 빈도가 낮다).
|
||||||
|
*/
|
||||||
|
private boolean hasValidToken(HttpServletRequest request) {
|
||||||
|
String headerName = internalApiTokenService.ensureHeaderName();
|
||||||
|
if (!internalApiTokenService.matches(request.getHeader(headerName))) {
|
||||||
|
log.warn("메뉴 내부 API 차단 - 토큰 불일치, remote: {}, header: {}", request.getRemoteAddr(), headerName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
|
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
|
||||||
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
|
* (embedded Tomcat 은 forward-headers-strategy: native 로 XFF 가 remoteAddr 에 반영될 수 있으나
|
||||||
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
|
* 그 경우에도 allowlist 검사로 차단된다. WebLogic WAR 배포에서는 원 소켓 IP 로 검사된다.)
|
||||||
*/
|
*/
|
||||||
private boolean isAllowed(HttpServletRequest request) {
|
private boolean isAllowedIp(HttpServletRequest request) {
|
||||||
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
||||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||||
|
|
||||||
|
|||||||
@@ -321,6 +321,14 @@ function insertImageAsDataUri(file, $editor) {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정규식 기반 정리를 적용할 HTML 최대 길이(문자).
|
||||||
|
* 아래 VML/네임스페이스 정리 정규식은 백트래킹으로 super-linear 가 될 수 있어(Sonar S5852)
|
||||||
|
* 비정상적으로 큰 입력에서는 정규식 단계를 건너뛴다. data-uri 이미지를 포함한 일반적인
|
||||||
|
* Office 붙여넣기는 이 한도 아래다.
|
||||||
|
*/
|
||||||
|
var MAX_REGEX_CLEAN_LENGTH = 500000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용)
|
* 붙여넣기된 HTML 정리 (MS Office 등에서 복사한 내용)
|
||||||
* - 불필요한 Office 속성 제거
|
* - 불필요한 Office 속성 제거
|
||||||
@@ -358,6 +366,14 @@ function cleanPastedHtml(html) {
|
|||||||
|
|
||||||
// VML 태그 제거 (v:, o:, w: 등)
|
// VML 태그 제거 (v:, o:, w: 등)
|
||||||
var cleanedHtml = $temp.html();
|
var cleanedHtml = $temp.html();
|
||||||
|
|
||||||
|
// 한도 초과 시 정규식 정리를 건너뛴다 — DOM 정리(이미지 속성 제거)는 이미 끝났으므로
|
||||||
|
// 붙여넣기 자체는 동작하고, Office 네임스페이스 잔여물만 남는다. (브라우저 멈춤 방지)
|
||||||
|
if (cleanedHtml.length > MAX_REGEX_CLEAN_LENGTH) {
|
||||||
|
console.warn('붙여넣기 HTML이 너무 커서 Office 태그 정리를 건너뜁니다. length=' + cleanedHtml.length);
|
||||||
|
return cleanedHtml;
|
||||||
|
}
|
||||||
|
|
||||||
cleanedHtml = cleanedHtml.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
cleanedHtml = cleanedHtml.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
||||||
cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
|
cleanedHtml = cleanedHtml.replace(/<o:[^>]*>[\s\S]*?<\/o:[^>]*>/gi, '');
|
||||||
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
|
cleanedHtml = cleanedHtml.replace(/<w:[^>]*>[\s\S]*?<\/w:[^>]*>/gi, '');
|
||||||
|
|||||||
Reference in New Issue
Block a user