- PersonalDataEncryptConverter: isFakeMode 메서드 추가
- LegacyEncryptionMigration: 허용 IP 검증 로직 추가 - 스킵 내역 로그 강화 - 암호화 대상 컬럼 추가 및 DamoMode 구분 지원
This commit is contained in:
+83
-21
@@ -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 한다.</p>
|
||||
*
|
||||
* <p>보안: 오직 127.0.0.1(localhost)에서 직접 호출한 요청만 허용한다. 기본은 dry-run(미변경)이며,
|
||||
* 실제 실행은 {@code dryRun=false}를 명시해야 한다. 작업 완료 후 이 클래스는 제거한다.</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, 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'
|
||||
* </pre>
|
||||
*/
|
||||
@@ -46,13 +53,14 @@ import java.util.Map;
|
||||
@RequestMapping("/internal/migration")
|
||||
public class LegacyEncryptionMigrationController {
|
||||
|
||||
/** PII 직접 컬럼 (로그인/검색에 직접 영향) */
|
||||
/** 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")),
|
||||
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<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) {
|
||||
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<String, Object> encryptLegacy(HttpServletRequest request,
|
||||
@RequestParam(defaultValue = "true") boolean dryRun,
|
||||
@RequestParam(defaultValue = "false") boolean includeAudit) {
|
||||
assertLocalOnly(request);
|
||||
assertAllowedIp(request);
|
||||
assertNotBypass();
|
||||
|
||||
List<TargetTable> targets = new ArrayList<>(PII_TARGETS);
|
||||
@@ -90,24 +106,36 @@ public class LegacyEncryptionMigrationController {
|
||||
|
||||
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={}",
|
||||
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<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;
|
||||
|
||||
Reference in New Issue
Block a user