diff --git a/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java b/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java index c539cbc..c9d4680 100644 --- a/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java +++ b/src/main/java/com/eactive/apim/portal/common/migration/LegacyEncryptionMigrationController.java @@ -2,6 +2,7 @@ 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; @@ -20,6 +21,8 @@ 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} 규칙으로 일괄 정규화(암호화)하는 운영 도구. @@ -29,15 +32,19 @@ import java.util.Map; * 쓰기에서 무조건 인코딩하므로, {@code convertToDatabaseColumn(convertToEntityAttribute(x))}는 * 평문→인코딩, 인코딩→동일값(멱등)으로 정규화된다. 이 값이 기존과 다를 때만 UPDATE 한다.

* - *

보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며, - * 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.

+ *

보안: 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}를 명시해야 한다. + * 작업 완료 후 이 클래스는 제거한다.

* *
  *   # 미리보기(변경 안 함)
  *   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, 15개 테이블)까지 포함
+ *   # audit 컬럼(created_by/last_modified_by, 19개 테이블)까지 포함
  *   curl -X POST 'http://127.0.0.1:39130/internal/migration/encrypt-legacy?dryRun=false&includeAudit=true'
  * 
*/ @@ -46,13 +53,14 @@ import java.util.Map; @RequestMapping("/internal/migration") public class LegacyEncryptionMigrationController { - /** PII 직접 컬럼 (로그인/검색에 직접 영향) */ + /** PII 직접 컬럼 (로그인/검색에 직접 영향). ofctelno 는 admin(UnifbwkManService)이 컨버터를 수동 호출해 암호화하는 컬럼 */ private static final List 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")), + 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_TWO_FACTOR_AUTH", Arrays.asList("recipient")), + new TargetTable("PTL_USER_INVITATION", Arrays.asList("INVITATION_MOBILE")) ); /** Auditable(@MappedSuperclass) 상속 테이블의 감사 컬럼 (옵션) */ @@ -61,16 +69,24 @@ public class LegacyEncryptionMigrationController { "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_INQUIRY_COMMENT", "ptl_inquiry", "ptl_partnership_application", + "PTL_MENU_ITEM", "PTL_MENU_PLACEMENT", "PTL_ROLE", "PTL_ROLE_AUTHORITY" ); private static final List 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) { + public LegacyEncryptionMigrationController(@Qualifier("portalDataSource") DataSource emsDataSource, + PortalPropertyService portalPropertyService) { // EMS(EMSAPP) 스키마 데이터소스. 컨버터 적용 테이블은 모두 EMS에 존재한다. this.jdbcTemplate = new JdbcTemplate(emsDataSource); + this.portalPropertyService = portalPropertyService; } @PostMapping("/encrypt-legacy") @@ -78,7 +94,7 @@ public class LegacyEncryptionMigrationController { public Map encryptLegacy(HttpServletRequest request, @RequestParam(defaultValue = "true") boolean dryRun, @RequestParam(defaultValue = "false") boolean includeAudit) { - assertLocalOnly(request); + assertAllowedIp(request); assertNotBypass(); List targets = new ArrayList<>(PII_TARGETS); @@ -90,24 +106,36 @@ public class LegacyEncryptionMigrationController { List> results = new ArrayList<>(); int totalChanged = 0; + int totalSkipped = 0; for (TargetTable target : targets) { for (String column : target.columns) { Map r = processColumn(target.table, column, dryRun); results.add(r); totalChanged += (int) r.get("changed"); + totalSkipped += (int) r.get("skipped"); } } Map 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={}", - dryRun ? "dry-run" : "executed", includeAudit, totalChanged); + 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. */ @@ -125,11 +153,13 @@ public class LegacyEncryptionMigrationController { 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 { @@ -139,6 +169,13 @@ public class LegacyEncryptionMigrationController { 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( @@ -151,6 +188,7 @@ public class LegacyEncryptionMigrationController { r.put("distinct", values.size()); r.put("changed", changed); + r.put("skipped", skipped); return r; } @@ -169,21 +207,45 @@ public class LegacyEncryptionMigrationController { } /** - * 127.0.0.1(localhost) 직접 호출만 허용. 프록시 경유(X-Forwarded-For 존재) 요청은 거부한다. + * PTL_PROPERTY({@code Portal / migration.internal.allow-ips}) 허용 IP 목록 검사. + * 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다. ({@code MenuInternalController} 모델) + * 운영 서버는 bind IP 가 NIC IP 라 loopback 기본값으로는 호출 불가 — 실행 전 property 에 + * 호출자 IP 를 추가하고 완료 후 원복한다. */ - private void assertLocalOnly(HttpServletRequest request) { - String remote = request.getRemoteAddr(); - boolean localAddr = "127.0.0.1".equals(remote) - || "0:0:0:0:0:0:0:1".equals(remote) - || "::1".equals(remote); + private void assertAllowedIp(HttpServletRequest request) { + String remote = canonicalize(request.getRemoteAddr()); boolean viaProxy = request.getHeader("X-Forwarded-For") != null; - if (!localAddr || viaProxy) { - log.warn("[마이그레이션] 비로컬 접근 차단 remoteAddr={} xff={}", - StringMaskingUtil.maskIpAddress(remote), StringMaskingUtil.maskIpAddress(request.getHeader("X-Forwarded-For"))); - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "localhost(127.0.0.1) 직접 호출만 허용됩니다."); + + Set 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 columns;