마스킹 표준화 적용 (eapim-admin/portal/JS)
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- admin: elink-online-core 표준 위임(e메일/이름/전화/IP)으로 규칙 일원화
- portal/JS: 표준 로직 직접 구현 + 기존 owner 비마스킹 유지
- 호출처 무수정/인라인 중복 제거/테스트 통과
This commit is contained in:
Rinjae
2026-06-18 17:56:32 +09:00
parent f671f491b7
commit 10ff5d6f2f
2 changed files with 195 additions and 21 deletions
@@ -1,10 +1,24 @@
package com.eactive.apim.portal.common.util; package com.eactive.apim.portal.common.util;
import java.util.Arrays;
/** /**
* 문자열 마스킹 처리를 위한 유틸리티 클래스 * 문자열 마스킹 처리를 위한 유틸리티 클래스
*
* <p>보안아키텍처 표준 마스킹 규칙(elink-online-core {@code MaskingUtils})과 동일한 알고리즘을
* 구현한다. eapim-portal은 elink-online-core에 의존하지 않으므로 로직을 직접 포함한다.
* 소유자 본인이 자신의 정보를 조회하는 경우 마스킹하지 않는 owner 인지 동작은 유지한다.</p>
*
* <pre>
* 이름 2자: 뒤 1자리(가나→가*) / 3자 이상: 앞뒤 제외 가운데 전체(가나다라→가**라)
* 이메일 아이디 앞2·뒤2 마스킹, 4자 이하 전체(test→****, test123→**st1**)
* 전화 2·3번째 세그먼트 각각 뒤 2자리(010-1234-1234→010-12**-12**)
* </pre>
*/ */
public class StringMaskingUtil { public class StringMaskingUtil {
private static final char MASK = '*';
// ID 비교 // ID 비교
private static boolean isOwner(String ownerId, String currentUserId) { private static boolean isOwner(String ownerId, String currentUserId) {
return currentUserId != null && ownerId != null && currentUserId.equals(ownerId); return currentUserId != null && ownerId != null && currentUserId.equals(ownerId);
@@ -21,8 +35,15 @@ public class StringMaskingUtil {
return name; return name;
} }
return name.length() > 1 ? int len = name.length();
name.substring(0, name.length() - 1) + "*" : name; if (len == 1) {
return name;
}
if (len == 2) {
return name.charAt(0) + String.valueOf(MASK);
}
// 3자 이상: 첫 자 + 가운데 전체 마스킹 + 마지막 자
return name.charAt(0) + stars(len - 2) + name.charAt(len - 1);
} }
// 이메일 마스킹 처리 // 이메일 마스킹 처리
@@ -31,32 +52,32 @@ public class StringMaskingUtil {
return email; return email;
} }
String[] parts = email.split("@", 2); int atIdx = email.indexOf('@');
if (parts.length != 2) { String local = email.substring(0, atIdx);
return email; String domain = email.substring(atIdx);
if (local.length() <= 4) {
return stars(local.length()) + domain;
} }
return stars(2) + local.substring(2, local.length() - 2) + stars(2) + domain;
String localPart = parts[0];
String maskedLocal;
if (localPart.length() <= 2) {
maskedLocal = "***";
} else {
maskedLocal = localPart.substring(0, localPart.length() - 3) + "***";
}
return maskedLocal + "@" + parts[1];
} }
// 휴대폰 번호 마스킹 처리 // 휴대폰/전화/FAX 번호 마스킹 처리
public static String maskMobileNumber(String number, String ownerId, String currentUserId) { public static String maskMobileNumber(String number, String ownerId, String currentUserId) {
if (!isValidString(number) || isOwner(ownerId, currentUserId)) { if (!isValidString(number) || isOwner(ownerId, currentUserId)) {
return number; return number;
} }
String[] parts = number.split("-", 3); boolean hasHyphen = number.contains("-");
return parts.length == 3 ? String normalized = hasHyphen ? number : normalizePhoneNumber(number);
parts[0] + "-***-" + parts[2] : number; String[] parts = normalized.split("-");
if (parts.length == 3) {
String m0 = parts[0];
String m1 = maskSegmentTail(parts[1], 2);
String m2 = maskSegmentTail(parts[2], 2);
return hasHyphen ? m0 + "-" + m1 + "-" + m2 : m0 + m1 + m2;
}
return number;
} }
// 기존 메서드 오버로딩 (하위 호환성) // 기존 메서드 오버로딩 (하위 호환성)
@@ -71,4 +92,41 @@ public class StringMaskingUtil {
public static String maskMobileNumber(String number) { public static String maskMobileNumber(String number) {
return maskMobileNumber(number, null, null); return maskMobileNumber(number, null, null);
} }
// =========================================================================
// private helpers
// =========================================================================
private static String stars(int count) {
if (count <= 0) return "";
char[] arr = new char[count];
Arrays.fill(arr, MASK);
return new String(arr);
}
private static String maskSegmentTail(String segment, int count) {
if (segment.length() <= count) return stars(segment.length());
return segment.substring(0, segment.length() - count) + stars(count);
}
/**
* 한국 전화번호(숫자만) → XXX-XXXX-XXXX 형식으로 정규화
* <pre>
* 02 + 7자리 → 02-XXX-XXXX
* 02 + 8자리 → 02-XXXX-XXXX
* 0XX + 7자리 → 0XX-XXX-XXXX
* 0XX + 8자리 → 0XX-XXXX-XXXX
* </pre>
*/
private static String normalizePhoneNumber(String digits) {
int len = digits.length();
if (digits.startsWith("02")) {
if (len == 9) return digits.substring(0, 2) + "-" + digits.substring(2, 5) + "-" + digits.substring(5);
if (len == 10) return digits.substring(0, 2) + "-" + digits.substring(2, 6) + "-" + digits.substring(6);
} else if (digits.startsWith("0")) {
if (len == 10) return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
if (len == 11) return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
}
return digits;
}
} }
@@ -0,0 +1,116 @@
package com.eactive.apim.portal.common.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* eapim-portal {@link StringMaskingUtil}가 보안아키텍처 표준 규칙으로 마스킹하며,
* 소유자 본인 조회 시에는 마스킹하지 않는 동작을 검증한다.
*/
class StringMaskingUtilTest {
// --- 이름 ---
@Test
@DisplayName("이름 — 2자: 뒤 1자리")
void maskName_2chars() {
assertEquals("가*", StringMaskingUtil.maskName("가나"));
}
@Test
@DisplayName("이름 — 3자: 가운데 1자리")
void maskName_3chars() {
assertEquals("가*다", StringMaskingUtil.maskName("가나다"));
}
@Test
@DisplayName("이름 — 4자: 앞뒤 제외 가운데 전체")
void maskName_4chars() {
assertEquals("가**라", StringMaskingUtil.maskName("가나다라"));
}
@Test
@DisplayName("이름 — 5자: 앞뒤 제외 가운데 전체")
void maskName_5chars() {
assertEquals("가***마", StringMaskingUtil.maskName("가나다라마"));
}
@Test
@DisplayName("이름 — 1자/null/빈문자 방어")
void maskName_oneAndNull() {
assertEquals("", StringMaskingUtil.maskName(""));
assertNull(StringMaskingUtil.maskName(null));
assertEquals("", StringMaskingUtil.maskName(""));
}
// --- 이메일 ---
@Test
@DisplayName("이메일 — 아이디 앞2·뒤2 마스킹")
void maskEmail_normal() {
assertEquals("**st1**@test.com", StringMaskingUtil.maskEmail("test123@test.com"));
}
@Test
@DisplayName("이메일 — 아이디 4자: 전체 마스킹")
void maskEmail_fourChars() {
assertEquals("****@test.com", StringMaskingUtil.maskEmail("test@test.com"));
}
@Test
@DisplayName("이메일 — 아이디 1자: 전체 마스킹")
void maskEmail_oneChar() {
assertEquals("*@123.com", StringMaskingUtil.maskEmail("1@123.com"));
}
@Test
@DisplayName("이메일 — @ 없으면 원본 / null·빈문자 방어")
void maskEmail_invalidAndNull() {
assertEquals("notanemail", StringMaskingUtil.maskEmail("notanemail"));
assertNull(StringMaskingUtil.maskEmail(null));
assertEquals("", StringMaskingUtil.maskEmail(""));
}
// --- 휴대폰/전화 ---
@Test
@DisplayName("휴대폰 — 2·3번째 세그먼트 각 뒤 2자리")
void maskMobileNumber_normal() {
assertEquals("010-12**-12**", StringMaskingUtil.maskMobileNumber("010-1234-1234"));
assertEquals("010-12**-56**", StringMaskingUtil.maskMobileNumber("010-1234-5678"));
}
@Test
@DisplayName("휴대폰 — 하이픈 없는 11자리")
void maskMobileNumber_noHyphen() {
assertEquals("01012**56**", StringMaskingUtil.maskMobileNumber("01012345678"));
}
@Test
@DisplayName("휴대폰 — null/빈문자 방어")
void maskMobileNumber_null() {
assertNull(StringMaskingUtil.maskMobileNumber(null));
assertEquals("", StringMaskingUtil.maskMobileNumber(""));
}
// --- owner 인지 (본인 조회 시 비마스킹) ---
@Test
@DisplayName("owner — 본인 조회 시 마스킹하지 않음")
void ownerAware_noMaskWhenOwner() {
assertEquals("가나다", StringMaskingUtil.maskName("가나다", "user1", "user1"));
assertEquals("test123@test.com", StringMaskingUtil.maskEmail("test123@test.com", "user1", "user1"));
assertEquals("010-1234-1234", StringMaskingUtil.maskMobileNumber("010-1234-1234", "user1", "user1"));
}
@Test
@DisplayName("owner — 타인 조회 시 마스킹")
void ownerAware_maskWhenNotOwner() {
assertEquals("가*다", StringMaskingUtil.maskName("가나다", "user1", "user2"));
assertEquals("**st1**@test.com", StringMaskingUtil.maskEmail("test123@test.com", "user1", "user2"));
assertEquals("010-12**-12**", StringMaskingUtil.maskMobileNumber("010-1234-1234", "user1", "user2"));
}
}