feat: HTTP 어댑터 암복호화 필터 기반 클래스 추가 및 ReloadCryptoModuleCommand 개선

- CryptoFilter (abstract): BODY/FIELD scope 암복호화, buildRuntimeContext 추상화
  - String/byte[]/JSONObject 타입 message 처리 (toBodyString)
  - STATIC/DYNAMIC 키 모듈 공통 지원 (CryptoModuleManager가 내부 분기)
- ReloadCryptoModuleCommand: 로깅 추가, 성공 시 "success" 반환, 에러코드 정의

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curry772
2026-05-07 15:03:47 +09:00
parent bf1135f9ad
commit 580686886c
2 changed files with 232 additions and 1 deletions
@@ -0,0 +1,222 @@
package com.eactive.eai.adapter.http.dynamic.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;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.util.JsonPathUtil;
/**
* HTTP 어댑터 요청/응답 암복호화 필터 기반 클래스.
*
* 서브클래스는 {@link #buildRuntimeContext}를 구현하여
* DYNAMIC 키 도출에 필요한 컨텍스트 맵을 제공한다.
* STATIC 키 모듈을 사용하는 경우 빈 Map을 반환하면 된다.
* (CryptoModuleManager가 KEY_SOURCE_TYPE=STATIC이면 runtimeContext를 무시하고 고정 키를 사용한다.)
*
* 어댑터 프로퍼티 키:
* crypto.module.name 필수. DB에 등록된 암호화 모듈명.
* crypto.scope BODY | FIELD (기본 BODY)
* BODY : 메시지 전체를 암복호화 (Base64 인코딩/디코딩)
* FIELD : 특정 JSON 경로의 값만 암복호화
* crypto.dec.enabled Y | N (기본 Y). doPreFilter(요청) 복호화 수행 여부.
* crypto.enc.enabled Y | N (기본 N). doPostFilter(응답) 암호화 수행 여부.
*
* FIELD 범위 전용:
* crypto.dec.from.path 복호화 대상 JSON 경로 (예: /encryptedData)
* crypto.dec.to.path 복호화 결과 반영 경로. "/" = 전체 body 교체.
* crypto.enc.from.path 암호화 대상 JSON 경로. "/" = 전체 body 암호화.
* crypto.enc.to.path 암호화 결과(Base64)를 넣을 JSON 경로 (예: /encryptedData)
*/
public abstract class CryptoFilter implements HttpAdapterFilter {
protected static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
public static final String PROP_MODULE_NAME = "crypto.module.name";
public static final String PROP_SCOPE = "crypto.scope";
public static final String PROP_DEC_ENABLED = "crypto.dec.enabled";
public static final String PROP_ENC_ENABLED = "crypto.enc.enabled";
public static final String PROP_DEC_FROM_PATH = "crypto.dec.from.path";
public static final String PROP_DEC_TO_PATH = "crypto.dec.to.path";
public static final String PROP_ENC_FROM_PATH = "crypto.enc.from.path";
public static final String PROP_ENC_TO_PATH = "crypto.enc.to.path";
protected static final String SCOPE_BODY = "BODY";
protected static final String SCOPE_FIELD = "FIELD";
protected static final String PATH_ROOT = "/";
// ------------------------------------------------------------------
// 추상 메서드
// ------------------------------------------------------------------
/**
* 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 {
if (!isEnabled(prop, PROP_DEC_ENABLED, "Y")) {
return message;
}
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = prop.getProperty(PROP_SCOPE, SCOPE_BODY).toUpperCase();
String body = toBodyString(message);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, request);
if (SCOPE_FIELD.equals(scope)) {
return decryptField(body, moduleName, runtimeCtx,
required(prop, PROP_DEC_FROM_PATH),
prop.getProperty(PROP_DEC_TO_PATH, PATH_ROOT));
}
return decryptBody(body, moduleName, runtimeCtx);
}
// ------------------------------------------------------------------
// doPostFilter: 응답 암호화
// ------------------------------------------------------------------
@Override
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
if (!isEnabled(prop, PROP_ENC_ENABLED, "N")) {
return resultMessage;
}
String moduleName = required(prop, PROP_MODULE_NAME);
String scope = prop.getProperty(PROP_SCOPE, SCOPE_BODY).toUpperCase();
String body = toBodyString(resultMessage);
Map<String, String> runtimeCtx = buildRuntimeContext(prop, request);
if (SCOPE_FIELD.equals(scope)) {
return encryptField(body, moduleName, runtimeCtx,
prop.getProperty(PROP_ENC_FROM_PATH, PATH_ROOT),
required(prop, PROP_ENC_TO_PATH));
}
return encryptBody(body, moduleName, runtimeCtx);
}
// ------------------------------------------------------------------
// 복호화 구현
// ------------------------------------------------------------------
private String decryptBody(String body, String moduleName, Map<String, String> runtimeCtx) throws Exception {
byte[] cipherBytes = Base64.getDecoder().decode(body.trim());
byte[] plainBytes = CryptoModuleService.getInstance().decrypt(moduleName, runtimeCtx, cipherBytes);
return new String(plainBytes, StandardCharsets.UTF_8);
}
/**
* fromPath의 Base64 값을 복호화하여 toPath에 반영.
* toPath = "/" → 복호화된 텍스트로 body 전체 교체.
*/
private String decryptField(String body, String moduleName, Map<String, String> runtimeCtx,
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, cipherBytes);
String plainText = new String(plainBytes, StandardCharsets.UTF_8);
if (PATH_ROOT.equals(toPath)) {
return plainText;
}
return JsonPathUtil.setValueAtPath(body, toPath, plainText, false);
}
// ------------------------------------------------------------------
// 암호화 구현
// ------------------------------------------------------------------
private String encryptBody(String body, String moduleName, Map<String, String> runtimeCtx) throws Exception {
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx,
body.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(cipherBytes);
}
/**
* fromPath 값을 암호화하여 toPath(Base64)에 반영.
* fromPath = "/" → body 전체를 암호화하여 toPath 필드명으로 래핑.
*/
private String encryptField(String body, String moduleName, Map<String, String> runtimeCtx,
String fromPath, String toPath) throws Exception {
String plainText;
if (PATH_ROOT.equals(fromPath)) {
plainText = body;
} else {
plainText = JsonPathUtil.getValueAtPath(body, fromPath);
if (StringUtils.isBlank(plainText)) {
logger.warn("CryptoFilter] 암호화 대상 필드 없음: path=" + fromPath);
return body;
}
}
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx,
plainText.getBytes(StandardCharsets.UTF_8));
String encBase64 = Base64.getEncoder().encodeToString(cipherBytes);
if (PATH_ROOT.equals(fromPath)) {
String fieldName = toPath.replaceFirst("^/", "");
return "{\"" + fieldName + "\":\"" + encBase64 + "\"}";
}
return JsonPathUtil.setValueAtPath(body, toPath, encBase64, false);
}
// ------------------------------------------------------------------
// 유틸
// ------------------------------------------------------------------
/**
* 필터로 전달되는 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) {
return (String) message;
}
if (message instanceof byte[]) {
return new String((byte[]) message, StandardCharsets.UTF_8);
}
return message.toString();
}
private boolean isEnabled(Properties prop, String key, String defaultVal) {
return !"N".equalsIgnoreCase(prop.getProperty(key, defaultVal));
}
protected String required(Properties prop, String key) {
String v = prop.getProperty(key);
if (StringUtils.isBlank(v)) {
throw new IllegalArgumentException("CryptoFilter 필수 프로퍼티 누락: " + key);
}
return v;
}
}
@@ -3,16 +3,25 @@ package com.eactive.eai.agent.security;
import com.eactive.eai.agent.command.Command;
import com.eactive.eai.agent.command.CommandException;
import com.eactive.eai.common.security.CryptoModuleManager;
import com.eactive.eai.common.util.Logger;
public class ReloadCryptoModuleCommand extends Command {
private static final long serialVersionUID = 1032982004418318100L;
@Override
public Object execute() throws CommandException {
Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
try {
String cryptoId = (String) args;
CryptoModuleManager.getInstance().reload(cryptoId);
return null;
return "success";
} catch (Exception e) {
String rspErrorCode = "RECEAIMCM002";
String msg = this.makeException(rspErrorCode, e);
if (logger.isError()) {
logger.error(msg, e);
}
throw new CommandException(e.getMessage());
}
}