test 추가
This commit is contained in:
@@ -5,7 +5,7 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
@MapperScan("com.owrawww.domain.mapper")
|
||||
@MapperScan({"com.owrawww.domain.mapper", "com.owrawww.migration"})
|
||||
public class OwrawwwApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -45,5 +45,7 @@ public class Inquiry {
|
||||
private Integer subGubun;
|
||||
private Integer depth;
|
||||
private String solutionGubun;
|
||||
private String telHash; // SHA-256(솔트+tel) - 검색/중복체크용
|
||||
private String emailHash; // SHA-256(솔트+email) - 검색/중복체크용
|
||||
|
||||
}
|
||||
|
||||
21
src/main/java/com/owrawww/migration/DataMigrationMapper.java
Normal file
21
src/main/java/com/owrawww/migration/DataMigrationMapper.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.owrawww.migration;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Mapper
|
||||
public interface DataMigrationMapper {
|
||||
|
||||
/** 미암호화 레코드 조회 (phone 길이 30자 이하 = 평문) */
|
||||
List<Map<String, Object>> selectPlainRecords();
|
||||
|
||||
/** phone, tel_hash, email, email_hash 일괄 업데이트 */
|
||||
int updateEncrypted(@Param("id") Long id,
|
||||
@Param("phone") String phone,
|
||||
@Param("telHash") String telHash,
|
||||
@Param("email") String email,
|
||||
@Param("emailHash") String emailHash);
|
||||
}
|
||||
81
src/main/java/com/owrawww/migration/DataMigrationRunner.java
Normal file
81
src/main/java/com/owrawww/migration/DataMigrationRunner.java
Normal file
@@ -0,0 +1,81 @@
|
||||
package com.owrawww.migration;
|
||||
|
||||
import com.owrawww.util.AesUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 기존 평문 phone/email 데이터를 일괄 암호화하는 마이그레이션 Runner.
|
||||
*
|
||||
* 실행 방법 (운영 서버):
|
||||
* java -jar owrawww.jar --spring.profiles.active=prod,migration
|
||||
*
|
||||
* 주의사항:
|
||||
* - 한 번만 실행해야 합니다. 재실행 시 이미 암호화된 데이터는 자동으로 건너뜁니다.
|
||||
* - 실행 전 DB 백업을 반드시 수행하세요.
|
||||
* - 마이그레이션 완료 후 이 profile 없이 앱을 재시작하세요.
|
||||
*/
|
||||
@Slf4j
|
||||
@Profile("migration")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DataMigrationRunner implements ApplicationRunner {
|
||||
|
||||
private final DataMigrationMapper migrationMapper;
|
||||
private final AesUtil aesUtil;
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(ApplicationArguments args) {
|
||||
log.info("=== [마이그레이션 시작] phone/email 암호화 ===");
|
||||
|
||||
List<Map<String, Object>> records = migrationMapper.selectPlainRecords();
|
||||
log.info("암호화 대상 레코드 수: {}건", records.size());
|
||||
|
||||
if (records.isEmpty()) {
|
||||
log.info("암호화할 평문 데이터가 없습니다. 이미 완료됐거나 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
int success = 0;
|
||||
int skip = 0;
|
||||
|
||||
for (Map<String, Object> row : records) {
|
||||
Long id = ((Number) row.get("id")).longValue();
|
||||
String phone = (String) row.get("phone");
|
||||
String email = (String) row.get("email");
|
||||
|
||||
// null/빈값 방어
|
||||
if (phone == null || phone.isBlank() || email == null || email.isBlank()) {
|
||||
log.warn(" ID={} : phone 또는 email 이 비어있어 건너뜁니다.", id);
|
||||
skip++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String encPhone = aesUtil.encrypt(phone);
|
||||
String hashPhone = aesUtil.hash(phone);
|
||||
String encEmail = aesUtil.encrypt(email);
|
||||
String hashEmail = aesUtil.hash(email);
|
||||
|
||||
migrationMapper.updateEncrypted(id, encPhone, hashPhone, encEmail, hashEmail);
|
||||
log.info(" ID={} : 암호화 완료", id);
|
||||
success++;
|
||||
} catch (Exception e) {
|
||||
log.error(" ID={} : 암호화 실패 - {}", id, e.getMessage());
|
||||
throw new RuntimeException("ID=" + id + " 처리 중 오류 발생. 트랜잭션 롤백.", e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("=== [마이그레이션 완료] 성공: {}건, 건너뜀: {}건 ===", success, skip);
|
||||
log.info("마이그레이션이 끝났습니다. 'migration' 프로파일 없이 앱을 재시작하세요.");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,9 @@ public class InquiryService {
|
||||
savedInquiry.setComment(inquiry.getContent());
|
||||
savedInquiry.setName(inquiry.getName());
|
||||
savedInquiry.setTel(aesUtil.encrypt(inquiry.getTel()));
|
||||
savedInquiry.setTelHash(aesUtil.hash(inquiry.getTel()));
|
||||
savedInquiry.setEmail(aesUtil.encrypt(inquiry.getEmail()));
|
||||
savedInquiry.setEmailHash(aesUtil.hash(inquiry.getEmail()));
|
||||
savedInquiry.setTopCode(2);
|
||||
savedInquiry.setLeftCode(1);
|
||||
savedInquiry.setSubGubun(1);
|
||||
|
||||
@@ -7,8 +7,12 @@ import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* AES-256-GCM 암호화/복호화 유틸리티
|
||||
@@ -22,13 +26,17 @@ public class AesUtil {
|
||||
private static final int TAG_BIT_LEN = 128; // GCM 인증 태그 크기
|
||||
|
||||
private final SecretKey secretKey;
|
||||
private final String hashSalt;
|
||||
|
||||
public AesUtil(@Value("${app.encryption.key}") String base64Key) {
|
||||
public AesUtil(
|
||||
@Value("${app.encryption.key}") String base64Key,
|
||||
@Value("${app.hash.salt}") String hashSalt) {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
|
||||
if (keyBytes.length != 32) {
|
||||
throw new IllegalArgumentException("암호화 키는 Base64 인코딩된 32바이트(256bit)여야 합니다.");
|
||||
}
|
||||
this.secretKey = new SecretKeySpec(keyBytes, "AES");
|
||||
this.hashSalt = hashSalt;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,4 +83,22 @@ public class AesUtil {
|
||||
throw new RuntimeException("복호화 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 평문 → SHA-256(솔트 + 평문) → HEX 문자열 (검색/중복체크용)
|
||||
* - 단방향: 복호화 불가
|
||||
* - 솔트로 레인보우 테이블 공격 방어
|
||||
*/
|
||||
public String hash(String plainText) {
|
||||
if (plainText == null || plainText.isEmpty()) return plainText;
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes = digest.digest(
|
||||
(hashSalt + plainText).getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
return HexFormat.of().formatHex(hashBytes);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new RuntimeException("SHA-256 알고리즘을 사용할 수 없습니다.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user