feat: InCryptoFilter / OutCryptoFilter 추가 및 암호화 필터 계층 재구성

- AbstractCryptoFilter (신규, http.filter 패키지): 공통 암복호화 로직 및 상수 분리
- InCryptoFilter (신규, dynamic.filter): HttpAdapterFilter 구현 - 수신 요청 복호화, 응답 암호화
- OutCryptoFilter (신규, client.impl.filter): HttpClientAdapterFilter 구현 - 송신 요청 암호화, 응답 복호화
- BaseCryptoFilter, CryptoFilter 제거 → InCryptoFilter로 통합
- CryptoFilterHsmIntegrationTest: 새 클래스명으로 업데이트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curry772
2026-05-14 11:26:36 +09:00
parent 8db66f8bef
commit 0b68b8166f
7 changed files with 509 additions and 169 deletions
@@ -0,0 +1,87 @@
package com.eactive.eai.adapter.http.client.impl.filter;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.adapter.http.filter.AbstractCryptoFilter;
/**
* HTTP 클라이언트 어댑터용 암복호화 필터 (송신 방향).
*
* doPreFilter : 외부 시스템으로 보내는 요청 암호화 (CRYPTO_ENC_* 프로퍼티 기반)
* doPostFilter : 외부 시스템에서 받은 응답 복호화 (CRYPTO_DEC_* 프로퍼티 기반)
*
* DYNAMIC 키 컨텍스트가 필요한 경우 {@link #buildRuntimeContext}를 오버라이드한다.
* STATIC 키 모듈을 사용하는 경우 빈 Map이 기본값이므로 오버라이드 불필요.
*
* 프로퍼티 설명은 {@link AbstractCryptoFilter} 참조.
* AAD 관련 추가 프로퍼티:
* CRYPTO_AAD_PROP tempProp에서 AAD 값을 조회할 키 이름.
* 미설정 또는 해당 키 없으면 aad=null → IV를 AAD 대체값으로 사용.
*/
public class OutCryptoFilter extends AbstractCryptoFilter implements HttpClientAdapterFilter {
public static final String PROP_AAD_PROP = "CRYPTO_AAD_PROP";
/**
* DYNAMIC 키 도출용 컨텍스트를 구성한다.
* STATIC 키 모듈이면 빈 Map({@code new HashMap<>()})을 반환한다.
* 서브클래스에서 오버라이드하여 tempProp으로부터 값을 추출할 수 있다.
*/
protected Map<String, String> buildRuntimeContext(Properties prop, Properties tempProp) {
return new HashMap<>();
}
/**
* CRYPTO_AAD_PROP 프로퍼티에 지정된 키로 tempProp에서 AAD 값을 조회한다.
* 미설정 또는 값 없으면 null을 반환하여 GCM 기본 동작(IV를 AAD 대체값으로 사용)을 따른다.
*/
protected byte[] buildAad(Properties prop, Properties tempProp) {
String propKey = prop.getProperty(PROP_AAD_PROP);
if (StringUtils.isBlank(propKey) || tempProp == null) {
return null;
}
String value = tempProp.getProperty(propKey);
return StringUtils.isBlank(value) ? null : value.getBytes(StandardCharsets.UTF_8);
}
@Override
public Object doPreFilter(String adptGrpName, String adptName, Properties prop, Object message,
Properties tempProp) throws Exception {
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = getProp(prop, PROP_SCOPE, SCOPE_FIELD).toUpperCase();
String body = toBodyString(message);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, tempProp);
byte[] aad = buildAad(prop, tempProp);
if (SCOPE_FIELD.equals(scope)) {
return encryptField(body, moduleName, runtimeCtx, aad,
getProp(prop, PROP_ENC_FROM_PATH, PATH_ROOT),
getProp(prop, PROP_ENC_TO_PATH, PATH_ENCDATA));
}
return encryptBody(body, moduleName, runtimeCtx, aad);
}
@Override
public Object doPostFilter(String adptGrpName, String adptName, Properties prop, Object message,
Properties tempProp) throws Exception {
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = getProp(prop, PROP_SCOPE, SCOPE_FIELD).toUpperCase();
String body = toBodyString(message);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, tempProp);
byte[] aad = buildAad(prop, tempProp);
if (SCOPE_FIELD.equals(scope)) {
return decryptField(body, moduleName, runtimeCtx, aad,
getProp(prop, PROP_DEC_FROM_PATH, PATH_ENCDATA),
getProp(prop, PROP_DEC_TO_PATH, PATH_ROOT));
}
return decryptBody(body, moduleName, runtimeCtx, aad);
}
}
@@ -1,21 +0,0 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
/**
* HTTP 어댑터 요청/응답 암복호화 필터 기반 클래스.
*
*/
public class BaseCryptoFilter extends CryptoFilter {
/**
* 빈 Runtime Context를 생성한다.
*/
protected Map<String, String> buildRuntimeContext(Properties prop, HttpServletRequest request) {
return new HashMap<>();
}
}
@@ -0,0 +1,85 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.adapter.http.filter.AbstractCryptoFilter;
/**
* HTTP 서버 어댑터용 암복호화 필터 (수신 방향).
*
* doPreFilter : 수신 요청 복호화 (CRYPTO_DEC_* 프로퍼티 기반)
* doPostFilter : 송신 응답 암호화 (CRYPTO_ENC_* 프로퍼티 기반)
*
* DYNAMIC 키 컨텍스트가 필요한 경우 {@link #buildRuntimeContext}를 오버라이드한다.
* STATIC 키 모듈을 사용하는 경우 빈 Map이 기본값이므로 오버라이드 불필요.
*
* 프로퍼티 설명은 {@link AbstractCryptoFilter} 참조.
*/
public class InCryptoFilter extends AbstractCryptoFilter implements HttpAdapterFilter {
/**
* DYNAMIC 키 도출용 컨텍스트를 구성한다.
* STATIC 키 모듈이면 빈 Map({@code new HashMap<>()})을 반환한다.
* 서브클래스에서 오버라이드하여 HttpServletRequest로부터 값을 추출할 수 있다.
*/
protected Map<String, String> buildRuntimeContext(Properties prop, HttpServletRequest request) {
return new HashMap<>();
}
/**
* CRYPTO_AAD_HEADER 프로퍼티에 지정된 헤더값을 UTF-8 바이트로 반환한다.
* 미설정 또는 헤더값 없으면 null을 반환하여 GCM 기본 동작(IV를 AAD 대체값으로 사용)을 따른다.
*/
protected byte[] buildAad(Properties prop, HttpServletRequest request) {
String headerName = prop.getProperty(PROP_AAD_HEADER);
if (StringUtils.isBlank(headerName) || request == null) {
return null;
}
String headerValue = request.getHeader(headerName);
return StringUtils.isBlank(headerValue) ? null : headerValue.getBytes(StandardCharsets.UTF_8);
}
@Override
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = getProp(prop, PROP_SCOPE, SCOPE_FIELD).toUpperCase();
String body = toBodyString(message);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, request);
byte[] aad = buildAad(prop, request);
if (SCOPE_FIELD.equals(scope)) {
return decryptField(body, moduleName, runtimeCtx, aad,
getProp(prop, PROP_DEC_FROM_PATH, PATH_ENCDATA),
getProp(prop, PROP_DEC_TO_PATH, PATH_ROOT));
}
return decryptBody(body, moduleName, runtimeCtx, aad);
}
@Override
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = getProp(prop, PROP_SCOPE, SCOPE_FIELD).toUpperCase();
String body = toBodyString(resultMessage);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, request);
byte[] aad = buildAad(prop, request);
if (SCOPE_FIELD.equals(scope)) {
return encryptField(body, moduleName, runtimeCtx, aad,
getProp(prop, PROP_ENC_FROM_PATH, PATH_ROOT),
getProp(prop, PROP_ENC_TO_PATH, PATH_ENCDATA));
}
return encryptBody(body, moduleName, runtimeCtx, aad);
}
}
@@ -1,13 +1,10 @@
package com.eactive.eai.adapter.http.dynamic.filter;
package com.eactive.eai.adapter.http.filter;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.security.CryptoModuleService;
@@ -15,30 +12,28 @@ import com.eactive.eai.common.util.Logger;
import com.eactive.eai.util.JsonPathUtil;
/**
* HTTP 어댑터 요청/응답 암복호화 필터 기반 클래스.
* InCryptoFilter / OutCryptoFilter 공통 기반 클래스.
*
* 서브클래스는 {@link #buildRuntimeContext} 구현하여
* DYNAMIC 도출에 필요한 컨텍스트 맵을 제공한다.
* STATIC 모듈을 사용하는 경우 Map을 반환하면 된다.
* (CryptoModuleManager가 KEY_SOURCE_TYPE=STATIC이면 runtimeContext를 무시하고 고정 키를 사용한다.)
* BODY/FIELD 범위 암복호화 로직, 프로퍼티 상수, 유틸리티 메서드를 제공한다.
* HTTP 서버 어댑터용은 {@link InCryptoFilter}, 클라이언트 어댑터용은 {@link OutCryptoFilter} 참조.
*
* 어댑터 프로퍼티 :
* 프로퍼티 :
* CRYPTO_MODULE_NAME 필수. DB에 등록된 암호화 모듈명.
* CRYPTO_SCOPE BODY | FIELD (기본 FIELD)
* CRYPTO_SCOPE BODY | FIELD (기본: FIELD)
* BODY : 메시지 전체를 암복호화 (Base64 인코딩/디코딩)
* FIELD : 특정 JSON 경로의 값만 암복호화
*
* FIELD 범위 전용:
* CRYPTO_DEC_FROM_PATH 복호화 대상 JSON 경로 (: /encrypted_data)
* CRYPTO_DEC_FROM_PATH 복호화 대상 JSON 경로 (기본값: /encrypted_data)
* CRYPTO_DEC_TO_PATH 복호화 결과 반영 경로. "/" = 전체 body 교체.
* CRYPTO_ENC_FROM_PATH 암호화 대상 JSON 경로. "/" = 전체 body 암호화.
* CRYPTO_ENC_TO_PATH 암호화 결과(Base64) 넣을 JSON 경로 (: /encrypted_data)
* CRYPTO_ENC_TO_PATH 암호화 결과(Base64) 넣을 JSON 경로 (기본값: /encrypted_data)
*
* GCM AAD 전용 (선택):
* CRYPTO_AAD_HEADER AAD로 사용할 HTTP 요청 헤더명 (: X-Api-Request-Id).
* CRYPTO_AAD_HEADER AAD로 사용할 HTTP 요청 헤더명 (InCryptoFilter 전용).
* 미설정 또는 헤더값 없으면 aad=null IV를 AAD 대체값으로 사용.
*/
public abstract class CryptoFilter implements HttpAdapterFilter {
public abstract class AbstractCryptoFilter {
protected static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
@@ -50,70 +45,17 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
public static final String PROP_ENC_TO_PATH = "CRYPTO_ENC_TO_PATH";
public static final String PROP_AAD_HEADER = "CRYPTO_AAD_HEADER";
public static final String SCOPE_BODY = "BODY";
public static final String SCOPE_FIELD = "FIELD";
public static final String PATH_ROOT = "/";
public static final String PATH_ENCDATA = "/encrypted_data";
// ------------------------------------------------------------------
// 추상 메서드
// ------------------------------------------------------------------
/**
* DYNAMIC 도출에 필요한 runtimeContext 맵을 구성한다.
* STATIC 모듈이면 Map({@code new HashMap<>()}) 반환한다.
*/
protected abstract Map<String, String> buildRuntimeContext(Properties prop, HttpServletRequest request);
// ------------------------------------------------------------------
// doPreFilter: 요청 복호화
// ------------------------------------------------------------------
@Override
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = getProp(prop, PROP_SCOPE, SCOPE_FIELD).toUpperCase();
String body = toBodyString(message);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, request);
byte[] aad = buildAad(prop, request);
if (SCOPE_FIELD.equals(scope)) {
return decryptField(body, moduleName, runtimeCtx, aad,
getProp(prop, PROP_DEC_FROM_PATH, PATH_ENCDATA),
getProp(prop, PROP_DEC_TO_PATH, PATH_ROOT));
}
return decryptBody(body, moduleName, runtimeCtx, aad);
}
// ------------------------------------------------------------------
// doPostFilter: 응답 암호화
// ------------------------------------------------------------------
@Override
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = getProp(prop, PROP_SCOPE, SCOPE_FIELD).toUpperCase();
String body = toBodyString(resultMessage);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, request);
byte[] aad = buildAad(prop, request);
if (SCOPE_FIELD.equals(scope)) {
return encryptField(body, moduleName, runtimeCtx, aad,
getProp(prop, PROP_ENC_FROM_PATH, PATH_ROOT),
getProp(prop, PROP_ENC_TO_PATH, PATH_ENCDATA));
}
return encryptBody(body, moduleName, runtimeCtx, aad);
}
public static final String SCOPE_BODY = "BODY";
public static final String SCOPE_FIELD = "FIELD";
public static final String PATH_ROOT = "/";
public static final String PATH_ENCDATA = "/encrypted_data";
// ------------------------------------------------------------------
// 복호화 구현
// ------------------------------------------------------------------
private String decryptBody(String body, String moduleName, Map<String, String> runtimeCtx, byte[] aad) throws Exception {
protected String decryptBody(String body, String moduleName, Map<String, String> runtimeCtx,
byte[] aad) throws Exception {
byte[] cipherBytes = Base64.getDecoder().decode(body.trim());
byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, aad, cipherBytes);
return new String(plainBytes, StandardCharsets.UTF_8);
@@ -123,19 +65,16 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
* fromPath의 Base64 값을 복호화하여 toPath에 반영.
* toPath = "/" 복호화된 텍스트로 body 전체 교체.
*/
private String decryptField(String body, String moduleName, Map<String, String> runtimeCtx, byte[] aad,
String fromPath, String toPath) throws Exception {
protected String decryptField(String body, String moduleName, Map<String, String> runtimeCtx,
byte[] aad, String fromPath, String toPath) throws Exception {
String encBase64 = JsonPathUtil.getValueAtPath(body, fromPath);
if (StringUtils.isBlank(encBase64)) {
logger.warn("CryptoFilter] 복호화 대상 필드 없음: path=" + fromPath);
return body;
}
byte[] cipherBytes = Base64.getDecoder().decode(encBase64.trim());
byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, aad, cipherBytes);
String plainText = new String(plainBytes, StandardCharsets.UTF_8);
if (PATH_ROOT.equals(toPath)) {
return plainText;
}
@@ -146,7 +85,8 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
// 암호화 구현
// ------------------------------------------------------------------
private String encryptBody(String body, String moduleName, Map<String, String> runtimeCtx, byte[] aad) throws Exception {
protected String encryptBody(String body, String moduleName, Map<String, String> runtimeCtx,
byte[] aad) throws Exception {
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, aad,
body.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(cipherBytes);
@@ -156,9 +96,8 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
* fromPath 값을 암호화하여 toPath(Base64) 반영.
* fromPath = "/" body 전체를 암호화하여 toPath 필드명으로 래핑.
*/
private String encryptField(String body, String moduleName, Map<String, String> runtimeCtx, byte[] aad,
String fromPath, String toPath) throws Exception {
protected String encryptField(String body, String moduleName, Map<String, String> runtimeCtx,
byte[] aad, String fromPath, String toPath) throws Exception {
String plainText;
if (PATH_ROOT.equals(fromPath)) {
plainText = body;
@@ -169,11 +108,9 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
return body;
}
}
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, aad,
plainText.getBytes(StandardCharsets.UTF_8));
String encBase64 = Base64.getEncoder().encodeToString(cipherBytes);
if (PATH_ROOT.equals(fromPath)) {
String fieldName = toPath.replaceFirst("^/", "");
return "{\"" + fieldName + "\":\"" + encBase64 + "\"}";
@@ -187,12 +124,6 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
/**
* 필터로 전달되는 message 객체를 JSON 문자열로 변환한다.
* <ul>
* <li>{@code String} 그대로 반환</li>
* <li>{@code byte[]} UTF-8 디코딩</li>
* <li>{@code JSONObject} / 기타 {@code toString()} 호출
* (json-simple JSONObject는 toString() toJSONString() 위임함)</li>
* </ul>
*/
protected String toBodyString(Object message) {
if (message instanceof String) {
@@ -204,19 +135,6 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
return message.toString();
}
/**
* crypto.aad.header 프로퍼티에 지정된 헤더값을 UTF-8 바이트로 반환한다.
* 프로퍼티 미설정 또는 헤더값 없으면 null을 반환하여 GCM 기본 동작(IV를 AAD 대체값으로 사용) 따른다.
*/
protected byte[] buildAad(Properties prop, HttpServletRequest request) {
String headerName = prop.getProperty(PROP_AAD_HEADER);
if (StringUtils.isBlank(headerName) || request == null) {
return null;
}
String headerValue = request.getHeader(headerName);
return StringUtils.isBlank(headerValue) ? null : headerValue.getBytes(StandardCharsets.UTF_8);
}
protected String required(Properties prop, String key) {
String v = prop.getProperty(key);
if (StringUtils.isBlank(v)) {
@@ -225,7 +143,7 @@ public abstract class CryptoFilter implements HttpAdapterFilter {
return v;
}
private String getProp(Properties prop, String key, String defaultVal) {
protected String getProp(Properties prop, String key, String defaultVal) {
String v = prop.getProperty(key);
return StringUtils.isBlank(v) ? defaultVal : v;
}