Merge branch 'feature/crypto-module' into master
This commit is contained in:
+1
-1
Submodule elink-online-common updated: 8f70451477...580686886c
+1
-1
Submodule elink-online-core-jpa updated: 76342bfaef...03aa7ced90
+82
@@ -0,0 +1,82 @@
|
|||||||
|
package com.eactive.eai.custom.adapter.http.dynamic.filter;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
|
||||||
|
import com.eactive.eai.adapter.http.dynamic.filter.CryptoFilter;
|
||||||
|
import com.eactive.eai.util.JsonPathUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DJB ERP 연동용 암복호화 필터.
|
||||||
|
*
|
||||||
|
* DYNAMIC 키 컨텍스트 추출 규칙:
|
||||||
|
* HTTP 헤더 {@code httpHeaderGroup}의 값은 JSON 객체이며,
|
||||||
|
* 그 중 {@code x-emp-nm} 필드 값을 {@code groupSeq}로
|
||||||
|
* runtimeContext에 담아 키 도출 전략에 전달한다.
|
||||||
|
*
|
||||||
|
* 어댑터 프로퍼티 (부모 클래스 공통 설정 외 추가 키):
|
||||||
|
* crypto.context.key runtimeContext에 넣을 키 이름 (기본: groupSeq)
|
||||||
|
* crypto.context.header 컨텍스트 JSON이 담긴 HTTP 헤더명 (기본: httpHeaderGroup)
|
||||||
|
* crypto.context.header.field 헤더 JSON에서 추출할 필드명 (기본: x-emp-nm)
|
||||||
|
*
|
||||||
|
* 어댑터 프로퍼티 설정 예:
|
||||||
|
* crypto.module.name = AES_GCM_NoPadding_DYNAMIC_sample
|
||||||
|
* crypto.scope = FIELD
|
||||||
|
* crypto.dec.enabled = Y
|
||||||
|
* crypto.enc.enabled = N
|
||||||
|
* crypto.dec.from.path = /encryptedData
|
||||||
|
* crypto.dec.to.path = /
|
||||||
|
* crypto.context.key = groupSeq
|
||||||
|
* crypto.context.header = httpHeaderGroup
|
||||||
|
* crypto.context.header.field = x-emp-nm
|
||||||
|
*
|
||||||
|
* 필터 타입 등록 (FQCN):
|
||||||
|
* com.eactive.eai.custom.adapter.http.dynamic.filter.DJBErpCryptoFilter
|
||||||
|
*/
|
||||||
|
public class DJBErpCryptoFilter extends CryptoFilter {
|
||||||
|
|
||||||
|
public static final String PROP_CONTEXT_KEY = "crypto.context.key";
|
||||||
|
public static final String PROP_CONTEXT_HEADER = "crypto.context.header";
|
||||||
|
public static final String PROP_CONTEXT_HEADER_FIELD = "crypto.context.header.field";
|
||||||
|
|
||||||
|
private static final String DEFAULT_CONTEXT_KEY = "groupSeq";
|
||||||
|
private static final String DEFAULT_CONTEXT_HEADER = "httpHeaderGroup";
|
||||||
|
private static final String DEFAULT_CONTEXT_HEADER_FIELD = "x-emp-nm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* httpHeaderGroup 헤더(JSON)에서 x-emp-nm 값을 추출하여 groupSeq로 매핑한다.
|
||||||
|
* 헤더가 없거나 필드를 찾을 수 없으면 빈 Map을 반환한다.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected Map<String, String> buildRuntimeContext(Properties prop, HttpServletRequest request) {
|
||||||
|
Map<String, String> ctx = new HashMap<>();
|
||||||
|
|
||||||
|
String contextKey = prop.getProperty(PROP_CONTEXT_KEY, DEFAULT_CONTEXT_KEY);
|
||||||
|
String contextHeaderName = prop.getProperty(PROP_CONTEXT_HEADER, DEFAULT_CONTEXT_HEADER);
|
||||||
|
String contextHeaderField = prop.getProperty(PROP_CONTEXT_HEADER_FIELD, DEFAULT_CONTEXT_HEADER_FIELD);
|
||||||
|
|
||||||
|
String headerJson = request.getHeader(contextHeaderName);
|
||||||
|
if (StringUtils.isBlank(headerJson)) {
|
||||||
|
logger.warn("DJBErpCryptoFilter] 컨텍스트 헤더 없음: " + contextHeaderName);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
String value = JsonPathUtil.getValueAtPath(headerJson, "/" + contextHeaderField);
|
||||||
|
if (value == null) {
|
||||||
|
logger.warn("DJBErpCryptoFilter] 헤더 JSON에서 필드 추출 실패: header="
|
||||||
|
+ contextHeaderName + ", field=" + contextHeaderField);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// x-emp-nm 값은 "groupSeq|empNo" 형태 — '|' 앞부분만 groupSeq로 사용
|
||||||
|
String groupSeq = value.contains("|") ? value.substring(0, value.indexOf('|')) : value;
|
||||||
|
ctx.put(contextKey, groupSeq);
|
||||||
|
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package com.eactive.eai.custom.security.keyderiv.strategy;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
|
||||||
|
import com.eactive.eai.common.hsm.HsmCryptoService;
|
||||||
|
import com.eactive.eai.common.security.keyderiv.DerivedKey;
|
||||||
|
import com.eactive.eai.common.security.keyderiv.KeyDerivationStrategy;
|
||||||
|
import com.eactive.eai.common.util.ApplicationContextProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HSM 마스터키 + 런타임 컨텍스트값을 연결(concatenate)한 뒤 SHA-256 해싱으로 키를 도출하는 전략.
|
||||||
|
* SHA-256 출력은 항상 32바이트(AES-256)이므로 별도 keyLength 파라미터가 불필요하다.
|
||||||
|
*
|
||||||
|
* key_deriv_params (JSON) 예시:
|
||||||
|
* {
|
||||||
|
* "hsmKeyAlias" : "MASTER_KEY_AES",
|
||||||
|
* "contextKey" : "X-Api-Group-Seq" -- runtimeContext에서 꺼낼 키 이름
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public class HsmContextSha256KeyDerivationStrategy implements KeyDerivationStrategy {
|
||||||
|
|
||||||
|
/** DB key_deriv_strategy 컬럼에 사용하는 FQCN 참조용 상수. */
|
||||||
|
public static final String STRATEGY_CLASS = HsmContextSha256KeyDerivationStrategy.class.getName();
|
||||||
|
|
||||||
|
private static final String PARAM_HSM_KEY_ALIAS = "hsmKeyAlias";
|
||||||
|
private static final String PARAM_CONTEXT_KEY = "contextKey";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DerivedKey deriveKey(Map<String, String> params, Map<String, String> runtimeContext) throws Exception {
|
||||||
|
String hsmKeyAlias = required(params, PARAM_HSM_KEY_ALIAS);
|
||||||
|
String contextKey = required(params, PARAM_CONTEXT_KEY);
|
||||||
|
|
||||||
|
SecretKey masterKey = hsmCryptoService().getSecretKey(hsmKeyAlias);
|
||||||
|
byte[] masterKeyBytes = masterKey.getEncoded();
|
||||||
|
byte[] contextBytes = runtimeContext.getOrDefault(contextKey, "")
|
||||||
|
.getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
byte[] combined = new byte[masterKeyBytes.length + contextBytes.length];
|
||||||
|
System.arraycopy(masterKeyBytes, 0, combined, 0, masterKeyBytes.length);
|
||||||
|
System.arraycopy(contextBytes, 0, combined, masterKeyBytes.length, contextBytes.length);
|
||||||
|
|
||||||
|
byte[] derived = MessageDigest.getInstance("SHA-256").digest(combined);
|
||||||
|
return new DerivedKey(derived, derived);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String buildCacheKey(String cryptoName, Map<String, String> params, Map<String, String> runtimeContext) {
|
||||||
|
String contextKey = params.getOrDefault(PARAM_CONTEXT_KEY, "");
|
||||||
|
String contextValue = runtimeContext.getOrDefault(contextKey, "");
|
||||||
|
return cryptoName + ":" + contextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String required(Map<String, String> params, String key) {
|
||||||
|
String value = params.get(key);
|
||||||
|
if (value == null || value.trim().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("key_deriv_params 필수값 누락: " + key);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HsmCryptoService hsmCryptoService() {
|
||||||
|
return ApplicationContextProvider.getContext().getBean(HsmCryptoService.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
+317
@@ -0,0 +1,317 @@
|
|||||||
|
package com.eactive.eai.custom.adapter.http.dynamic.filter;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Properties;
|
||||||
|
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
|
import org.json.simple.JSONObject;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.MethodOrderer;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestMethodOrder;
|
||||||
|
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
|
||||||
|
import org.springframework.context.support.GenericApplicationContext;
|
||||||
|
|
||||||
|
import com.eactive.eai.common.security.CryptoModuleService;
|
||||||
|
import com.eactive.eai.common.util.ApplicationContextProvider;
|
||||||
|
|
||||||
|
@TestMethodOrder(MethodOrderer.DisplayName.class)
|
||||||
|
class DJBErpCryptoFilterTest {
|
||||||
|
|
||||||
|
private static final String MODULE_NAME = "TEST_MODULE";
|
||||||
|
// 복호화 후 평문 (JSON 문자열)
|
||||||
|
private static final String PLAIN_TEXT = "{\"userId\":\"U001\",\"name\":\"test\"}";
|
||||||
|
private static final byte[] PLAIN_BYTES = PLAIN_TEXT.getBytes(StandardCharsets.UTF_8);
|
||||||
|
// 더미 암호문 bytes + Base64 표현
|
||||||
|
private static final byte[] CIPHER_BYTES = new byte[]{0x10, 0x20, 0x30, 0x40};
|
||||||
|
private static final String ENC_BASE64 = Base64.getEncoder().encodeToString(CIPHER_BYTES);
|
||||||
|
|
||||||
|
// 기본 httpHeaderGroup 헤더 JSON (groupSeq|empNo 형태)
|
||||||
|
private static final String HEADER_JSON_WITH_PIPE = "{\"x-emp-nm\":\"G001|E001\"}";
|
||||||
|
private static final String HEADER_JSON_WITHOUT_PIPE = "{\"x-emp-nm\":\"G001\"}";
|
||||||
|
|
||||||
|
private static GenericApplicationContext ctx;
|
||||||
|
private static CryptoModuleService mockCryptoService;
|
||||||
|
|
||||||
|
private DJBErpCryptoFilter filter;
|
||||||
|
private HttpServletRequest mockRequest;
|
||||||
|
private Properties prop;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 클래스 초기화 — Spring ApplicationContext + Mock CryptoModuleService
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpClass() throws Exception {
|
||||||
|
mockCryptoService = mock(CryptoModuleService.class);
|
||||||
|
|
||||||
|
ctx = new GenericApplicationContext();
|
||||||
|
ctx.getBeanFactory().registerSingleton("cryptoModuleService", mockCryptoService);
|
||||||
|
ctx.registerBeanDefinition("applicationContextProvider",
|
||||||
|
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
|
||||||
|
.getBeanDefinition());
|
||||||
|
ctx.refresh();
|
||||||
|
|
||||||
|
when(mockCryptoService.decrypt(anyString(), anyMap(), any(byte[].class)))
|
||||||
|
.thenReturn(PLAIN_BYTES);
|
||||||
|
when(mockCryptoService.encrypt(anyString(), anyMap(), any(byte[].class)))
|
||||||
|
.thenReturn(CIPHER_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void tearDownClass() {
|
||||||
|
if (ctx != null) ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
clearInvocations(mockCryptoService); // 이전 테스트의 호출 이력 초기화 (stubbing은 유지)
|
||||||
|
filter = new DJBErpCryptoFilter();
|
||||||
|
mockRequest = mock(HttpServletRequest.class);
|
||||||
|
prop = new Properties();
|
||||||
|
prop.setProperty(DJBErpCryptoFilter.PROP_MODULE_NAME, MODULE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. buildRuntimeContext
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-1. x-emp-nm이 'groupSeq|empNo' 형태이면 '|' 앞부분만 groupSeq로 추출")
|
||||||
|
void buildRuntimeContext_pipeFormat_extractsGroupSeqOnly() {
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE);
|
||||||
|
|
||||||
|
Map<String, String> runtimeCtx = filter.buildRuntimeContext(prop, mockRequest);
|
||||||
|
|
||||||
|
assertEquals("G001", runtimeCtx.get("groupSeq"));
|
||||||
|
assertEquals(1, runtimeCtx.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-2. x-emp-nm에 '|'가 없으면 전체 값을 groupSeq로 사용")
|
||||||
|
void buildRuntimeContext_noPipe_usesFullValue() {
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITHOUT_PIPE);
|
||||||
|
|
||||||
|
Map<String, String> runtimeCtx = filter.buildRuntimeContext(prop, mockRequest);
|
||||||
|
|
||||||
|
assertEquals("G001", runtimeCtx.get("groupSeq"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-3. httpHeaderGroup 헤더가 없으면 빈 Map 반환")
|
||||||
|
void buildRuntimeContext_headerMissing_returnsEmptyMap() {
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(null);
|
||||||
|
|
||||||
|
Map<String, String> runtimeCtx = filter.buildRuntimeContext(prop, mockRequest);
|
||||||
|
|
||||||
|
assertTrue(runtimeCtx.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-4. 헤더 JSON에 x-emp-nm 필드가 없으면 빈 Map 반환")
|
||||||
|
void buildRuntimeContext_fieldMissingInJson_returnsEmptyMap() {
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn("{\"other-field\":\"value\"}");
|
||||||
|
|
||||||
|
Map<String, String> runtimeCtx = filter.buildRuntimeContext(prop, mockRequest);
|
||||||
|
|
||||||
|
assertTrue(runtimeCtx.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-5. 프로퍼티로 헤더명/필드명/컨텍스트키를 커스터마이징할 수 있다")
|
||||||
|
void buildRuntimeContext_customProps_overrideDefaults() {
|
||||||
|
prop.setProperty(DJBErpCryptoFilter.PROP_CONTEXT_HEADER, "X-Custom-Header");
|
||||||
|
prop.setProperty(DJBErpCryptoFilter.PROP_CONTEXT_HEADER_FIELD, "emp-id");
|
||||||
|
prop.setProperty(DJBErpCryptoFilter.PROP_CONTEXT_KEY, "empGroup");
|
||||||
|
|
||||||
|
when(mockRequest.getHeader("X-Custom-Header")).thenReturn("{\"emp-id\":\"GRP99|EMP99\"}");
|
||||||
|
|
||||||
|
Map<String, String> runtimeCtx = filter.buildRuntimeContext(prop, mockRequest);
|
||||||
|
|
||||||
|
assertEquals("GRP99", runtimeCtx.get("empGroup"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. doPreFilter — 복호화
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-1. crypto.dec.enabled=N 이면 message를 그대로 반환하고 복호화하지 않는다")
|
||||||
|
void doPreFilter_decDisabled_returnsOriginalWithoutDecrypt() throws Exception {
|
||||||
|
prop.setProperty("crypto.dec.enabled", "N");
|
||||||
|
String original = "{\"encryptedData\":\"" + ENC_BASE64 + "\"}";
|
||||||
|
|
||||||
|
Object result = filter.doPreFilter(null, null, original, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertSame(original, result);
|
||||||
|
verifyNoInteractions(mockCryptoService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-2. FIELD scope: /encryptedData 복호화 후 dec.to.path=/ 이면 body 전체를 평문으로 교체")
|
||||||
|
void doPreFilter_fieldScope_toRoot_replacesEntireBody() throws Exception {
|
||||||
|
setFieldDecryptProps("/encryptedData", "/");
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE);
|
||||||
|
|
||||||
|
String body = "{\"encryptedData\":\"" + ENC_BASE64 + "\"}";
|
||||||
|
Object result = filter.doPreFilter(null, null, body, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertEquals(PLAIN_TEXT, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-3. FIELD scope: dec.to.path가 특정 경로이면 해당 필드에 복호화 결과를 설정")
|
||||||
|
void doPreFilter_fieldScope_toSpecificPath_setsFieldInBody() throws Exception {
|
||||||
|
setFieldDecryptProps("/encryptedData", "/plainData");
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE);
|
||||||
|
|
||||||
|
String body = "{\"encryptedData\":\"" + ENC_BASE64 + "\"}";
|
||||||
|
String result = (String) filter.doPreFilter(null, null, body, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertTrue(result.contains("\"encryptedData\""), "원본 필드가 유지되어야 한다");
|
||||||
|
assertTrue(result.contains("\"plainData\""), "복호화 결과 필드가 추가되어야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-4. FIELD scope: 복호화 대상 필드가 JSON에 없으면 원본 body를 그대로 반환")
|
||||||
|
void doPreFilter_fieldScope_missingEncryptedField_returnsOriginalBody() throws Exception {
|
||||||
|
setFieldDecryptProps("/encryptedData", "/");
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE);
|
||||||
|
|
||||||
|
String body = "{\"otherField\":\"value\"}";
|
||||||
|
Object result = filter.doPreFilter(null, null, body, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertEquals(body, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-5. BODY scope: message가 Base64 String이면 전체 복호화")
|
||||||
|
void doPreFilter_bodyScope_stringMessage_decryptsWholeBody() throws Exception {
|
||||||
|
prop.setProperty("crypto.scope", "BODY");
|
||||||
|
|
||||||
|
Object result = filter.doPreFilter(null, null, ENC_BASE64, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertEquals(PLAIN_TEXT, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-6. message가 byte[]이면 UTF-8 변환 후 FIELD 복호화 처리")
|
||||||
|
void doPreFilter_fieldScope_byteArrayMessage_convertsAndDecrypts() throws Exception {
|
||||||
|
setFieldDecryptProps("/encryptedData", "/");
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE);
|
||||||
|
|
||||||
|
byte[] bodyBytes = ("{\"encryptedData\":\"" + ENC_BASE64 + "\"}").getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
Object result = filter.doPreFilter(null, null, bodyBytes, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertEquals(PLAIN_TEXT, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-7. message가 JSONObject이면 toString() 변환 후 FIELD 복호화 처리")
|
||||||
|
void doPreFilter_fieldScope_jsonObjectMessage_convertsAndDecrypts() throws Exception {
|
||||||
|
setFieldDecryptProps("/encryptedData", "/");
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE);
|
||||||
|
|
||||||
|
JSONObject jsonMsg = new JSONObject();
|
||||||
|
jsonMsg.put("encryptedData", ENC_BASE64);
|
||||||
|
|
||||||
|
Object result = filter.doPreFilter(null, null, jsonMsg, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertEquals(PLAIN_TEXT, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-8. FIELD scope: runtimeContext에 groupSeq가 올바르게 전달된다")
|
||||||
|
void doPreFilter_fieldScope_runtimeContextPassedToDecrypt() throws Exception {
|
||||||
|
setFieldDecryptProps("/encryptedData", "/");
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE);
|
||||||
|
|
||||||
|
String body = "{\"encryptedData\":\"" + ENC_BASE64 + "\"}";
|
||||||
|
filter.doPreFilter(null, null, body, prop, mockRequest, null);
|
||||||
|
|
||||||
|
ArgumentCaptor<Map> ctxCaptor = ArgumentCaptor.forClass(Map.class);
|
||||||
|
verify(mockCryptoService).decrypt(eq(MODULE_NAME), ctxCaptor.capture(), any(byte[].class));
|
||||||
|
assertEquals("G001", ctxCaptor.getValue().get("groupSeq"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. doPostFilter — 암호화
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("3-1. crypto.enc.enabled 기본값(N) — resultMessage를 그대로 반환하고 암호화하지 않는다")
|
||||||
|
void doPostFilter_encDisabledByDefault_returnsOriginalWithoutEncrypt() throws Exception {
|
||||||
|
String original = "{\"result\":\"ok\"}";
|
||||||
|
|
||||||
|
Object result = filter.doPostFilter(null, null, original, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertSame(original, result);
|
||||||
|
verifyNoInteractions(mockCryptoService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("3-2. BODY scope, enc.enabled=Y — body 전체를 암호화하여 Base64 반환")
|
||||||
|
void doPostFilter_bodyScope_encEnabled_returnsBase64() throws Exception {
|
||||||
|
prop.setProperty("crypto.scope", "BODY");
|
||||||
|
prop.setProperty("crypto.enc.enabled", "Y");
|
||||||
|
|
||||||
|
Object result = filter.doPostFilter(null, null, PLAIN_TEXT, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertEquals(ENC_BASE64, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("3-3. FIELD scope, enc.from.path=/ — body 전체 암호화 후 enc.to.path 필드로 래핑")
|
||||||
|
void doPostFilter_fieldScope_fromRoot_wrapsEncryptedInJson() throws Exception {
|
||||||
|
prop.setProperty("crypto.scope", "FIELD");
|
||||||
|
prop.setProperty("crypto.enc.enabled", "Y");
|
||||||
|
prop.setProperty("crypto.enc.from.path", "/");
|
||||||
|
prop.setProperty("crypto.enc.to.path", "/encryptedData");
|
||||||
|
|
||||||
|
Object result = filter.doPostFilter(null, null, PLAIN_TEXT, prop, mockRequest, null);
|
||||||
|
|
||||||
|
assertEquals("{\"encryptedData\":\"" + ENC_BASE64 + "\"}", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
@DisplayName("3-4. FIELD scope: runtimeContext에 groupSeq가 올바르게 전달된다")
|
||||||
|
void doPostFilter_fieldScope_runtimeContextPassedToEncrypt() throws Exception {
|
||||||
|
prop.setProperty("crypto.scope", "FIELD");
|
||||||
|
prop.setProperty("crypto.enc.enabled", "Y");
|
||||||
|
prop.setProperty("crypto.enc.from.path", "/");
|
||||||
|
prop.setProperty("crypto.enc.to.path", "/encryptedData");
|
||||||
|
when(mockRequest.getHeader("httpHeaderGroup")).thenReturn(HEADER_JSON_WITH_PIPE);
|
||||||
|
|
||||||
|
filter.doPostFilter(null, null, PLAIN_TEXT, prop, mockRequest, null);
|
||||||
|
|
||||||
|
ArgumentCaptor<Map> ctxCaptor = ArgumentCaptor.forClass(Map.class);
|
||||||
|
verify(mockCryptoService).encrypt(eq(MODULE_NAME), ctxCaptor.capture(), any(byte[].class));
|
||||||
|
assertEquals("G001", ctxCaptor.getValue().get("groupSeq"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 헬퍼
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private void setFieldDecryptProps(String fromPath, String toPath) {
|
||||||
|
prop.setProperty("crypto.scope", "FIELD");
|
||||||
|
prop.setProperty("crypto.dec.from.path", fromPath);
|
||||||
|
prop.setProperty("crypto.dec.to.path", toPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
package com.eactive.eai.custom.security.keyderiv;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.MethodOrderer;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestMethodOrder;
|
||||||
|
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
|
||||||
|
import org.springframework.context.support.GenericApplicationContext;
|
||||||
|
|
||||||
|
import com.eactive.eai.common.hsm.HsmCryptoService;
|
||||||
|
import com.eactive.eai.common.security.keyderiv.DerivedKey;
|
||||||
|
import com.eactive.eai.common.util.ApplicationContextProvider;
|
||||||
|
import com.eactive.eai.custom.security.keyderiv.strategy.HsmContextSha256KeyDerivationStrategy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HsmContextSha256KeyDerivationStrategy 단위 테스트
|
||||||
|
*/
|
||||||
|
@TestMethodOrder(MethodOrderer.DisplayName.class)
|
||||||
|
class HsmContextSha256KeyDerivationStrategyTest {
|
||||||
|
|
||||||
|
private static final String HSM_KEY_ALIAS = "MASTER_KEY_AES";
|
||||||
|
private static final byte[] MASTER_KEY = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
private static GenericApplicationContext ctx;
|
||||||
|
private static HsmCryptoService mockHsmCryptoService;
|
||||||
|
private static HsmContextSha256KeyDerivationStrategy strategy;
|
||||||
|
|
||||||
|
private Map<String, String> params;
|
||||||
|
private Map<String, String> runtimeContext;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void setUpClass() throws Exception {
|
||||||
|
mockHsmCryptoService = mock(HsmCryptoService.class);
|
||||||
|
SecretKey mockSecretKey = new SecretKeySpec(MASTER_KEY, "AES");
|
||||||
|
when(mockHsmCryptoService.getSecretKey(HSM_KEY_ALIAS)).thenReturn(mockSecretKey);
|
||||||
|
|
||||||
|
ctx = new GenericApplicationContext();
|
||||||
|
ctx.getBeanFactory().registerSingleton("hsmCryptoService", mockHsmCryptoService);
|
||||||
|
ctx.registerBeanDefinition("applicationContextProvider",
|
||||||
|
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
|
||||||
|
.getBeanDefinition());
|
||||||
|
ctx.refresh();
|
||||||
|
|
||||||
|
strategy = new HsmContextSha256KeyDerivationStrategy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUpParams() {
|
||||||
|
params = new HashMap<>();
|
||||||
|
params.put("hsmKeyAlias", HSM_KEY_ALIAS);
|
||||||
|
params.put("contextKey", "X-Api-Group-Seq");
|
||||||
|
|
||||||
|
runtimeContext = new HashMap<>();
|
||||||
|
runtimeContext.put("X-Api-Group-Seq", "GROUP_001");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. deriveKey
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-1. 정상 파라미터 — DerivedKey 반환, 길이 32바이트(SHA-256 고정 출력)")
|
||||||
|
void testDeriveKey_validParams_returns32ByteKey() throws Exception {
|
||||||
|
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
|
||||||
|
|
||||||
|
assertNotNull(dk);
|
||||||
|
assertNotNull(dk.getEncKey());
|
||||||
|
assertNotNull(dk.getDecKey());
|
||||||
|
assertEquals(32, dk.getEncKey().length, "SHA-256 출력은 항상 32바이트여야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-2. SHA-256(masterKey || contextValue) 계산 결과 검증")
|
||||||
|
void testDeriveKey_sha256ResultIsCorrect() throws Exception {
|
||||||
|
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
|
||||||
|
|
||||||
|
byte[] contextBytes = "GROUP_001".getBytes(StandardCharsets.UTF_8);
|
||||||
|
byte[] combined = new byte[MASTER_KEY.length + contextBytes.length];
|
||||||
|
System.arraycopy(MASTER_KEY, 0, combined, 0, MASTER_KEY.length);
|
||||||
|
System.arraycopy(contextBytes, 0, combined, MASTER_KEY.length, contextBytes.length);
|
||||||
|
byte[] expected = MessageDigest.getInstance("SHA-256").digest(combined);
|
||||||
|
|
||||||
|
assertArrayEquals(expected, dk.getEncKey(), "SHA-256 해싱 결과가 일치해야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-3. 대칭 알고리즘 — encKey == decKey")
|
||||||
|
void testDeriveKey_encKeyEqualsDecKey() throws Exception {
|
||||||
|
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
|
||||||
|
|
||||||
|
assertArrayEquals(dk.getEncKey(), dk.getDecKey(), "대칭 방식이므로 encKey와 decKey가 동일해야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-4. runtimeContext 값 없음 — 빈 문자열로 처리")
|
||||||
|
void testDeriveKey_missingContextValue_emptyStringUsed() throws Exception {
|
||||||
|
runtimeContext.remove("X-Api-Group-Seq");
|
||||||
|
DerivedKey dk = strategy.deriveKey(params, runtimeContext);
|
||||||
|
|
||||||
|
byte[] expected = MessageDigest.getInstance("SHA-256").digest(MASTER_KEY);
|
||||||
|
|
||||||
|
assertArrayEquals(expected, dk.getEncKey(), "컨텍스트 값 없으면 masterKey만 해싱해야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-5. 다른 runtimeContext 값 — 다른 DerivedKey 생성")
|
||||||
|
void testDeriveKey_differentContextValue_differentKey() throws Exception {
|
||||||
|
DerivedKey dk1 = strategy.deriveKey(params, runtimeContext);
|
||||||
|
|
||||||
|
runtimeContext.put("X-Api-Group-Seq", "GROUP_002");
|
||||||
|
DerivedKey dk2 = strategy.deriveKey(params, runtimeContext);
|
||||||
|
|
||||||
|
assertFalse(java.util.Arrays.equals(dk1.getEncKey(), dk2.getEncKey()),
|
||||||
|
"컨텍스트 값이 다르면 도출된 키도 달라야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-6. 필수 파라미터 누락(hsmKeyAlias) — IllegalArgumentException")
|
||||||
|
void testDeriveKey_missingHsmKeyAlias_throwsException() {
|
||||||
|
params.remove("hsmKeyAlias");
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> strategy.deriveKey(params, runtimeContext),
|
||||||
|
"hsmKeyAlias 누락 시 IllegalArgumentException이 발생해야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("1-7. 필수 파라미터 누락(contextKey) — IllegalArgumentException")
|
||||||
|
void testDeriveKey_missingContextKey_throwsException() {
|
||||||
|
params.remove("contextKey");
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> strategy.deriveKey(params, runtimeContext),
|
||||||
|
"contextKey 누락 시 IllegalArgumentException이 발생해야 한다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. buildCacheKey
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-1. buildCacheKey — cryptoName:contextValue 형식")
|
||||||
|
void testBuildCacheKey_format() {
|
||||||
|
String cacheKey = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext);
|
||||||
|
|
||||||
|
assertEquals("CRYPTO_A:GROUP_001", cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-2. buildCacheKey — contextKey 값 없으면 빈 문자열")
|
||||||
|
void testBuildCacheKey_missingContextValue_emptyString() {
|
||||||
|
runtimeContext.remove("X-Api-Group-Seq");
|
||||||
|
String cacheKey = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext);
|
||||||
|
|
||||||
|
assertEquals("CRYPTO_A:", cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("2-3. buildCacheKey — 다른 cryptoName은 다른 캐시 키")
|
||||||
|
void testBuildCacheKey_differentCryptoName_differentKey() {
|
||||||
|
String key1 = strategy.buildCacheKey("CRYPTO_A", params, runtimeContext);
|
||||||
|
String key2 = strategy.buildCacheKey("CRYPTO_B", params, runtimeContext);
|
||||||
|
|
||||||
|
assertNotEquals(key1, key2);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user