Compare commits

...

77 Commits

Author SHA1 Message Date
curry772 3c6c8d181d isNotBlank -> StringUtils.isNotBlank로 변경 2026-09-01 11:26:11 +09:00
curry772 2240a0d604 HSM 설정 이중화(PRIMARY/SECONDARY) 및 상태 조회 API 추가
HsmManager
- PKCS11_CONFIG_SECONDARY / PIN_SECONDARY 프로퍼티 추가.
  PIN_SECONDARY 미설정 시 PIN 재사용, PKCS11_CONFIG_SECONDARY 미설정 시
  후보가 1건이므로 기존과 동일하게 동작한다.
- 기동/재연결 모두 PRIMARY -> SECONDARY 순으로 시도해 최초 성공분을 사용한다.
  연결 성공 판정은 Provider 생성 + KeyStore.load + alias/getKey probe까지 포함하며,
  Security 등록 전에 검증하므로 실패해도 사용 중인 Provider/keyStore는 영향이 없다.
- 주기적 재로드에서 SECONDARY로 동작 중이면 PRIMARY 재연결을 먼저 시도해
  PRIMARY 복구 시 자동 복귀한다. PRIMARY 정상 동작 중에는 기존 keyStore 인스턴스에
  다시 load() 하여 PKCS11 세션 누적 방지 로직을 유지한다.
- PropertyChangeListener로 등록하여 HSM 프로퍼티 변경 시 다음 스케줄을 기다리지 않고
  즉시 재적용한다. HSM 통신이 호출 스레드를 막지 않도록 스케줄러 스레드에 위임하며,
  RELOAD_INTERVAL_MINUTES 변경 시 스케줄을 재등록한다.
- activeConfigName, failoverCount, lastReloadResult 등 모니터링 상태값을 노출한다.

HsmCryptoService
- 캐시 진단용 getCachedKeyInfos() / getCacheTtlMs() 추가. 키 원본은 노출하지 않는다.

/manage/hsm
- status(연결 설정/프로퍼티/alias/캐시 키), properties, keystore/aliases, cache/keys 조회와
  reload, cache/clear 실행 API 추가. PIN 류 프로퍼티는 마스킹한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ZMr6y9kc6Y6TWN3x4gpYg
2026-09-01 10:08:02 +09:00
curry772 78606514ad newNumberSafeMapper 에 선행 0 숫자 허용 옵션 추가
상대 시스템이 0 패딩된 코드값을 따옴표 없이(=JSON 숫자로) 보내면 표준 파서가
"Invalid numeric value: Leading zeroes not allowed" 로 전문 전체를 거부한다.
전문을 못 읽고 실패하는 것보다 값을 받아들이는 쪽이 낫다고 판단해
ALLOW_LEADING_ZEROS_FOR_NUMBERS 를 켠다.

newNumberSafeMapper() 한 곳에 넣어 이를 쓰는 전 경로(JsonReader 표준전문,
JacksonUtil.OBJECT_MAPPER 를 타는 어댑터/필터)를 함께 덮는다.
WRITE_BIGDECIMAL_AS_PLAIN 을 여기 넣었던 것과 같은 이유다.

주의: 이 옵션은 00001 을 숫자 1 로 만든다(선행 0 미보존).
항목이 NUMBER/LL_NUMBER 로 선언돼 있으면 StandardItem.toTypeValue() 가
어차피 선행 0 을 깎으므로 결과가 같고, STRING/ZZ_STRING 선언이면 자릿수가
사라진다. 후자는 상대에게 따옴표를 붙여 보내달라고 요청하는 것이 정답이다.

Jackson 2.12.7 실측: 선행 0 파싱 8종, 숫자 정밀도 왕복 5종, 제어문자 정규화
조합, 과허용 여부(+1 / .5 / 무따옴표키 / 홑따옴표는 여전히 거부) 19건 PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Phh6MSGvgHM3rPgGUU3dzR
2026-08-28 16:41:21 +09:00
curry772 6dee34e085 제어문자 섞인 전문 대응: 수신은 정규화, 송신은 정제
수신측 - JacksonUtil.escapeControlChars() 적용 범위 확대
  JsonReader 에만 걸려 있어 TemplateCodeConvertAdapterErrorMsgHandler 등
  직접 readTree() 하는 경로가 "Illegal unquoted character ((CTRL-CHAR, code 10))"
  로 실패했다. JacksonUtil.readTree() 공용 진입점과 payload 를 파싱하는
  핸들러/필터에 적용한다.

송신측 - MessageUtil.stripControlChars() 신규
  TemplateAdapterErrorMsgHandler.render() 가 Matcher.quoteReplacement() 로만
  치환하고 있었다. 이것은 정규식 치환용이라 JSON/XML 이스케이프가 아니어서,
  값에 개행이 있으면 깨진 JSON 을 만들어 응답으로 내보냈다. 실제로 위 파싱
  오류의 원문도 상대 전문이 아니라 이 렌더 결과였다.

  템플릿이 JSON/XML 어느 쪽인지 알 수 없어 포맷별 이스케이프가 불가능하므로
  제어문자를 걸러낸다. 탭/개행/CR 은 공백으로, 나머지 제어문자와 DEL 은 제거.
  render() 의 스칼라 치환과 renderRow() 의 행 필드 치환에만 적용해
  템플릿 서식(들여쓰기/개행)은 보존한다.

검증: gradlew compileJava BUILD SUCCESSFUL.
      databind 2.12.7 런타임에서 0x00~0x1F 전수 + 로그 재현 케이스 통과.
2026-08-28 14:28:01 +09:00
curry772 f1b67df867 JsonReader: 이스케이프되지 않은 제어문자를 정규화 후 파싱
상대 시스템이 개행을 이스케이프하지 않고 그대로 보내는 경우
"Illegal unquoted character ((CTRL-CHAR, code 10))" 로 파싱이 실패했다.
파서 옵션(ALLOW_UNESCAPED_CONTROL_CHARS)으로 푸는 대신, 입력을 표준 JSON 으로
정규화하는 JacksonUtil.escapeControlChars() 를 추가하고 파서는 strict 로 유지한다.

문자열 리터럴 "안" 의 제어문자만 바꾼다. JSON 은 토큰 사이의 개행/탭을 공백으로
허용하므로 구조적 공백까지 치환하면 pretty-print 된 JSON 이 오히려 깨진다.
2026-08-28 13:17:34 +09:00
curry772 7677f23767 모니터링 타임아웃 분류 및 응답에러코드 널 안전성 수정
- EAIServiceMonitor: timeOutCodes.indexOf 판정을 '> 0' -> '>= 0' 으로 수정
  (목록 첫 번째 코드가 타임아웃이 아닌 시스템에러로 분류되던 문제)
- EAIServiceMonitor: getLogRspErrCd() null 대비 defaultString 적용, 리터럴 비교 순서 변경
- EAILogDAO, EAIFileLogger: 응답에러코드 substring(1,2) 를 널 안전한 StringUtils.substring 으로 교체
2026-08-28 10:43:54 +09:00
curry772 dd7c8a29bf newNumberSafeMapper 출력측 옵션 추가 및 ArrayNode.set 버전 호환 수정
JacksonUtil.newNumberSafeMapper() 는 USE_BIG_DECIMAL_FOR_FLOATS +
withExactBigDecimals 로 입력(파싱)측만 막고 있었다. DecimalNode 직렬화는
BigDecimal.toString() 이라 scale 이 음수이거나 adjusted exponent < -6 일 때
지수 표기가 나온다. 즉 파싱을 제대로 해도 내보낼 때 작은 소수가 깨졌다.
  0.00000012 -> 1.2E-7 , -0.0000005 -> -5E-7
WRITE_BIGDECIMAL_AS_PLAIN 을 추가해 출력측까지 보존한다.
(금액처럼 scale >= 0 인 큰 값은 원래 영향 없음. 이율/환율이 대상)

또한 JacksonUtil.setValue() 가 ArrayNode.set(int, String) 을 쓰고 있었다.
이 오버로드는 jackson-databind 2.13 부터이고, 실제 런타임은 2.12.7 이다
(2.12.7 은 set(int, JsonNode) 하나뿐). jackson-dataformat-xml:2.13.1 이
compileOnly 로 컴파일 클래스패스에만 2.13.1 을 올려 컴파일만 통과하던
상태였고, 배열 경로에 setText() 하면 운영에서 NoSuchMethodError 가 났다.
TextNode.valueOf() 로 감싸 set(int, JsonNode) 에 바인딩한다.

그 외
- TemplateCodeConvertAdapterErrorMsgHandler: 응답 JSON 왕복 지점 전환
- HttpAdapterServiceSupport, HttpClient5AdapterServiceRestAddFilter: 직렬화 지점 전환
- JsonReader: parse() 호출마다 ObjectMapper 를 새로 만들던 것을 static 싱글턴으로
  바꾸고 중복 설정 코드를 JacksonUtil 에 위임
- MessageKeyExtractor: 선언만 하고 쓰지 않는 ObjectMapper 필드 제거
- build.gradle: 미사용 jackson-dataformat-xml 제거 (common 은 Ignite 경유로 jackson 확보)

검증: gradlew compileJava BUILD SUCCESSFUL, databind 2.12.7 런타임에서
숫자 왕복 4건 + setText 배열/객체/미존재 경로 5건 전부 통과.
2026-08-28 09:26:32 +09:00
curry772 bb8a91f5f2 Merge branch 'master' of https://git.eactive.synology.me:8090/eapim/djbank/elink-online-common.git 2026-08-27 16:56:29 +09:00
curry772 0f02f4d261 Jackson Mapper 생성을 newNumberSafeMapper 이용으로 통일 2026-08-27 16:56:08 +09:00
Rinjae bc9a4caf4a generatedJavaDir 중복 등록 방지 코드 추가 - APT 재생성 오류 해결 2026-08-27 16:32:41 +09:00
curry772 82b7c779f1 feat : template 기반 AdapterErrorMessageHandler 내부오류코드별 템플릿 지정 추가
- {in 어댑터그룹명}.sys.{rspErrCd}.template 키 사용
2026-08-26 13:48:05 +09:00
curry772 65bd1ff005 거래로그의 오류 출력 부분 수정
- 수신시스템 오류응답 수신 시(RECEAIINA001) 성공건으로 변경
- 유량제어(RECEAIIRP072), 거래차단(RECEAIIRP070) 건은 오류건으로 변경
2026-08-26 11:04:33 +09:00
curry772 e9fbffa87e 거래로그의 오류 출력 부분 수정
- 오류 발생 시 AdapterErrorMessageHandler가 처리하여도 400 오류로 출력되도록 수정
- 업무 오류 발생 시 AdapterErrorMessageHandler가 처리하여도 에러 RECEAIINA001 출력
2026-08-26 09:47:02 +09:00
curry772 585a9e4520 응답 변환 시 한글 깨지는 문제
- 원인은 HTTPProcess에서 어댑터 문자셋에 따라 무조건 charset 마추어 bytes로 변경하는 문제
2026-08-18 15:36:22 +09:00
curry772 e3419e4d43 가변 url 표준전문헤더값 추출 기능 추가
- stdHeaderGroup. prefix 사용
2026-08-18 10:06:55 +09:00
curry772 f11d27e05e damo-manager.jar 배포본 내 WEB-INF/lib로 이동 2026-08-15 11:17:10 +09:00
curry772 2865c6bd03 헤더내 환경구분코드 운영 system mode 시 'P' 설정하도록 수정 2026-08-14 15:20:26 +09:00
curry772 cc016d9d81 ReflectHeaderFilter 필터 추가 2026-08-14 15:19:46 +09:00
curry772 23bdca4777 printStackTrace() 주석 처리 2026-08-13 15:07:21 +09:00
curry772 398c26537f 비동기 로그 배치 커밋 적용 2026-08-12 13:15:58 +09:00
curry772 3f75bd9b49 QueryString(parameter, formdata) 관련 Util 클래스 추가 2026-08-10 15:25:30 +09:00
curry772 51d95d8824 QueryString(parameter, formdata) 관련 Util 클래스 추가 2026-08-10 15:25:30 +09:00
curry772 5c3de18cc9 HttpAdapterServiceVirtualAccount online -> common 이동 2026-08-06 14:23:09 +09:00
curry772 a5a072fef4 Merge branch 'master' of https://git.eactive.synology.me:8090/eapim/djbank/elink-online-common.git 2026-08-06 09:30:17 +09:00
curry772 078331b3b8 In 필터 개발 : 데이터부에 상태코드 값을 응답 HTTP 상태코드에 설정하는 필터 2026-08-06 09:29:54 +09:00
Rinjae 428ab1103a damo-manager.jar 라이브러리 업데이트
- eapim-admin, eapim-online, elink-portal 공통 반영
2026-08-05 17:46:54 +09:00
curry772 4813a2ef0e 거래고유번호 맨앞에 instid 추가 2026-08-03 13:29:17 +09:00
curry772 752156b86f 거래고유번호 맨앞에 instid 추가 2026-08-03 11:38:21 +09:00
curry772 5732ddf962 checkTokenRetry 메소드 refactoring 2026-07-31 14:26:45 +09:00
curry772 5ffc48c039 REST 전송 body log의 길이를 줄이는 문제 처리 2026-07-31 13:48:39 +09:00
curry772 cbb6f33ce5 HttpClient5AdapterServiceRest.checkTokenRetry private -> protected 2026-07-31 13:48:08 +09:00
curry772 44093681b7 AdapterErrorMessageHandler 프러퍼티 그룹에 default template 기능 추가
- default.template
- default.in.template
- default.sys.template
2026-07-31 09:35:48 +09:00
curry772 a24b437f5c 수신에서 표준오류응답을 수신하였는데 Inbound 어댑터에 AdapterErrorHandler 설정하였으나, 처리 후 null를
리턴하는 경우, 처리 로직 추가
2026-07-30 14:08:25 +09:00
curry772 67dba6ca4a R(응답)거래가 없어져서 소스 원복 2026-07-30 11:25:44 +09:00
curry772 f4d7ac2fad 타발응답거래(R2) 거래시 로그 출력 및 응답 안주는 문제 처리
- R2거래이어도 동기 거래이기 때문에 응답을 전달해야 함.
- 로그도 100 ~ 400을 출력해야 함.
2026-07-30 08:57:09 +09:00
curry772 6ca653aa1e oauth token scope 검증 중 통과 시킬 scope 배열 리스트 설정으로 변경
- oAuthServer pass.scope.list에 ',' 구분자로 통과시킬 scope 리스트
  설정(ex:public,oob)
2026-07-27 14:56:20 +09:00
curry772 90941bff54 DAMO 암호화 복호화 실패 시 원본 리턴되도록 수정 2026-07-27 10:43:33 +09:00
curry772 9e1f7391a0 ToolsController 개발
- base64 인코딩, damo 암복호화, version 정보
2026-07-23 11:43:59 +09:00
curry772 cb0a588a9c ToolsController(/manage/tools) Postman 컬렉션 추가
base64/hex 변환, DAMO 및 EncryptionManager 암복호화, 배포 버전 조회 API를
기존 CryptoModuleManage/SessionCacheManage 컬렉션과 동일한 스타일로 샘플링.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 09:10:27 +09:00
curry772 afe2040b6e SessionManager 캐시 조회 관리 API 추가 (/manage/session)
SessionManagerForIgnite/SessionManagerForEhcache가 관리하는 login,
http-login, evict-master, terminal, convert-user-id, socket,
outbound-access-token 캐시를 조회할 수 있는 관리용 API를 추가한다.
outbound-access-token 캐시는 Ignite 백엔드에서만 지원하며, Ehcache
백엔드에서는 success=false로 응답하고 500을 반환하지 않는다.
2026-07-21 15:25:27 +09:00
curry772 d13278c374 OAuth2 토큰 타입이 없을 때 Authorization 헤더에 Bearer 접두어 미부착
accessToken.getTokenType()이 비어있으면 Bearer 접두어 없이 토큰 값만
헤더에 설정하도록 setAuthHeaders를 수정하고, OAuth2AccessTokenVO의
tokenType은 BEARER_TYPE과 대소문자 무관하게 일치할 때만 상수로 정규화한다.
2026-07-21 15:01:54 +09:00
curry772 98d815e659 CryptoModuleManager 기반 암호화모듈 진단/암복호화 테스트 관리 API 추가
/manage/crypto 하위에 등록된 암호화모듈 목록/단건 조회, DYNAMIC 키
전략(KeyDerivationStrategy) 로드 상태 확인, 동적 키 캐시 키 목록 조회,
Base64 기반 암복호화 라운드트립 테스트 엔드포인트를 제공한다.
원본 암호키(encKeyHex/decKeyHex/ivHex)는 응답에 포함하지 않는다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 14:59:09 +09:00
curry772 c118c88dce 어댑터별 apikey 헤더명 동적 적용 2026-07-20 11:12:13 +09:00
curry772 28a4becf1c threshhold 및 threshholdpersecond 값 null 체크 추가 2026-07-20 11:10:56 +09:00
curry772 8339efc752 상태코드 출력포맷 변경 2026-07-20 11:10:21 +09:00
curry772 61c9aa9864 REST, HTTP 어댑터 재처리 기능 보완 2026-07-13 13:25:38 +09:00
curry772 a515d0f8b0 디버그 로그 필터 추가 2026-07-10 09:45:15 +09:00
curry772 b389ef2962 REST Header 로그에 송신 Body 출력할 수 있는 기능 추가 2026-07-09 16:24:42 +09:00
curry772 c15e52d205 UnkownMessageLogUtil 클래스명 오타 수정 2026-07-09 14:55:04 +09:00
curry772 2829d4362c 파라미터 메소드 생성 assignGetParam, assignRequestHeaders 파라미터 추가 2026-07-09 14:14:27 +09:00
curry772 5a9225e93e 로그 항목 수정 2026-07-09 14:08:11 +09:00
curry772 815a064cd9 제주은행 EAI와 응답 시 데이터부가 없을 경우(API GW 오류 발생), 500으로 리턴되도록 수정 2026-07-09 11:08:58 +09:00
curry772 5a943e3399 인코딩 오류 수정 2026-07-08 12:26:20 +09:00
curry772 3090f745ba weblogic 재배포 대응 2026-07-07 11:24:07 +09:00
curry772 af6b84f57d HSM 장애 대응 기능 개발 2026-07-06 09:22:38 +09:00
curry772 90633b3d26 JsonNode 사용방식으로 변경 2026-07-03 09:00:32 +09:00
curry772 822072147e 수신 시스템이 비표준일 경우, 오류 메시지를 표준메시지헤더에서 가져오지 않도록 수정 2026-07-03 08:59:06 +09:00
curry772 3527884e31 내부 ObjectMapper 추가 2026-07-03 08:57:42 +09:00
curry772 8df8a73175 hsm keystore reload 주기 설정 추가 2026-07-03 08:57:09 +09:00
curry772 5d9676b504 수신 시각이 프러퍼티에 있을 경우 그 값을 사용하도록 수정 2026-07-03 08:55:37 +09:00
curry772 40f496cfba 암복호화 기능 수정 2026-06-30 16:24:46 +09:00
curry772 efc06e6023 hsm 키 추가 2026-06-30 09:43:10 +09:00
curry772 a923ff3f9f HSM, 암호화 관련 테스트 수행 중 수정 2026-06-29 13:16:39 +09:00
curry772 ab9b86e7cf DAMO 라이브러리 업데이트 2026-06-25 10:00:33 +09:00
curry772 d78e422368 PreFilter에서 헤더복사하도록 수정 2026-06-25 09:56:23 +09:00
curry772 3cc66fcaa8 JacksonUtil 개발 2026-06-25 09:56:01 +09:00
curry772 73ed49372a ERP용 token 발급 개발 2026-06-25 09:55:27 +09:00
curry772 3574ad9e0c damo 테스트 추가 2026-06-23 09:55:50 +09:00
curry772 49eb8fbe85 API에 scope이 적용되어 있지 않은 경우, Client에 API 적용여부를 확인하도록 수정 2026-06-23 09:55:20 +09:00
curry772 253716f4b2 암복호화 적용 2026-06-23 09:54:07 +09:00
curry772 02e282c7ba Bypass REST Outbound Adapter 이동 2026-06-23 09:46:54 +09:00
curry772 180ea8cac1 거래차단 거래 503 Service unavailable throw 하도록 수정 2026-06-23 09:33:14 +09:00
curry772 2c21c691df UnkownMessageLogUtils.logUnkownMessage 로그 위치 변경 2026-06-23 09:23:17 +09:00
curry772 0da31a8de2 OAuthServer 프러퍼티 그룹 암호화 적용 2026-06-23 08:54:00 +09:00
curry772 46d88d77f2 필터 에러코드 추가 2026-06-19 18:00:31 +09:00
curry772 51967284ba api 찾지 못할 경우 FilterException throw로 변경 2026-06-19 18:00:13 +09:00
curry772 a218291748 rest outbound adapter에서 통신오류가 아닌 경우에만, adaptererrorhandler를 호출하도록 수정 2026-06-19 11:21:22 +09:00
113 changed files with 9699 additions and 1841 deletions
+17 -5
View File
@@ -25,7 +25,8 @@ java {
compileJava {
options.encoding = 'UTF-8'
sourceSets.main.java { srcDir generatedJavaDir }
// generatedJavaDir 는 아래 generatedSourceOutputDirectory 로 APT 가 이미 컴파일함.
// srcDir 로도 등록하면 낡은 생성물이 입력소스가 돼 APT 재생성 시 duplicate class 발생 → 등록 금지.
options.generatedSourceOutputDirectory = project.file(generatedJavaDir)
aptOptions {
@@ -50,8 +51,16 @@ dependencies {
//implementation project(':elink-online-transformer')
api project(':elink-online-transformer')
compileOnly fileTree(dir: 'libs', include: ['*.jar'])
// damo-manager.jar 를 제외한 나머지 libs 는 기존대로 컴파일 시점에만 사용한다.
// (WAS lib 또는 다른 경로에서 런타임에 제공됨)
compileOnly fileTree(dir: 'libs', include: ['*.jar'], exclude: ['damo-manager.jar'])
// damo-manager.jar: 기존에는 tomcat/lib 에 직접 넣어 런타임에 제공했으나,
// 배포 WAR(eapim-online.war)의 WEB-INF/lib 에 포함시키기 위해 런타임 의존성으로 전환한다.
// implementation 이므로 이 모듈의 compile/test classpath 와, 이 모듈을 project 의존성으로
// 참조하는 eapim-online 루트의 runtimeClasspath(= war 패키징 대상)에 함께 포함된다.
implementation files('libs/damo-manager.jar')
api (group: 'org.apache.activemq', name: 'activemq-console', version: '5.14.5'){
exclude group: 'com.fasterxml.jackson.core'
}
@@ -132,8 +141,10 @@ dependencies {
compileOnly group: 'javax.servlet.jsp', name: 'javax.servlet.jsp-api', version: '2.3.3'
compileOnly 'javax.resource:javax.resource-api:1.7'
compileOnly 'javax.jms:javax.jms-api:2.0.1'
compileOnly group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1'
// jackson-dataformat-xml 제거 (2026-08-27)
// XmlMapper/JacksonXml* 사용처가 전 소스에 0건이고, 선언 버전(2.13.1)이
// 실제 해석되는 jackson-core/databind(2.12.7)와 마이너 불일치라 승격 시 위험했다.
api "org.springframework.security:spring-security-jwt:1.1.1.RELEASE"
@@ -142,6 +153,7 @@ dependencies {
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
testImplementation 'org.springframework.boot:spring-boot-starter-test:2.6.15'
testImplementation 'junit:junit:4.4'
testImplementation files('libs/kjb-safedb.jar')
}
test {
Binary file not shown.
@@ -0,0 +1,391 @@
{
"info": {
"_postman_id": "b7e2e1d0-3c1a-4b0a-9d2e-crypto-manage-0001",
"name": "CryptoModuleManage API (/manage/crypto)",
"description": "CryptoModuleManager 기반 암호화모듈 진단 및 암복호화 테스트 API 샘플 모음.\n\n사용 전 확인사항:\n1. Collection Variables 의 baseUrl 을 실제 서버 주소로 변경\n2. cryptoName / dynamicCryptoName 을 DB(crypto_module_config)에 실제 등록된 모듈명으로 변경\n3. DYNAMIC 모듈은 keyDerivParams 의 contextKey 값과 dynamicContextKey 변수가 일치해야 함 (예: X-Api-Enc-Key)\n\n주의: 이 API는 원본 암호키(encKeyHex/decKeyHex/ivHex)를 절대 응답에 포함하지 않는다. 진단 응답은 메타데이터와 키 도출 전략(KeyDerivationStrategy) 로드 상태만 노출한다.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{ "key": "baseUrl", "value": "http://localhost:8080", "type": "string" },
{ "key": "cryptoName", "value": "SAMPLE_STATIC", "type": "string", "description": "STATIC 키 방식으로 등록된 실제 cryptoName으로 교체" },
{ "key": "dynamicCryptoName", "value": "SAMPLE_DYNAMIC", "type": "string", "description": "DYNAMIC 키 방식으로 등록된 실제 cryptoName으로 교체" },
{ "key": "dynamicContextKey", "value": "X-Api-Enc-Key", "type": "string", "description": "DYNAMIC 모듈의 key_deriv_params.contextKey 값과 일치해야 함" },
{ "key": "dynamicContextValue", "value": "clientKeyValue01", "type": "string" },
{ "key": "plainText", "value": "테스트 평문입니다", "type": "string" },
{ "key": "plainTextBase64", "value": "", "type": "string" },
{ "key": "staticCipherTextBase64", "value": "", "type": "string" },
{ "key": "dynamicCipherTextBase64", "value": "", "type": "string" }
],
"item": [
{
"name": "1. 진단 (Diagnostics)",
"item": [
{
"name": "전체 암호화모듈 목록 조회",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/crypto/list",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "list"]
},
"description": "등록된 전체 암호화모듈의 진단정보(메타데이터)를 반환한다. 원본 키는 포함되지 않는다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('data는 배열', () => pm.expect(json.data).to.be.an('array'));",
"pm.test('원본 키 필드가 응답에 없어야 함', () => {",
" const body = pm.response.text();",
" pm.expect(body).to.not.include('encKeyHex');",
" pm.expect(body).to.not.include('decKeyHex');",
"});"
]
}
}
]
},
{
"name": "단일 암호화모듈 조회",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/crypto/{{cryptoName}}",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "{{cryptoName}}"]
},
"description": "cryptoName 하나의 진단정보를 조회한다. STATIC 모듈은 keyDerivStrategy가 null, strategyLoaded가 false로 내려온다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('cryptoName 일치', () => pm.expect(json.data.cryptoName).to.eql(pm.collectionVariables.get('cryptoName')));"
]
}
}
]
},
{
"name": "DYNAMIC 모듈 조회 (전략 로드 확인)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/crypto/{{dynamicCryptoName}}",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "{{dynamicCryptoName}}"]
},
"description": "DYNAMIC 모듈은 resolveStrategy를 통해 키 도출 전략(KeyDerivationStrategy) 클래스가 정상 로드되는지 strategyLoaded/strategyClassName으로 확인할 수 있다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('keySourceType = DYNAMIC', () => pm.expect(json.data.keySourceType).to.eql('DYNAMIC'));",
"pm.test('strategyLoaded = true', () => pm.expect(json.data.strategyLoaded).to.eql(true));",
"pm.test('strategyClassName 존재', () => pm.expect(json.data.strategyClassName).to.be.a('string'));"
]
}
}
]
},
{
"name": "동적 키 캐시 목록 조회",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/crypto/cache/dynamic-keys",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "cache", "dynamic-keys"]
},
"description": "DYNAMIC 키 방식 모듈이 캐시한 키의 캐시-키 목록(예: 'cryptoName:contextValue')을 반환한다. 원본 키 값이 아니다. 아래 'DYNAMIC 키' 폴더의 암호화 요청을 먼저 실행한 뒤 호출하면 목록이 채워진다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('data는 배열', () => pm.expect(json.data).to.be.an('array'));"
]
}
}
]
}
]
},
{
"name": "2. STATIC 키 암복호화 테스트",
"item": [
{
"name": "암호화 (encrypt)",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"plainTextBase64\": \"{{plainTextBase64}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/crypto/{{cryptoName}}/test/encrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "{{cryptoName}}", "test", "encrypt"]
},
"description": "STATIC 키 방식 모듈로 평문(Base64)을 암호화한다. runtimeContext 없이 호출한다."
},
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
"const plainText = pm.collectionVariables.get('plainText');",
"pm.collectionVariables.set('plainTextBase64', btoa(unescape(encodeURIComponent(plainText))));"
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('cipherTextBase64 존재', () => pm.expect(json.data.cipherTextBase64).to.be.a('string'));",
"pm.test('평문 base64와 달라야 함', () => pm.expect(json.data.cipherTextBase64).to.not.eql(pm.collectionVariables.get('plainTextBase64')));",
"pm.collectionVariables.set('staticCipherTextBase64', json.data.cipherTextBase64);"
]
}
}
]
},
{
"name": "복호화 (decrypt) — 위 암호화 결과 사용",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"cipherTextBase64\": \"{{staticCipherTextBase64}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/crypto/{{cryptoName}}/test/decrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "{{cryptoName}}", "test", "decrypt"]
},
"description": "바로 위 '암호화 (encrypt)' 요청에서 저장한 staticCipherTextBase64를 복호화하여 원문과 일치하는지 확인한다. 반드시 암호화 요청을 먼저 실행할 것."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"const decoded = decodeURIComponent(escape(atob(json.data.plainTextBase64)));",
"pm.test('원문과 일치 (라운드트립)', () => pm.expect(decoded).to.eql(pm.collectionVariables.get('plainText')));"
]
}
}
]
}
]
},
{
"name": "3. DYNAMIC 키 암복호화 테스트",
"item": [
{
"name": "암호화 (encrypt, runtimeContext 포함)",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"plainTextBase64\": \"{{plainTextBase64}}\",\n \"runtimeContext\": {\n \"{{dynamicContextKey}}\": \"{{dynamicContextValue}}\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/crypto/{{dynamicCryptoName}}/test/encrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "{{dynamicCryptoName}}", "test", "encrypt"]
},
"description": "DYNAMIC 키 방식 모듈로 runtimeContext(예: 헤더값)를 전달하여 키를 도출한 뒤 암호화한다. dynamicContextKey는 DB에 등록된 key_deriv_params.contextKey와 일치해야 한다."
},
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
"const plainText = pm.collectionVariables.get('plainText');",
"pm.collectionVariables.set('plainTextBase64', btoa(unescape(encodeURIComponent(plainText))));"
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('cipherTextBase64 존재', () => pm.expect(json.data.cipherTextBase64).to.be.a('string'));",
"pm.collectionVariables.set('dynamicCipherTextBase64', json.data.cipherTextBase64);"
]
}
}
]
},
{
"name": "복호화 (decrypt, 동일 runtimeContext 필요)",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"cipherTextBase64\": \"{{dynamicCipherTextBase64}}\",\n \"runtimeContext\": {\n \"{{dynamicContextKey}}\": \"{{dynamicContextValue}}\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/crypto/{{dynamicCryptoName}}/test/decrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "{{dynamicCryptoName}}", "test", "decrypt"]
},
"description": "암호화 시 사용한 것과 동일한 runtimeContext를 전달해야 동일한 키가 도출되어 복호화에 성공한다. dynamicContextValue를 바꿔서 보내면 키가 달라져 복호화가 실패(예외)하는 것도 확인해볼 수 있다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"const decoded = decodeURIComponent(escape(atob(json.data.plainTextBase64)));",
"pm.test('원문과 일치 (라운드트립)', () => pm.expect(decoded).to.eql(pm.collectionVariables.get('plainText')));"
]
}
}
]
}
]
},
{
"name": "4. 에러 케이스",
"item": [
{
"name": "미등록 cryptoName 조회 → success=false",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/crypto/NOT_REGISTERED_MODULE",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "NOT_REGISTERED_MODULE"]
},
"description": "존재하지 않는 cryptoName 조회 시 HTTP 200 + success=false + message에 사유가 담겨 반환되는지 확인한다 (예외가 그대로 500으로 노출되지 않음)."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = false', () => pm.expect(json.success).to.eql(false));",
"pm.test('message에 모듈명 포함', () => pm.expect(json.message).to.include('NOT_REGISTERED_MODULE'));"
]
}
}
]
},
{
"name": "잘못된 Base64로 복호화 → success=false",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"cipherTextBase64\": \"not-valid-base64!!\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/crypto/{{cryptoName}}/test/decrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "{{cryptoName}}", "test", "decrypt"]
},
"description": "Base64 형식이 아닌 문자열을 전달하면 success=false로 처리되고 서버가 500으로 죽지 않는지 확인한다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = false', () => pm.expect(json.success).to.eql(false));",
"pm.test('message 존재', () => pm.expect(json.message).to.be.a('string'));"
]
}
}
]
},
{
"name": "다른 runtimeContext로 DYNAMIC 복호화 시도 → 실패 확인",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"cipherTextBase64\": \"{{dynamicCipherTextBase64}}\",\n \"runtimeContext\": {\n \"{{dynamicContextKey}}\": \"differentContextValue\"\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/crypto/{{dynamicCryptoName}}/test/decrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "crypto", "{{dynamicCryptoName}}", "test", "decrypt"]
},
"description": "'3. DYNAMIC 키' 폴더의 암호화를 먼저 실행해 dynamicCipherTextBase64를 채운 뒤, 암호화 때와 다른 runtimeContext 값으로 복호화를 시도한다. 키가 달라 padding/복호화 오류로 success=false가 되는 것을 확인한다 (패딩 모드에 따라 우연히 성공할 수도 있으니 참고용)."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"console.log('다른 컨텍스트 복호화 결과:', JSON.stringify(json));"
]
}
}
]
}
]
}
]
}
@@ -0,0 +1,312 @@
{
"info": {
"_postman_id": "c3f4a1e0-9b2d-4a11-8e5a-session-cache-0001",
"name": "SessionCacheManage API (/manage/session)",
"description": "SessionManager(SessionManagerForIgnite/SessionManagerForEhcache) 캐시 조회 API 샘플 모음.\n\n사용 전 확인사항:\n1. Collection Variables 의 baseUrl 을 실제 서버 주소로 변경\n2. 서버가 Ignite 캐시 백엔드로 기동된 경우에만 outbound-access-token 캐시가 채워짐 (Ehcache 백엔드는 success=false로 응답, 정상 동작임)\n3. 모든 요청은 조회 전용(GET)이며 실제 세션/캐시 데이터를 변경하지 않는다.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{ "key": "baseUrl", "value": "http://localhost:8080", "type": "string" }
],
"item": [
{
"name": "1. 캐시 요약 / 마스터 인스턴스",
"item": [
{
"name": "전체 캐시 요약 (구현체별 XML)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/cache/status",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "cache", "status"]
},
"description": "Ignite/Ehcache 구현체가 각자 형식으로 만든 전체 캐시 이름/크기 요약 텍스트(XML)를 반환한다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('data는 문자열(XML)', () => pm.expect(json.data).to.be.a('string'));",
"pm.test('Caches 루트 태그 포함', () => pm.expect(json.data).to.include('<Caches'));"
]
}
}
]
},
{
"name": "현재 evict master 인스턴스 조회",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/master-inst-id",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "master-inst-id"]
},
"description": "싱글 어댑터 처리를 담당하는 현재 master 서버 인스턴스명을 반환한다. 아직 선출되지 않았으면 data가 null일 수 있다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"console.log('master instance id:', json.data);"
]
}
}
]
}
]
},
{
"name": "2. 개별 캐시 조회",
"item": [
{
"name": "로그인(WebSocket) 캐시",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/cache/login",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "cache", "login"]
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('name = loginSession', () => pm.expect(json.data.name).to.eql('loginSession'));",
"pm.test('keys는 배열', () => pm.expect(json.data.keys).to.be.an('array'));",
"pm.test('size는 keys 개수와 일치', () => pm.expect(json.data.size).to.eql(json.data.keys.length));"
]
}
}
]
},
{
"name": "HTTP 로그인 캐시",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/cache/http-login",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "cache", "http-login"]
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('name = httpLogin', () => pm.expect(json.data.name).to.eql('httpLogin'));",
"pm.test('keys는 배열', () => pm.expect(json.data.keys).to.be.an('array'));"
]
}
}
]
},
{
"name": "싱글 어댑터 evict-master 캐시",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/cache/evict-master",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "cache", "evict-master"]
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('name = evictMasterCache', () => pm.expect(json.data.name).to.eql('evictMasterCache'));",
"pm.test('keys는 배열', () => pm.expect(json.data.keys).to.be.an('array'));"
]
}
}
]
},
{
"name": "단말(ATM 등) 캐시",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/cache/terminal",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "cache", "terminal"]
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('name = terminalSession', () => pm.expect(json.data.name).to.eql('terminalSession'));",
"pm.test('keys는 배열', () => pm.expect(json.data.keys).to.be.an('array'));"
]
}
}
]
},
{
"name": "단말ID→사용자ID 변환 캐시",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/cache/convert-user-id",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "cache", "convert-user-id"]
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('name = convertUserIdSession', () => pm.expect(json.data.name).to.eql('convertUserIdSession'));",
"pm.test('keys는 배열', () => pm.expect(json.data.keys).to.be.an('array'));"
]
}
}
]
},
{
"name": "소켓 세션 캐시",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/cache/socket",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "cache", "socket"]
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('name = adapterSession', () => pm.expect(json.data.name).to.eql('adapterSession'));",
"pm.test('keys는 배열', () => pm.expect(json.data.keys).to.be.an('array'));"
]
}
}
]
},
{
"name": "아웃바운드 AccessToken 캐시 (Ignite 전용)",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/cache/outbound-access-token",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "cache", "outbound-access-token"]
},
"description": "Ignite 백엔드에서만 지원한다. Ehcache 백엔드로 기동된 서버라면 success=false + message로 응답하며, 이는 정상 동작이다(서버 500 아님)."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"if (json.success) {",
" pm.test('data.name 존재', () => pm.expect(json.data.name).to.be.a('string'));",
" pm.test('data.keys는 배열', () => pm.expect(json.data.keys).to.be.an('array'));",
"} else {",
" pm.test('미지원 백엔드는 message 포함', () => pm.expect(json.message).to.be.a('string'));",
" console.log('OutboundAccessToken 캐시 미지원 백엔드:', json.message);",
"}"
]
}
}
]
}
]
},
{
"name": "3. 전체 캐시 목록",
"item": [
{
"name": "모든 캐시 한번에 조회",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/session/cache/all",
"host": ["{{baseUrl}}"],
"path": ["manage", "session", "cache", "all"]
},
"description": "login/http-login/evict-master/terminal/convert-user-id/socket 6종은 항상 포함되고, outbound-access-token은 Ignite 백엔드일 때만 추가되어 count가 6 또는 7이 된다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('data는 배열', () => pm.expect(json.data).to.be.an('array'));",
"pm.test('count는 6 또는 7 (백엔드에 따라 outbound-access-token 포함 여부 다름)', () => {",
" pm.expect(json.count).to.be.oneOf([6, 7]);",
"});",
"pm.test('count와 data.length 일치', () => pm.expect(json.count).to.eql(json.data.length));",
"json.data.forEach((cache) => {",
" pm.test(`캐시 [${cache.name}] 는 keys 배열을 가짐`, () => pm.expect(cache.keys).to.be.an('array'));",
"});"
]
}
}
]
}
]
}
]
}
+353
View File
@@ -0,0 +1,353 @@
{
"info": {
"_postman_id": "d4a8b2f0-5e3c-4d21-9f0a-tools-manage-0001",
"name": "ToolsManage API (/manage/tools)",
"description": "base64/hex 변환, DAMO 및 EncryptionManager 암복호화 테스트, 배포 버전 확인용 유틸리티 API 샘플 모음.\n\n사용 전 확인사항:\n1. Collection Variables 의 baseUrl 을 실제 서버 주소로 변경\n2. DAMO/EncryptionManager 암복호화는 각 실행 순서(암호화 → 복호화)를 지켜야 체이닝 변수가 채워짐\n3. EncryptionManager 는 encryptYN=N 환경에서는 encrypt/decrypt가 원문을 그대로 반환한다(정상 동작). 먼저 '상태 조회'로 확인할 것\n4. /manage/tools/version 은 eapim-online(WAR 루트) build.gradle의 generateVersionInfo 태스크가 생성한 version.info 리소스가 있어야 success=true로 응답한다. 재빌드 없이 IDE에서 바로 기동한 로컬 환경 등에서는 success=false + message로 응답하며, 이는 정상 동작이다.",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"variable": [
{ "key": "baseUrl", "value": "http://localhost:8080", "type": "string" },
{ "key": "charset", "value": "UTF-8", "type": "string" },
{ "key": "plainText", "value": "테스트 평문입니다", "type": "string" },
{ "key": "base64EncodedText", "value": "", "type": "string" },
{ "key": "hexEncodedText", "value": "", "type": "string" },
{ "key": "damoCipherText", "value": "", "type": "string" },
{ "key": "encManagerCipherText", "value": "", "type": "string" }
],
"item": [
{
"name": "1. Base64 / Hex 변환",
"item": [
{
"name": "문자열 → Base64 인코딩",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"text\": \"{{plainText}}\",\n \"charset\": \"{{charset}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/tools/base64/encode",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "base64", "encode"]
},
"description": "charset(생략 시 UTF-8) 기준으로 문자열을 바이트로 변환한 뒤 Base64로 인코딩한다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('data는 문자열', () => pm.expect(json.data).to.be.a('string'));",
"pm.collectionVariables.set('base64EncodedText', json.data);"
]
}
}
]
},
{
"name": "Base64 → 문자열 디코딩 (위 인코딩 결과 사용)",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"text\": \"{{base64EncodedText}}\",\n \"charset\": \"{{charset}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/tools/base64/decode",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "base64", "decode"]
},
"description": "바로 위 '문자열 → Base64 인코딩' 요청에서 저장한 base64EncodedText를 디코딩하여 원문과 일치하는지 확인한다. 반드시 인코딩 요청을 먼저 실행할 것."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('원문과 일치 (라운드트립)', () => pm.expect(json.data).to.eql(pm.collectionVariables.get('plainText')));"
]
}
}
]
},
{
"name": "문자열 → Hex 인코딩",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"text\": \"{{plainText}}\",\n \"charset\": \"{{charset}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/tools/hex/encode",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "hex", "encode"]
},
"description": "charset(생략 시 UTF-8) 기준으로 문자열을 바이트로 변환한 뒤 대문자 Hex 문자열로 인코딩한다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('data는 문자열', () => pm.expect(json.data).to.be.a('string'));",
"pm.collectionVariables.set('hexEncodedText', json.data);"
]
}
}
]
},
{
"name": "Hex → 문자열 디코딩 (위 인코딩 결과 사용)",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"text\": \"{{hexEncodedText}}\",\n \"charset\": \"{{charset}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/tools/hex/decode",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "hex", "decode"]
},
"description": "바로 위 '문자열 → Hex 인코딩' 요청에서 저장한 hexEncodedText를 디코딩하여 원문과 일치하는지 확인한다. 반드시 인코딩 요청을 먼저 실행할 것."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('원문과 일치 (라운드트립)', () => pm.expect(json.data).to.eql(pm.collectionVariables.get('plainText')));"
]
}
}
]
}
]
},
{
"name": "2. DAMO 암복호화 (DamoManager 직접 호출)",
"item": [
{
"name": "암호화 (encrypt)",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"text\": \"{{plainText}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/tools/damo/encrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "damo", "encrypt"]
},
"description": "com.eactive.ext.djb.DamoManager를 직접 호출한다. EncryptionManager의 encryptYN 설정과 무관하게 항상 실제 암호화가 수행된다."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('data는 문자열', () => pm.expect(json.data).to.be.a('string'));",
"pm.test('평문과 달라야 함', () => pm.expect(json.data).to.not.eql(pm.collectionVariables.get('plainText')));",
"pm.collectionVariables.set('damoCipherText', json.data);"
]
}
}
]
},
{
"name": "복호화 (decrypt) — 위 암호화 결과 사용",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"text\": \"{{damoCipherText}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/tools/damo/decrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "damo", "decrypt"]
},
"description": "바로 위 '암호화 (encrypt)' 요청에서 저장한 damoCipherText를 복호화하여 원문과 일치하는지 확인한다. 반드시 암호화 요청을 먼저 실행할 것."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('원문과 일치 (라운드트립)', () => pm.expect(json.data).to.eql(pm.collectionVariables.get('plainText')));"
]
}
}
]
}
]
},
{
"name": "3. EncryptionManager 암복호화 (운영 설정 반영)",
"item": [
{
"name": "현재 설정 상태 조회",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/tools/encryption-manager/status",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "encryption-manager", "status"]
},
"description": "encryptYN/dbEncryptSolutionName/encryptEnabled를 조회한다. encryptYN=N이면 아래 encrypt/decrypt가 원문을 그대로 반환하는 것이 정상 동작이니 먼저 이 값을 확인할 것."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('encryptYN 존재', () => pm.expect(json.data.encryptYN).to.exist);",
"pm.test('encryptEnabled는 boolean', () => pm.expect(json.data.encryptEnabled).to.be.a('boolean'));",
"console.log('EncryptionManager 상태:', JSON.stringify(json.data));"
]
}
}
]
},
{
"name": "암호화 (encrypt)",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"text\": \"{{plainText}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/tools/encryption-manager/encrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "encryption-manager", "encrypt"]
},
"description": "실제 운영 코드가 사용하는 EncryptionManager.encryptDBData를 그대로 호출한다. encryptYN=N 환경에서는 원문이 그대로 반환된다(정상 동작)."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('data는 문자열', () => pm.expect(json.data).to.be.a('string'));",
"pm.collectionVariables.set('encManagerCipherText', json.data);"
]
}
}
]
},
{
"name": "복호화 (decrypt) — 위 암호화 결과 사용",
"request": {
"method": "POST",
"header": [{ "key": "Content-Type", "value": "application/json" }],
"body": {
"mode": "raw",
"raw": "{\n \"text\": \"{{encManagerCipherText}}\"\n}"
},
"url": {
"raw": "{{baseUrl}}/manage/tools/encryption-manager/decrypt",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "encryption-manager", "decrypt"]
},
"description": "바로 위 '암호화 (encrypt)' 요청에서 저장한 encManagerCipherText를 복호화한다. encryptYN 설정값과 무관하게 encrypt→decrypt 라운드트립 결과는 항상 원문과 같아야 한다(활성 시 실제 복호화, 비활성 시 원문 그대로 통과). 반드시 암호화 요청을 먼저 실행할 것."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"pm.test('success = true', () => pm.expect(json.success).to.eql(true));",
"pm.test('원문과 일치 (라운드트립)', () => pm.expect(json.data).to.eql(pm.collectionVariables.get('plainText')));"
]
}
}
]
}
]
},
{
"name": "4. 버전 정보",
"item": [
{
"name": "배포 버전 조회",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/manage/tools/version",
"host": ["{{baseUrl}}"],
"path": ["manage", "tools", "version"]
},
"description": "eapim-online(WAR를 만드는 루트 프로젝트) build.gradle의 generateVersionInfo 태스크가 생성한 version.info(git describe 결과, buildTime)를 반환한다. 재빌드 없이 IDE에서 바로 기동한 로컬 환경 등 파일이 없으면 success=false + message로 응답하며, 이는 정상 동작이다(서버 500 아님)."
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"const json = pm.response.json();",
"pm.test('status 200', () => pm.response.to.have.status(200));",
"if (json.success) {",
" pm.test('version 존재', () => pm.expect(json.data.version).to.be.a('string'));",
" pm.test('buildTime 존재', () => pm.expect(json.data.buildTime).to.be.a('string'));",
"} else {",
" pm.test('미생성 환경은 message 포함', () => pm.expect(json.message).to.be.a('string'));",
" console.log('version.info 없음:', json.message);",
"}"
]
}
}
]
}
]
}
]
}
@@ -23,6 +23,7 @@ import com.eactive.eai.common.message.EAIMessage;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.common.util.MessageUtil;
import com.eactive.eai.common.util.MessageUtil;
import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage;
import com.google.gson.JsonElement;
@@ -127,6 +128,9 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
StandardMessage resStandardMessage = resEaiMsg.getStandardMessage();
String templateKey = inboundAdapterGroupName + ".template";
String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
if (StringUtils.isBlank(template))
template = PropManager.getInstance().getProperty(PROP_GROUP, "default.template");
if (StringUtils.isBlank(template)) {
if (logger.isWarn()) {
@@ -168,7 +172,10 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
} else {
// ${path} / ${callprop.키} / ${exception.필드} 스칼라 변수
String expr = matcher.group(3).trim();
replacement = resolveScalar(expr, msg, callProp, exception);
// 치환값에 제어문자가 섞이면 렌더 결과가 깨진 JSON/XML 이 된다.
// (개행이 든 값 → {"outpMsgDesc":"오류상세<개행>..."} → 수신측 파싱 실패)
// 템플릿 포맷을 알 수 없으므로 이스케이프 대신 제어문자를 걸러낸다.
replacement = MessageUtil.stripControlChars(resolveScalar(expr, msg, callProp, exception));
}
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
}
@@ -320,6 +327,8 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
? expr.substring(closeIdx + 2)
: "";
if (msg == null) return defaultVal;
String key = msg.findItemValue(msgPath);
if (StringUtils.isBlank(key)) return defaultVal;
if (callProp == null) return defaultVal;
@@ -395,7 +404,8 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
String value = "";
StandardItem item = row.get(fieldName);
if (item != null) {
value = StringUtils.defaultString(item.getValue());
// render() 의 스칼라 치환과 동일한 이유로 제어문자를 걸러낸다
value = MessageUtil.stripControlChars(StringUtils.defaultString(item.getValue()));
}
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
}
@@ -406,8 +416,18 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
@Override
public Object generateNonStandardInternalErrorResponseMessage(String inboudnAdapterGroupName, String inboudnAdapterName,
Properties callProp, Object inboundRequestData, EAIMessage resEaiMsg) throws Exception {
String templateKey = inboudnAdapterGroupName + ".sys.template";
String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
String template = "";
String templateKey = "";
if (resEaiMsg != null && StringUtils.isNotEmpty(resEaiMsg.getRspErrCd())) {
templateKey = inboudnAdapterGroupName + ".sys." + resEaiMsg.getRspErrCd() + ".template";
template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
}
if (StringUtils.isBlank(template)) {
templateKey = inboudnAdapterGroupName + ".sys.template";
template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
}
if (StringUtils.isBlank(template))
template = PropManager.getInstance().getProperty(PROP_GROUP, "default.sys.template");
if (StringUtils.isBlank(template)) {
if (logger.isWarn()) {
@@ -456,6 +476,10 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
throws Exception {
String templateKey = outboundadapterGroupName + ".template";
String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
if (StringUtils.isBlank(template))
template = PropManager.getInstance().getProperty(PROP_GROUP, "default.template");
if (StringUtils.isBlank(template)) {
return null;
}
@@ -466,15 +490,27 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
String adptMsgType, String encode, Throwable e1, int httpCode) {
String templateKey = adapterGroupName + ".in." + httpCode + ".template";
String template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
if (StringUtils.isBlank(template)) {
templateKey = "default.in." + httpCode + ".template";
template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
}
if (StringUtils.isBlank(template)) {
templateKey = adapterGroupName + ".in.template";
template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
if (StringUtils.isBlank(template)) {
String errorResponseFormat = httpProp.getProperty("ERROR_RESPONSE_FORMAT");
return MessageUtil.makeErrorMessageByMessageType(adptMsgType, encode,
MessageUtil.ERROR_CODE_AP_ERROR, e1.getMessage(), errorResponseFormat);
}
}
if (StringUtils.isBlank(template)) {
templateKey = "default.in.template";
template = PropManager.getInstance().getProperty(PROP_GROUP, templateKey);
}
if (StringUtils.isBlank(template)) {
String errorResponseFormat = httpProp.getProperty("ERROR_RESPONSE_FORMAT");
return MessageUtil.makeErrorMessageByMessageType(adptMsgType, encode,
MessageUtil.ERROR_CODE_AP_ERROR, e1.getMessage(), errorResponseFormat);
}
return render(template, null, callProp, e1);
}
@@ -6,6 +6,7 @@ import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.message.EAIMessage;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.JacksonUtil;
import com.eactive.eai.common.util.Logger;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -19,7 +20,8 @@ public class TemplateCodeConvertAdapterErrorMsgHandler extends TemplateAdapterEr
/** PropManager 에서 코드 변환 설정을 조회할 프로퍼티 그룹 이름 */
static final String PROP_GROUP = "AdapterErrorMessageHandler{CODE_CONVERT}";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
// 기본 ObjectMapper 는 응답 JSON 왕복에서 100000000.00 을 1.0E8 로 바꿔버린다.
private static final ObjectMapper OBJECT_MAPPER = JacksonUtil.newNumberSafeMapper();
@Override
public Object generateNonStandardErrorResponseMessage(
@@ -58,7 +60,9 @@ public class TemplateCodeConvertAdapterErrorMsgHandler extends TemplateAdapterEr
return responseMsessage;
}
JsonNode rootNode = OBJECT_MAPPER.readTree(jsonStr);
// 상대 시스템이 개행 등 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다.
// (이미 표준을 지킨 JSON 이면 escapeControlChars 는 원본을 그대로 반환한다)
JsonNode rootNode = OBJECT_MAPPER.readTree(JacksonUtil.escapeControlChars(jsonStr));
boolean modified = false;
for (String rawField : fieldsValue.split(",")) {
@@ -57,7 +57,7 @@ public class HttpStatusException extends Exception {
public String getCode() {
if (StringUtils.isBlank(code)) {
return String.format("Http Status: %d", status);
return String.format("HttCd:%d", status);
} else {
return code;
}
@@ -11,7 +11,9 @@ import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
@@ -30,6 +32,7 @@ import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuil
import org.apache.hc.client5.http.ssl.NoopHostnameVerifier;
import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
import org.apache.hc.client5.http.ssl.TrustAllStrategy;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpRequest;
import org.apache.hc.core5.http.HttpRequestInterceptor;
import org.apache.hc.core5.http.HttpResponseInterceptor;
@@ -40,6 +43,7 @@ import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.Keys;
import com.eactive.eai.adapter.http.secure.HttpClient5SSLContextFactory;
import com.eactive.eai.common.TransactionContextKeys;
import com.eactive.eai.common.logger.HttpAdapterExtraHeaderVo;
import com.eactive.eai.common.message.EAIMessageKeys;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.server.EAIServerManager;
@@ -416,8 +420,24 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
logProcessNo = 200;
}
HttpAdapterExtraLogUtil.insertHttpAdapterExtraLog(uuid, logProcessNo,
contextAdapterGroupName, contextAdapterName, request.getHeaders(), url, request.getMethod());
Header[] headers = request.getHeaders();
List<HttpAdapterExtraHeaderVo> headerVoList = null;
if ( headers != null && headers.length != 0 ) {
headerVoList = HttpAdapterExtraLogUtil.convertHeaderToListOfHttpAdapterExtraHeaderVo(headers);
} else {
headerVoList = new ArrayList<HttpAdapterExtraHeaderVo>();
}
String trimedPostBody = (String) context.getAttribute(HttpAdapterExtraLogUtil.BODY_FIELD_NAME);
if(StringUtils.isNotEmpty(trimedPostBody)) {
HttpAdapterExtraHeaderVo vo = new HttpAdapterExtraHeaderVo();
vo.setName(HttpAdapterExtraLogUtil.BODY_FIELD_NAME);
vo.setValue(trimedPostBody);
headerVoList.add(vo);
}
HttpAdapterExtraLogUtil.insertHttpAdapterExtraLog(uuid, logProcessNo,
contextAdapterGroupName, contextAdapterName, headerVoList, url, request.getMethod(), 0);
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
@@ -443,7 +463,7 @@ public abstract class HttpClient5AdapterServiceSupport implements HttpClientAdap
}else{
logProcessNo += 100;
}
HttpAdapterExtraLogUtil.insertHttpAdapterExtraLog(uuid, logProcessNo,
contextAdapterGroupName, contextAdapterName, response.getHeaders(), url, request.getMethod(), response.getCode());
} catch (URISyntaxException e) {
@@ -88,10 +88,17 @@ public class DeadlineAwareRetryExecutor {
if (!policy.shouldRetryOnStatus(request, status)) {
// 성공 또는 재시도 불필요 → 호출부에서 close 책임
log.debug("non-retryable response status={}, attempt={}", status, attempt + 1);
log.info("non-retryable response status={}, attempt={}", status, attempt + 1);
return response;
}
// ④ 마지막 시도였으면 종료
if (attempt >= policy.getMaxRetries()) {
// 최대 재처리에 도달하여, 재시도하지 않고, 응답 객체를 리턴한다.
log.info("최대 재시도 횟수 도달 response status={}, attempt={}", status, attempt + 1);
return response;
}
// 재시도 대상
log.warn("retryable status={}, attempt={}/{}",
status, attempt + 1, policy.getMaxRetries());
@@ -127,9 +134,6 @@ public class DeadlineAwareRetryExecutor {
}
}
// ④ 마지막 시도였으면 바로 종료
if (attempt >= policy.getMaxRetries()) break;
// ⑤ 백오프 대기 전 남은 시간 재확인
long remainingBeforeWait = totalDeadlineMs - (System.currentTimeMillis() - startTime);
if (remainingBeforeWait <= policy.getBackoffMs()) {
@@ -171,10 +175,6 @@ public class DeadlineAwareRetryExecutor {
log.info("RequestConfig = {}", config);
}
private boolean isSuccess(int status) {
return status >= 200 && status < 300;
}
private void closeQuietly(CloseableHttpResponse response) {
try {
response.close();
@@ -7,6 +7,7 @@ import com.eactive.eai.adapter.http.client.HttpClientAdapterVO;
import com.eactive.eai.common.TransactionContextKeys;
import com.eactive.eai.common.message.MessageType;
import com.eactive.eai.common.util.CommonLib;
import com.eactive.eai.common.util.JacksonUtil;
import com.eactive.eai.common.util.TxFileLogger;
import org.apache.commons.httpclient.HttpStatus;
@@ -14,6 +15,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.StopWatch;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.HttpHost;
@@ -59,8 +61,10 @@ public class HttpClient5AdapterServiceBody extends HttpClient5AdapterServiceSupp
}
if(MessageType.JSON.equals(prop.getProperty("messageType"))) {
ObjectMapper mapper = new ObjectMapper();
ObjectNode jsonNode = (ObjectNode) mapper.readTree(sendData);
// 파싱 후 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 를 쓴다.
// 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다.
ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
ObjectNode jsonNode = (ObjectNode) mapper.readTree(JacksonUtil.escapeControlChars(sendData));
ObjectNode headerPart = (ObjectNode) jsonNode.get("header_part");
if( headerPart.get("mciIntfId") != null && headerPart.get("mciIntfId").asText().trim().length() > 0 ) {
@@ -135,7 +139,15 @@ public class HttpClient5AdapterServiceBody extends HttpClient5AdapterServiceSupp
byte[] responseMessage = null;
String responseString = "";
try (CloseableHttpResponse response = (CloseableHttpResponse) this.client.execute(method, context)) {
DeadlineAwareRetryExecutor executor =
new DeadlineAwareRetryExecutor((CloseableHttpClient)this.client,
vo.getConnectionTimeout(),
vo.getTimeout());
// Properties에서 RetryPolicy 로드
RetryPolicy policy = RetryPolicy.from(prop); // 설정 로드 방식에 맞게
try (CloseableHttpResponse response = (CloseableHttpResponse) executor.execute(method, context, policy)) {
status = response.getCode();
responseMessage = EntityUtils.toByteArray(response.getEntity());
responseString = new String(responseMessage, vo.getEncode());
@@ -1,4 +1,4 @@
package com.eactive.eai.adapter.http.client.impl.kjb;
package com.eactive.eai.adapter.http.client.impl;
import java.io.IOException;
import java.net.ConnectException;
@@ -43,7 +43,6 @@ import com.eactive.eai.adapter.http.HttpMethodType;
import com.eactive.eai.adapter.http.client.HttpClient5AdapterServiceSupport;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.adapter.http.client.HttpClientAdapterVO;
import com.eactive.eai.adapter.http.client.impl.HttpClientAdapterServiceRest;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.impl.HttpAdapterServiceBypass;
import com.eactive.eai.common.TransactionContextKeys;
@@ -9,6 +9,10 @@ import org.json.simple.JSONObject;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.adapter.http.client.impl.filter.HttpClient5AdapterFilterFactory;
import com.eactive.eai.adapter.http.client.impl.filter.HttpClientAdapterFilter;
import com.eactive.eai.common.util.JacksonUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* 1. 기능 : HttpClient5AdapterServiceRest 호출 전후 Filter 적용할 수 있는 기능을 제공한다.<br>
@@ -34,6 +38,9 @@ public class HttpClient5AdapterServiceRestAddFilter extends HttpClient5AdapterSe
// // Adapter에서 Exception이 발생한 경우, 처리할 필터
// static final String EXCEPTION_FILTER = "EXCEPTION_FILTER";
// 기본 ObjectMapper 는 JsonNode 직렬화 시 작은 소수를 1.2E-7 로 바꿔버린다.
private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
/**
* 1. 기능 : HttpClient 호출 전후 Filter 적용용 2. 처리 개요 : <br>
* 3. 주의사항 <br>
@@ -54,8 +61,9 @@ public class HttpClient5AdapterServiceRestAddFilter extends HttpClient5AdapterSe
adapterResponse = doPostFilters(adptGrpName, adptName, prop, adapterResponse, tempProp);
if (adapterResponse instanceof JSONObject)
adapterResponse = ((JSONObject) adapterResponse).toJSONString();
if (adapterResponse instanceof Node)
else if (adapterResponse instanceof ObjectNode)
adapterResponse = mapper.writeValueAsString((ObjectNode) adapterResponse);
else if (adapterResponse instanceof Node)
adapterResponse = ((Node) adapterResponse).asXML();
return adapterResponse;
@@ -65,7 +65,7 @@ public class RetryPolicy {
return new RetryPolicy(getInt(props, RETRY_MAX_RETRIES, 0), getLong(props, RETRY_BACKOFF_MS, 1000L), codes,
getBoolean(props, RETRY_ON_CONNECT_FAIL, false), getBoolean(props, RETRY_ON_TIMEOUT, false),
getBoolean(props, RETRY_IDEMPOTENT_ONLY, true));
getBoolean(props, RETRY_IDEMPOTENT_ONLY, false));
}
// ----------------------------------------------------------------
@@ -0,0 +1,33 @@
package com.eactive.eai.adapter.http.client.impl.filter;
import java.util.Properties;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
import com.eactive.eai.common.util.Logger;
public class DebugLoggerOutFilter implements HttpClientAdapterFilter, HttpAdapterServiceKey {
protected static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
@Override
public Object doPreFilter(String adptGrpName, String adptName, Properties prop, Object message, Properties tempProp)
throws Exception {
logger.debug("Outbound Filter SND adptGrpName : {}", adptGrpName);
logger.debug("Outbound Filter SND adptName : {}", adptName);
logger.debug("Outbound Filter SND prop : {}", prop);
logger.debug("Outbound Filter SND message : {}", message);
logger.debug("Outbound Filter SND tempProp : {}", tempProp);
return message;
}
@Override
public Object doPostFilter(String adptGrpName, String adptName, Properties prop, Object message,
Properties tempProp) throws Exception {
logger.debug("Outbound Filter RCV adptGrpName : {}", adptGrpName);
logger.debug("Outbound Filter RCV adptName : {}", adptName);
logger.debug("Outbound Filter RCV prop : {}", prop);
logger.debug("Outbound Filter RCV message : {}", message);
logger.debug("Outbound Filter RCV tempProp : {}", tempProp);
return message;
}
}
@@ -31,7 +31,15 @@ public class OutCryptoFilter extends AbstractCryptoFilter implements HttpClientA
* 서브클래스에서 오버라이드하여 tempProp으로부터 값을 추출할 수 있다.
*/
protected Map<String, String> buildRuntimeContext(Properties prop, Properties tempProp) {
return new HashMap<>();
Map<String, String> context = new HashMap<>();
for (String key : prop.stringPropertyNames()) {
context.put(key, prop.getProperty(key));
}
for (String key : tempProp.stringPropertyNames()) {
context.put(key, tempProp.getProperty(key));
}
return context;
}
/**
@@ -39,11 +47,10 @@ public class OutCryptoFilter extends AbstractCryptoFilter implements HttpClientA
* 미설정 또는 값 없으면 null을 반환하여 GCM 기본 동작(IV를 AAD 대체값으로 사용)을 따른다.
*/
protected byte[] buildAad(Properties prop, Properties tempProp) {
String propKey = prop.getProperty(PROP_AAD_HEADER);
if (StringUtils.isBlank(propKey) || tempProp == null) {
return null;
}
String value = tempProp.getProperty(propKey);
if (tempProp == null) {
return null;
}
String value = tempProp.getProperty(PROP_AAD_HEADER);
return StringUtils.isBlank(value) ? null : value.getBytes(StandardCharsets.UTF_8);
}
@@ -85,4 +85,11 @@ public interface HttpAdapterServiceKey {
//응답 처리 용 표준 전문 오브젝트
static final String STANDARD_MESSAGE_OBJECT = "STANDARD_MESSAGE_OBJECT";
//송신(Outbound) 요청 표준 전문 오브젝트
static final String OUT_REQ_STD_MSG = "OUT_REQ_STD_MSG";
// 어댑터별 인증 키 헤더 이름
static final String ADAPTER_TOKEN_HEADER_NAME = "ADAPTER_TOKEN_HEADER_NAME";
static final String ADAPTER_APIKEY_HEADER_NAME = "ADAPTER_APIKEY_HEADER_NAME";
}
@@ -6,7 +6,7 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.MDC;
import org.json.simple.JSONObject;
import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
@@ -18,9 +18,13 @@ import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilterFactory;
import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilterType;
import com.eactive.eai.adapter.http.dynamic.filter.JwtAuthException;
import com.eactive.eai.common.TransactionContextKeys;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.JacksonUtil;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.common.util.TxSiftContext;
import com.eactive.eai.common.util.UUIDGenerator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public abstract class HttpAdapterServiceSupport implements HttpAdapterService, HttpAdapterServiceKey {
private static final String LOG_PREFIX = "HttpAdapterServiceSupport] ";
@@ -28,13 +32,22 @@ public abstract class HttpAdapterServiceSupport implements HttpAdapterService, H
public static final String HEADER_NAME_CLIENT_ID = "x-elink-client-id";
public static final String PROPERTIES_NAME_CLIENT_ID = "clientId";
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
// 기본 ObjectMapper 는 JsonNode 직렬화 시 작은 소수를 1.2E-7 로 바꿔버린다.
private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
EAIServerManager eaiServerManager;
String instid = null;
public Object service(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
String uuid = prop.getProperty(TransactionContextKeys.TRANSACTION_UUID);
boolean bMDCput = false;
if(uuid == null) {
uuid = UUIDGenerator.getUUID().toString().replaceAll("-", "");
if(instid == null) {
eaiServerManager = EAIServerManager.getInstance();
instid = eaiServerManager.getGroupInstId();
}
uuid = instid + UUIDGenerator.getUUID().toString().replaceAll("-", "");
prop.setProperty(TransactionContextKeys.TRANSACTION_UUID, uuid);
}
@@ -63,6 +76,13 @@ public abstract class HttpAdapterServiceSupport implements HttpAdapterService, H
Object obj = null;
try {
message = doPreFilters(adptGrpName, adptName, message, prop, request, response);
if (message instanceof JSONObject) {
message = ((JSONObject) message).toJSONString();
} else if (message instanceof JsonNode) {
message = mapper.writeValueAsString((JsonNode) message);
}
obj = RequestDispatcher.getRequestDispatcher(adptGrpName).handle(adptName, message, prop);
obj = doPostFilters(adptGrpName, adptName, obj, prop, request, response);
} catch (HttpStatusException e) { // inbound error
@@ -70,13 +90,13 @@ public abstract class HttpAdapterServiceSupport implements HttpAdapterService, H
throw e;
} catch (JwtAuthException e) { // filter error
logger.error(LOG_PREFIX + transactionId + "-" + adptGrpName + "-" + adptName + ">>" + e.getMessage(), e);
UnkownMessageLogUtils.logUnkownMessage(adptGrpName, adptName, message, prop, request, response,
"RECEAIIRP202", e);
// UnkownMessageLogUtils.logUnkownMessage(adptGrpName, adptName, message, prop, request, response,
// "RECEAIIRP202", e);
throw e;
} catch (Exception e) { // filter error
logger.error(LOG_PREFIX + transactionId + "-" + adptGrpName + "-" + adptName + ">>" + e.getMessage(), e);
UnkownMessageLogUtils.logUnkownMessage(adptGrpName, adptName, message, prop, request, response,
"RECEAIIRP201", e);
// UnkownMessageLogUtils.logUnkownMessage(adptGrpName, adptName, message, prop, request, response,
// "RECEAIIRP201", e);
throw e;
}
@@ -86,6 +106,10 @@ public abstract class HttpAdapterServiceSupport implements HttpAdapterService, H
return (byte[]) obj;
} else if (obj instanceof String) {
return (String) obj;
} else if (obj instanceof JSONObject) {
return ((JSONObject) obj).toJSONString();
} else if (obj instanceof JsonNode) {
return (JsonNode) obj;
} else {
throw new Exception("RECEAIAHA001");
}
@@ -22,10 +22,10 @@ import com.eactive.eai.inbound.error.InboundErrorInfoVO;
import com.eactive.eai.inbound.error.InboundErrorKeys;
import com.eactive.eai.util.HexaConverter;
public class UnkownMessageLogUtils {
public class UnknownMessageLogUtils {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private UnkownMessageLogUtils() {
private UnknownMessageLogUtils() {
throw new IllegalStateException("Utility class");
}
@@ -49,7 +49,7 @@ public class UnkownMessageLogUtils {
StringBuffer sb = new StringBuffer();
sb.append(errorMsg).append("\n").append("Remote Addr : ").append(request.getRemoteAddr()).append("\n").append("Request URI : ")
.append(request.getRequestURI()).append("\n").append("clientId=").append(clientId).append(",hexClientId=").append(hexClientId).append(",txProp=").append(prop).append("\n").append("Exception : ").append(e.getMessage());
UnkownMessageLogUtils.logUnknownMessage(txId, adptGrpName, adptName,
UnknownMessageLogUtils.logUnknownMessage(txId, adptGrpName, adptName,
"", "", false, errCode, sb.toString(), System.currentTimeMillis(),
serverName, InboundErrorKeys.IN_UNKNOWN, message);
} catch (Exception ex) {
@@ -1,5 +1,26 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.text.ParseException;
import java.util.Base64;
import java.util.Date;
import java.util.HashSet;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceSupport;
import com.eactive.eai.authserver.service.BearerTokenInfo;
//import com.eactive.eai.authserver.vo.BearerTokenInfo;
@@ -13,7 +34,6 @@ import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.session.SessionManager;
import com.eactive.eai.common.stdmessage.STDMessageManager;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.common.util.StringUtil;
import com.eactive.eai.inbound.action.ActionFactory;
import com.eactive.eai.inbound.action.RequestAction;
import com.eactive.eai.inbound.processor.Processor;
@@ -24,31 +44,13 @@ import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.util.IOUtils;
import com.nimbusds.jwt.SignedJWT;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.text.ParseException;
import java.util.Base64;
import java.util.Date;
import java.util.HashSet;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
public class ApiAuthFilter implements HttpAdapterFilter {
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
public static final String PROP_GROUP_AUTH_SERVER = "OAuthServer";
public static final String PROP_KEYSTORE_PATH = "certification.publicKeyPath";
public static final String PASS_SCOPE_LIST = "pass.scope.list";
public static final String ERROR_AUTHENTICATION_FAIL = "E.AUTHENTICATION_FAIL";
public static final String ERROR_AUTHORIZATION_FAIL = "E.AUTHORIZATION_FAIL";
@@ -58,7 +60,7 @@ public class ApiAuthFilter implements HttpAdapterFilter {
public static final String PAYLOAD_PARAM_NAME_CLIENT_ID = "client_id";
private JWSVerifier jwsVerifier;
private String[] passScopeArr = {};
// // CA 토큰 저장소
// private final Map<String, BearerTokenInfo> CATokenStore = new ConcurrentHashMap<>();
@@ -88,6 +90,10 @@ public class ApiAuthFilter implements HttpAdapterFilter {
} catch (IOException | NoSuchAlgorithmException | RuntimeException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
String passScope = vo.getProperty(PASS_SCOPE_LIST, "oob,public");
passScopeArr = org.springframework.util.StringUtils.tokenizeToStringArray(passScope, ",");
}
@Override
@@ -122,7 +128,7 @@ public class ApiAuthFilter implements HttpAdapterFilter {
switch (eaiMessage.getAuthType()){
case "oauth":
try {
String token = extractBearerToken(request);
String token = extractBearerToken(request, prop);
SignedJWT signedJWT = SignedJWT.parse(token);
if (signedJWT.getJWTClaimsSet().getExpirationTime() == null) {
@@ -141,26 +147,32 @@ public class ApiAuthFilter implements HttpAdapterFilter {
OAuth2Manager manager = OAuth2Manager.getInstance();
HashSet<String> scopeSet = manager.getApiScopeMap().get(apiId);
String[] scopeArr = signedJWT.getJWTClaimsSet().getStringArrayClaim(PAYLOAD_PARAM_NAME_SCOPE);
if (scopeArr == null || scopeArr.length == 0) {
throw new JwtAuthException(ERROR_AUTHORIZATION_FAIL,
String.format("Insufficient [Scope](Allowed Scope=%s)", scopeSet.toString()));
}
for (String scope : scopeArr) {
if (StringUtils.equals(scope, "oob") || StringUtils.equals(scope, "public")) {
isPassScope = true;
}
}
// API에 scope 설정이 안되어 있는 경우, scope 체크를 pass하고 나중에 client API 맵을 체크한다.
if(CollectionUtils.isEmpty(scopeSet))
isPassScope = true;
if (!isPassScope) {
if (!verifyScope(scopeSet, signedJWT)) {
if(!isPassScope) {
String[] scopeArr = signedJWT.getJWTClaimsSet().getStringArrayClaim(PAYLOAD_PARAM_NAME_SCOPE);
if (scopeArr == null || scopeArr.length == 0) {
throw new JwtAuthException(ERROR_AUTHORIZATION_FAIL,
String.format("Insufficient [Scope](Allowed Scope=%s)", scopeSet.toString()));
}
for (String scope : scopeArr) {
if (StringUtils.equals(scope, "oob") || StringUtils.equals(scope, "public")) {
isPassScope = true;
}
}
if (!isPassScope) {
if (!verifyScope(scopeSet, signedJWT)) {
throw new JwtAuthException(ERROR_AUTHORIZATION_FAIL,
String.format("Insufficient [Scope](Allowed Scope=%s)", scopeSet.toString()));
}
}
}
// header 값으로 전송된 clientId와 토큰 소유 clientId check
if (!verifyClientId(prop, signedJWT)) {
throw new JwtAuthException(ERROR_AUTHENTICATION_FAIL, "client_id not matched(header/token)");
@@ -174,8 +186,23 @@ public class ApiAuthFilter implements HttpAdapterFilter {
}
break;
case "api_key":
String apiKeyName = PropManager.getInstance().getProperty("ApiConfig", "api.key.name", "x-api-key");
String apiKey = request.getHeader(apiKeyName);
// 기관별로 API Key 헤더명이 다를 수 있어, 어댑터별 설정(ADAPTER_TOKEN_HEADER_NAME)을 우선 사용하고
// 없으면 ApiConfig 전역 설정(api.key.name)을 사용. 콤마로 다중 헤더명 지정 시 순서대로 값이 있는 헤더를 사용
String apiKeyNameConf = prop.getProperty(HttpAdapterServiceKey.ADAPTER_APIKEY_HEADER_NAME);
if (StringUtils.isBlank(apiKeyNameConf)) {
apiKeyNameConf = PropManager.getInstance().getProperty("ApiConfig", "api.key.name", "x-api-key");
}
String[] apiKeyNames = org.springframework.util.StringUtils.tokenizeToStringArray(apiKeyNameConf, ",");
String apiKeyName = null;
String apiKey = null;
for (String name : apiKeyNames) {
String value = request.getHeader(name);
if (StringUtils.isNotBlank(value)) {
apiKeyName = name;
apiKey = value.trim();
break;
}
}
if(apiKey == null){
// QueryString으로 전달된 access_token 파라미터 확인
String apiKeyParamName = PropManager.getInstance().getProperty("ApiConfig", "api.key.param.name", "x-api-key");
@@ -183,14 +210,14 @@ public class ApiAuthFilter implements HttpAdapterFilter {
if (StringUtils.isNotBlank(queryApiKey)) {
apiKey = queryApiKey.trim();
} else {
throw new JwtAuthException(ERROR_AUTHENTICATION_FAIL, "Invalid or missing API key \""+apiKeyName+"\" in Http Header");
throw new JwtAuthException(ERROR_AUTHENTICATION_FAIL, "Invalid or missing API key \""+apiKeyNameConf+"\" in Http Header");
}
}
prop.setProperty(HttpAdapterServiceSupport.PROPERTIES_NAME_CLIENT_ID, apiKey);
isPassScope = true;
break;
case "ca":
String token = extractBearerToken(request);
String token = extractBearerToken(request, prop);
BearerTokenInfo bearerTokenInfo = SessionManager.getInstance().getCAToken(token);
if ( bearerTokenInfo != null ) {
if( bearerTokenInfo.isExpired() ) {
@@ -294,9 +321,6 @@ public class ApiAuthFilter implements HttpAdapterFilter {
return false;
}
String passScope = "oob,public";
String[] passScopeArr = org.springframework.util.StringUtils.tokenizeToStringArray(passScope, ",");
for (String scope : scopeArr) {
for (String pScope : passScopeArr) {
if ( StringUtils.equalsAny(scope, pScope)) {
@@ -311,8 +335,13 @@ public class ApiAuthFilter implements HttpAdapterFilter {
return false;
}
public static String extractBearerToken(HttpServletRequest request) throws JwtAuthException {
String tokenHeaderName = PropManager.getInstance().getProperty("ApiConfig", "token.header.name", "Authorization");
public static String extractBearerToken(HttpServletRequest request, Properties prop) throws JwtAuthException {
// 기관별로 토큰 헤더명이 다를 수 있어, 어댑터별 설정(ADAPTER_APPKEY_HEADER_NAME)을 우선 사용하고
// 없으면 ApiConfig 전역 설정(token.header.name)을 사용
String tokenHeaderName = prop != null ? prop.getProperty(HttpAdapterServiceKey.ADAPTER_TOKEN_HEADER_NAME) : null;
if (StringUtils.isBlank(tokenHeaderName)) {
tokenHeaderName = PropManager.getInstance().getProperty("ApiConfig", "token.header.name", "Authorization");
}
String authorization = request.getHeader(tokenHeaderName);
if (StringUtils.isBlank(authorization)) {
// QueryString으로 전달된 access_token 파라미터 확인
@@ -6,6 +6,7 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpStatus;
import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilter;
import com.eactive.eai.adapter.http.dynamic.filter.JwtAuthException;
@@ -48,7 +49,7 @@ public class ApiKeyExtractFilter implements HttpAdapterFilter {
// PathVariable 지원 추가
String ruledPath = getMatchedKey(requestPath);
if(ruledPath == null)
throw new IllegalAccessException("path not found - " + requestPath);
throw new FilterException("path not found - " + requestPath, ERROR_PRE_FAIL, HttpStatus.FORBIDDEN.value());
prop.setProperty(FINAL_STD_MESSAGE_KEY, ruledPath);// StandardMessageUtil.getMatchedKey(), url
// pathvariable도 처리
@@ -0,0 +1,33 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.eactive.eai.common.util.Logger;
public class DebugLoggerInFilter implements HttpAdapterFilter {
protected static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
@Override
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
logger.debug("Inbound Filter RCV adptGrpName : {}", adptGrpName);
logger.debug("Inbound Filter RCV adptName : {}", adptName);
logger.debug("Inbound Filter RCV message : {}", message);
logger.debug("Inbound Filter RCV prop : {}", prop);
return message;
}
@Override
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
logger.debug("Inbound Filter SND adptGrpName : {}", adptGrpName);
logger.debug("Inbound Filter SND adptName : {}", adptName);
logger.debug("Inbound Filter SND resultMessage : {}", resultMessage);
logger.debug("Inbound Filter SND prop : {}", prop);
return resultMessage;
}
}
@@ -7,6 +7,9 @@ import javax.servlet.http.HttpServletResponse;
public interface HttpAdapterFilter {
public static final String ERROR_PRE_FAIL = "E.PREFILTER_FAIL";
public static final String ERROR_POST_FAIL = "E.POSTFILTER_FAIL";
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception;
@@ -25,13 +25,19 @@ import com.eactive.eai.adapter.http.filter.AbstractCryptoFilter;
*/
public class InCryptoFilter extends AbstractCryptoFilter implements HttpAdapterFilter {
/**
public static final String ERROR_DEC_FAIL = "E.DECRYPT_FAIL";
public static final String ERROR_ENC_FAIL = "E.ENCRYPT_FAIL";
/**
* DYNAMIC 도출용 컨텍스트를 구성한다.
* STATIC 모듈이면 Map({@code new HashMap<>()}) 반환한다.
* 서브클래스에서 오버라이드하여 HttpServletRequest로부터 값을 추출할 있다.
*/
protected Map<String, String> buildRuntimeContext(Properties prop, HttpServletRequest request) {
return new HashMap<>();
Map<String, String> context = new HashMap<>();
for (String key : prop.stringPropertyNames()) {
context.put(key, prop.getProperty(key));
}
return context;
}
/**
@@ -43,7 +49,9 @@ public class InCryptoFilter extends AbstractCryptoFilter implements HttpAdapterF
if (StringUtils.isBlank(headerName) || request == null) {
return null;
}
String headerValue = request.getHeader(headerName);
Properties inboundHeaderProp = (Properties)prop.get("INBOUND_HEADER");
String headerValue = inboundHeaderProp.getProperty(headerName);
// String headerValue = request.getHeader(headerName);
return StringUtils.isBlank(headerValue) ? null : headerValue.getBytes(StandardCharsets.UTF_8);
}
@@ -0,0 +1,67 @@
package com.eactive.eai.adapter.http.dynamic.filter;
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.property.PropManager;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.util.JsonPathUtil;
import com.fasterxml.jackson.databind.JsonNode;
public class JsonToSetStatusFilter implements HttpAdapterFilter {
private static final String PROPGROUP = "JsonToSetStatusFilter";
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
/** HTTP 상태코드 유효 범위 (RFC 7231) */
private static final int MIN_HTTP_STATUS = 100;
private static final int MAX_HTTP_STATUS = 599;
@Override
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
// do nothing
return message;
}
@Override
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
String fieldName = "";
String statusParam = "";
try {
JsonNode rootNode = parseJson(adptGrpName, resultMessage);
fieldName = getFieldName(adptGrpName);
if (rootNode != null && StringUtils.isNotBlank(fieldName) && rootNode.has(fieldName)) {
statusParam = rootNode.get(fieldName).asText();
int httpStatus = Integer.parseInt(statusParam);
if (isValidHttpStatus(httpStatus)) {
response.setStatus(httpStatus);
} else {
logger.warn("유효하지 않은 HTTP 상태코드. 상태코드를 설정하지 않음. fieldName={}, value={}", fieldName, statusParam);
}
} else {
logger.warn("설정과 맞지 않는 메시지. 상태코드를 설정하지 않음. fieldName={}", fieldName);
}
} catch (Exception e) {
logger.warn("상태코드 추출 실패. fieldName={}, value={}", fieldName, statusParam, e);
}
return resultMessage;
}
/** HTTP 상태코드로 사용 가능한 값인지 확인한다. (100 ~ 599) */
private boolean isValidHttpStatus(int httpStatus) {
return httpStatus >= MIN_HTTP_STATUS && httpStatus <= MAX_HTTP_STATUS;
}
private String getFieldName(String adptGrpName) {
return PropManager.getInstance().getProperty(PROPGROUP, adptGrpName);
}
private JsonNode parseJson(String adptGrpName, Object message) throws Exception {
return JsonPathUtil.toTree(message);
}
}
@@ -51,13 +51,6 @@ public class ReflectAllHeaderFilter implements HttpAdapterFilter {
@Override
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
return message;
}
@Override
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
logger.debug("doPreFilter ReflectAllHeaderFilter Start.");
String propValue = PropManager.getInstance().getProperty(PROPERTIES_GROUP_NAME, HEADER_KEY_NAMES, "").trim();
@@ -89,6 +82,45 @@ public class ReflectAllHeaderFilter implements HttpAdapterFilter {
}
logger.debug("doPreFilter ReflectAllHeaderFilter End.");
return message;
}
@Override
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
logger.debug("doPostFilter ReflectAllHeaderFilter Start.");
String propValue = PropManager.getInstance().getProperty(PROPERTIES_GROUP_NAME, HEADER_KEY_NAMES, "").trim();
String[] userSettingBlackList = propValue.split(",");
if( userSettingBlackList.length > 0 ) {
HttpHeaderRelayBlackList = Stream
.concat(Arrays.stream(HttpHeaderRelayBlackList), Arrays.stream(userSettingBlackList))
.toArray(String[]::new);
}
try {
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
String headerValue = request.getHeader(headerName);
if( StringUtils.equalsAnyIgnoreCase( headerName, HttpHeaderRelayBlackList ) ) {
logger.debug("Skip Processing Key ["+headerName+"], value ["+headerValue+"] in HttpHeaderRelayBlackList");
continue;
}
response.setHeader(headerName, headerValue);
}
} catch (Exception e) {
logger.error(e.getMessage());
}
logger.debug("doPostFilter ReflectAllHeaderFilter End.");
return resultMessage;
}
@@ -0,0 +1,263 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.Logger;
/**
* 수신 Http Header <b>허용 목록(white list) 등록된 헤더만</b> 응답 헤더로 복사하는 Inbound Adapter Filter.
*
* <p>모든 헤더를 복사하고 제외 목록으로 걸러내는 {@link ReflectAllHeaderFilter} 달리,
* 복사 대상을 어댑터 그룹별로 명시하는 방식이다.
*
* <p>설정은 <code>HttpHeaderFilter</code> property 그룹에 등록하며, 아래 순서로 가장 먼저 찾은 하나만 사용한다.
* (합집합이 아니라 override 이므로 어댑터 그룹 키를 지정하면 전역 키는 무시된다.)
*
* <pre>
* 1. ReflectHeaderFilter.whiteList.{어댑터그룹명} 어댑터 그룹 단위
* 2. ReflectHeaderFilter.whiteList 전역 기본값
* </pre>
*
* <p>값은 콤마로 구분한 헤더명 목록이며 대소문자를 구분하지 않는다.
* 헤더명 끝에 <code>*</code> 붙이면 접두사 일치로 처리한다.
*
* <pre>
* HttpHeaderFilter.ReflectHeaderFilter.whiteList = x-elink-client-id
* HttpHeaderFilter.ReflectHeaderFilter.whiteList.djbTrans = x-obp-txid, x-obp-partnercode, X-KKB-*
* </pre>
*
* <p>설정이 없거나 비어 있으면 어떤 헤더도 복사하지 않는다.
*/
public class ReflectHeaderFilter implements HttpAdapterFilter {
protected static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
public static final String PROPERTIES_GROUP_NAME = "HttpHeaderFilter";
public static final String HEADER_KEY_NAMES = "ReflectHeaderFilter.whiteList";
/**
* 허용 목록에 등록되어 있어도 복사하지 않는 헤더.
* 요청측 값이 응답 본문/커넥션과 불일치하면 응답 자체가 깨지므로 설정으로 없게 한다.
*/
private static final Set<String> NEVER_REFLECT = createHeaderSet(
"Content-Length",
"Transfer-Encoding",
"Connection",
"Keep-Alive",
"Upgrade",
"TE",
"Trailer");
/** 어댑터 그룹별 허용 목록 캐시. 필터 인스턴스는 HttpAdapterFilterFactory 에서 싱글톤으로 공유된다. */
private final ConcurrentHashMap<String, CachedWhiteList> whiteListCache = new ConcurrentHashMap<String, CachedWhiteList>();
public ReflectHeaderFilter() {
super();
}
@Override
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
logger.debug("doPreFilter ReflectHeaderFilter Start.");
reflectHeaders(adptGrpName, request, response);
logger.debug("doPreFilter ReflectHeaderFilter End.");
return message;
}
@Override
public Object doPostFilter(String adptGrpName, String adptName, Object resultMessage, Properties prop,
HttpServletRequest request, HttpServletResponse response) throws Exception {
logger.debug("doPostFilter ReflectHeaderFilter Start.");
reflectHeaders(adptGrpName, request, response);
logger.debug("doPostFilter ReflectHeaderFilter End.");
return resultMessage;
}
private void reflectHeaders(String adptGrpName, HttpServletRequest request, HttpServletResponse response) {
WhiteList whiteList = getWhiteList(adptGrpName);
if (whiteList.isEmpty()) {
logger.debug("No reflect header configured for adapter group [" + adptGrpName + "]. Skip all.");
return;
}
try {
for (String headerName : whiteList.getNames()) {
String headerValue = request.getHeader(headerName);
if (headerValue == null) {
continue;
}
setHeader(headerName, headerValue, response);
}
// 접두사(*) 설정이 있을 때만 수신 헤더를 순회한다.
if (whiteList.hasPrefix()) {
java.util.Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames != null && headerNames.hasMoreElements()) {
String headerName = headerNames.nextElement();
if (!whiteList.matchesPrefix(headerName)) {
continue;
}
setHeader(headerName, request.getHeader(headerName), response);
}
}
} catch (Exception e) {
logger.error(e.getMessage());
}
}
private void setHeader(String headerName, String headerValue, HttpServletResponse response) {
if (NEVER_REFLECT.contains(headerName)) {
logger.debug("Skip Processing Key [" + headerName + "] in NEVER_REFLECT list");
return;
}
// 헤더명/값에 CR, LF 있으면 응답 분할(response splitting) 위험이 있다.
if (containsCrLf(headerName) || containsCrLf(headerValue)) {
logger.error("Skip Processing Key [" + headerName + "] - CR/LF detected in header name or value");
return;
}
response.setHeader(headerName, headerValue);
logger.debug("Processing Key [" + headerName + "], value [" + headerValue + "]");
}
private boolean containsCrLf(String value) {
return value != null && (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0);
}
/**
* 어댑터 그룹 전역 순으로 property 찾아 허용 목록을 반환한다.
* property 값이 바뀌지 않는 동안은 파싱 결과를 재사용한다.
*/
private WhiteList getWhiteList(String adptGrpName) {
String cacheKey = StringUtils.defaultString(adptGrpName);
String propValue = findPropValue(adptGrpName);
CachedWhiteList cached = whiteListCache.get(cacheKey);
if (cached != null && StringUtils.equals(cached.propValue, propValue)) {
return cached.whiteList;
}
WhiteList whiteList = parse(propValue);
whiteListCache.put(cacheKey, new CachedWhiteList(propValue, whiteList));
return whiteList;
}
private String findPropValue(String adptGrpName) {
PropManager propManager = PropManager.getInstance();
if (StringUtils.isNotBlank(adptGrpName)) {
String value = propManager.getProperty(PROPERTIES_GROUP_NAME, HEADER_KEY_NAMES + "." + adptGrpName, "");
if (StringUtils.isNotBlank(value)) {
return StringUtils.trimToEmpty(value);
}
}
return StringUtils.trimToEmpty(propManager.getProperty(PROPERTIES_GROUP_NAME, HEADER_KEY_NAMES, ""));
}
private static WhiteList parse(String propValue) {
Set<String> names = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
List<String> prefixes = new ArrayList<String>();
if (StringUtils.isNotBlank(propValue)) {
for (String token : propValue.split(",")) {
String name = StringUtils.trimToEmpty(token);
if (name.isEmpty()) {
continue;
}
if (name.endsWith("*")) {
String prefix = name.substring(0, name.length() - 1);
if (!prefix.isEmpty()) {
prefixes.add(prefix);
}
continue;
}
names.add(name);
}
}
return new WhiteList(names, prefixes);
}
private static Set<String> createHeaderSet(String... names) {
Set<String> set = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
Collections.addAll(set, names);
return Collections.unmodifiableSet(set);
}
/** 파싱된 허용 목록. 헤더명 완전일치 목록과 접두사(*) 목록으로 구성된다. */
private static class WhiteList {
private final Set<String> names;
private final List<String> prefixes;
WhiteList(Set<String> names, List<String> prefixes) {
this.names = Collections.unmodifiableSet(names);
this.prefixes = Collections.unmodifiableList(prefixes);
}
boolean isEmpty() {
return names.isEmpty() && prefixes.isEmpty();
}
Set<String> getNames() {
return names;
}
boolean hasPrefix() {
return !prefixes.isEmpty();
}
boolean matchesPrefix(String headerName) {
for (String prefix : prefixes) {
if (StringUtils.startsWithIgnoreCase(headerName, prefix)) {
return true;
}
}
return false;
}
}
private static class CachedWhiteList {
private final String propValue;
private final WhiteList whiteList;
CachedWhiteList(String propValue, WhiteList whiteList) {
this.propValue = propValue;
this.whiteList = whiteList;
}
}
}
@@ -6,6 +6,7 @@ import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.eactive.eai.common.util.JacksonUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import org.apache.commons.lang3.StringUtils;
@@ -66,6 +67,6 @@ public class JsonToSetStatusFilter implements HttpAdapterFilter {
orgMessageString = (String) message;
}
return mapper.readTree(orgMessageString);
return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
}
}
@@ -13,12 +13,15 @@ import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilter;
import com.eactive.eai.common.util.JacksonUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
public class JsonToStdConverterFilter implements HttpAdapterFilter {
private ObjectMapper mapper = new ObjectMapper();
// 파싱 rootNode.toString() 으로 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 쓴다.
// 기본 ObjectMapper 100000000.00 1.0E8 바꿔버린다.
private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
@Override
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
@@ -104,7 +107,7 @@ public class JsonToStdConverterFilter implements HttpAdapterFilter {
orgMessageString = (String) message;
}
return mapper.readTree(orgMessageString);
return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
}
}
@@ -11,6 +11,7 @@ import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilter;
import com.eactive.eai.common.util.JacksonUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -19,7 +20,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
public class KbankEaiJsonParseFilter implements HttpAdapterFilter {
private ObjectMapper mapper = new ObjectMapper();
// 파싱 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 쓴다.
// 기본 ObjectMapper 100000000.00 1.0E8 바꿔버린다.
private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
@Override
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
@@ -30,7 +33,7 @@ public class KbankEaiJsonParseFilter implements HttpAdapterFilter {
for(Iterator<String> it = rootNode.fieldNames(); it.hasNext();) {
String fieldName = it.next();
String value = rootNode.get(fieldName).asText();
JsonNode jsonNode = mapper.readTree(value);
JsonNode jsonNode = mapper.readTree(JacksonUtil.escapeControlChars(value));
replacedJson.set(fieldName, jsonNode);
}
@@ -62,7 +65,7 @@ public class KbankEaiJsonParseFilter implements HttpAdapterFilter {
orgMessageString = (String) message;
}
return mapper.readTree(orgMessageString);
return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
}
}
@@ -16,6 +16,7 @@ import javax.servlet.http.HttpServletResponse;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.JacksonUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
@@ -203,7 +204,7 @@ public class KbankHmacSha256VerifyFilter implements HttpAdapterFilter {
orgMessageString = (String) message;
}
return mapper.readTree(orgMessageString);
return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
}
@Override
@@ -1,5 +1,27 @@
package com.eactive.eai.adapter.http.dynamic.impl;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.StopWatch;
import org.apache.mina.common.ByteBuffer;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.JSONValue;
import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.AdapterPropManager;
@@ -8,8 +30,8 @@ import com.eactive.eai.adapter.Keys;
import com.eactive.eai.adapter.http.HttpMemoryLogger;
import com.eactive.eai.adapter.http.HttpStatusException;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceSupport;
import com.eactive.eai.adapter.http.dynamic.UnknownMessageLogUtils;
import com.eactive.eai.common.TransactionContextKeys;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.util.CommonLib;
@@ -19,30 +41,6 @@ import com.eactive.eai.common.util.MessageUtil;
import com.eactive.eai.env.ElinkConfig;
import com.eactive.eai.inbound.processor.Processor;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.StopWatch;
import org.apache.hc.core5.http.ContentType;
import org.apache.mina.common.ByteBuffer;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.JSONValue;
import org.springframework.jms.support.converter.MessageType;
import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
public class HttpAdapterServiceStandard extends HttpAdapterServiceSupport
{
public static final String PROPERTIES_NAME_HTTP_REQUEST_METHOD = "httpRequestMethod";
@@ -66,7 +64,8 @@ public class HttpAdapterServiceStandard extends HttpAdapterServiceSupport
AdapterGroupVO adapterGroupVo = null;
Properties prop = null;
byte[] requestBytes = null;
String encode = "MS949";
try {
AdapterManager adapterManager = AdapterManager.getInstance();
AdapterVO adptVO = adapterManager.getAdapterVO(adptGrpName,adptName);
@@ -78,9 +77,9 @@ public class HttpAdapterServiceStandard extends HttpAdapterServiceSupport
}
Properties httpProp = manager.getProperties(adptVO.getPropGroupName());
encode = StringUtils.defaultIfBlank(adapterManager.getAdapterGroupVO(adptGrpName).getMessageEncode(), "MS949");
String responseType = httpProp.getProperty(RESPONSE_TYPE,"SYNC");
String urlDecodeYn = httpProp.getProperty(URL_DECODE_YN,"N");
String encode = StringUtils.defaultIfBlank(adapterManager.getAdapterGroupVO(adptGrpName).getMessageEncode(), "MS949");
String messageType = adapterManager.getAdapterGroupVO(adptGrpName).getMessageType();
String requestBodyYn = httpProp.getProperty(REQUEST_BODY_YN,"N");
String parameterName = httpProp.getProperty(PARAMETER_NAME,PARAMETER_MESSAGE);
@@ -107,7 +106,6 @@ public class HttpAdapterServiceStandard extends HttpAdapterServiceSupport
logger.info("시작 >> encode = [" + encode + "]");
byte[] requestBytes = null;
if ("Y".equals(requestBodyYn)){
ServletInputStream sis = request.getInputStream();
@@ -191,19 +189,19 @@ public class HttpAdapterServiceStandard extends HttpAdapterServiceSupport
prop.put(PROPERTIES_NAME_HTTP_REQUEST_METHOD, request.getMethod());
prop.put(ALLOW_IP, httpProp.getProperty(ALLOW_IP, ""));
String body = new String ( requestBytes,encode);
try {
Object root = JSONValue.parse(body);
if( root instanceof JSONArray ) {
JSONObject wrappedObject = new JSONObject();
wrappedObject.put("DJB_ROOTLESS_ARRAY", root);
requestBytes = wrappedObject.toJSONString().getBytes(encode);
}
}catch (Exception e) {
// ignore json이 아니기에 해줄것이 없음.
}
// String body = new String ( requestBytes,encode);
//
// try {
// Object root = JSONValue.parse(body);
// if( root instanceof JSONArray ) {
// JSONObject wrappedObject = new JSONObject();
// wrappedObject.put("DJB_ROOTLESS_ARRAY", root);
// requestBytes = wrappedObject.toJSONString().getBytes(encode);
// }
//
// }catch (Exception e) {
// // ignore json이 아니기에 해줄것이 없음.
// }
// 로컬 서비스 호출
Object result = null;
@@ -215,6 +213,10 @@ public class HttpAdapterServiceStandard extends HttpAdapterServiceSupport
stopWatch.stop();
String apiInternalErrorCode = prop.getProperty("API_INTERNAL_ERROR_CODE", "");
if(StringUtils.isNotEmpty(apiInternalErrorCode))
response.setStatus(500);
byte[] responseBytes = null;
String responseData = "";
if (RESPONSE_TYPE_ASYNC.equals(responseType)){
@@ -256,10 +258,10 @@ public class HttpAdapterServiceStandard extends HttpAdapterServiceSupport
responseData = new String((byte[]) result, encode);
responseBytes = (byte[]) result;
} else if (result instanceof String) {
responseBytes = ((String)result).getBytes();
responseBytes = ((String)result).getBytes(encode);
if ( StringUtils.isBlank((String)result ) ) {
responseData = ElinkConfig.getAsyncDummyDataForAdapterGroup(adptGrpName, adptName);
responseBytes = responseData.getBytes();
responseBytes = responseData.getBytes(encode);
}
}
@@ -338,7 +340,14 @@ public class HttpAdapterServiceStandard extends HttpAdapterServiceSupport
}
} catch (Exception e) {
if (traceLevel >= 3){
try {
UnknownMessageLogUtils.logUnkownMessage(adptGrpName, adptName, requestBytes != null ? new String(requestBytes, encode) : null, prop, request, response,
"RECEAIIRP202", e);
} catch (UnsupportedEncodingException e1) {
UnknownMessageLogUtils.logUnkownMessage(adptGrpName, adptName, requestBytes != null ? new String(requestBytes) : null, prop, request, response,
"RECEAIIRP202", e);
}
if (traceLevel >= 3){
HttpMemoryLogger.error(adptGrpName+adptName, e.toString(),e);
}
logger.error("HttpAdapter] "+adptGrpName+"-"+adptName, e);
@@ -0,0 +1,766 @@
package com.eactive.eai.adapter.http.dynamic.impl;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.fileupload.FileItem;
import org.apache.commons.fileupload.disk.DiskFileItemFactory;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.time.StopWatch;
import org.apache.mina.common.ByteBuffer;
import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.JSONValue;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.util.AntPathMatcher;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.AdapterPropManager;
import com.eactive.eai.adapter.AdapterVO;
import com.eactive.eai.adapter.Keys;
import com.eactive.eai.adapter.http.HttpMemoryLogger;
import com.eactive.eai.adapter.http.HttpMethodType;
import com.eactive.eai.adapter.http.HttpStatusException;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceSupport;
import com.eactive.eai.adapter.http.dynamic.filter.JwtAuthException;
import com.eactive.eai.common.TransactionContextKeys;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.message.MessageType;
import com.eactive.eai.common.util.CommonLib;
import com.eactive.eai.common.util.HttpAdapterExtraLogUtil;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.common.util.MessageUtil;
import com.eactive.eai.env.ElinkConfig;
import com.eactive.eai.inbound.action.ActionFactory;
import com.eactive.eai.inbound.action.RequestAction;
import com.eactive.eai.inbound.processor.Processor;
import com.eactive.eai.message.StandardMessageUtil;
/*
* Kbank 가상계좌 INBOUND
* @see com.eactive.eai.custom.adapter.http.dynamic.filter.VirtualAccountCryptoFilter
* @Deprecated
*/
// FIXME : kbank - kbank에서는 Controller 방식을 사용하므로, 어댑터는 사용되지 않음 (VirtualAccountCryptoFilter 대체)
public class HttpAdapterServiceVirtualAccount extends HttpAdapterServiceSupport {
public static final String HEADER_GROUP = "HEADER_GROUP";
// HEADER_GROUP JSON에 추가할 항목 정의, 없으면 전체 header 추가
public static final String HEADER_KEYS = "HEADER_KEYS";
public static final String HTTP_STATUS = "HTTP_STATUS";
public static final String PROPERTIES_NAME_HTTP_REQUEST_METHOD = "httpRequestMethod";
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
private String logPrefix = "HttpAdapterServiceRest] ";
private static final String JSON_CONTENT_TYPE = "application/json";
private static final String JSON_FIELD_NAME = "json-body";
private static final String FILE_GROUP_NAME = "image-file";
private static final String UPLOAD_ROOT_PATH = "UPLOAD_ROOT_PATH";
private Properties addCryptoFilter(Properties prop) {
String cryptoFilterName = "com.eactive.eai.custom.adapter.http.dynamic.filter.VirtualAccountCryptoFilter";
String addedPreFilter = prop.getProperty(PRE_FILTERS);
String addedPostFilter = prop.getProperty(POST_FILTERS);
if(StringUtils.isBlank(addedPreFilter)) {
addedPreFilter = cryptoFilterName;
}
else {
addedPreFilter = addedPreFilter + "," +cryptoFilterName;
}
if(StringUtils.isBlank(addedPostFilter)) {
addedPostFilter = cryptoFilterName;
}
else {
addedPostFilter = cryptoFilterName + "," +addedPostFilter;
}
prop.setProperty(PRE_FILTERS, addedPreFilter);
prop.setProperty(POST_FILTERS, addedPostFilter);
return prop;
}
private String readMultipartBody(HttpServletRequest request) throws Exception {
String jsonString = null;
// Create a factory for disk-based file items
DiskFileItemFactory factory = new DiskFileItemFactory();
// Set the maximum size of the files to be uploaded
factory.setSizeThreshold(1024 * 1024);
// Set the temporary directory to store uploaded files
File tempDir = (File) request.getSession().getServletContext().getAttribute("javax.servlet.context.tempdir");
factory.setRepository(tempDir);
ServletFileUpload upload = new ServletFileUpload(factory);
Map<String, String> fileMap = new HashMap<>();
InputStream fin = null;
try {
byte[] buffer = new byte[1024];
int read = 0;
List<FileItem> items = upload.parseRequest(request);
for (FileItem item : items) {
if (!item.isFormField()) {
// file
String fieldName = item.getFieldName();
String fileName = item.getName();
fin = item.getInputStream();
ByteArrayOutputStream fo = new ByteArrayOutputStream();
while ((read = fin.read(buffer)) > 0) {
fo.write(buffer, 0, read);
}
int fileSize = fo.size();
byte[] fileBytes = fo.toByteArray();
String fileContents = new String(fileBytes);
if (logger.isInfo()) {
logger.info("[FILE]-------------------------------------------------->");
logger.info("Field name = " + fieldName);
logger.info("File name = " + fileName + " contents length = " + fileSize);
logger.info("File Contents [" + fileContents + "]");
logger.info("[FILE]<--------------------------------------------------");
}
fileMap.put(fileName, fileContents);
fin.close();
} else {
// regular form field
String fieldName = item.getFieldName();
String fieldValue = item.getString();
if (logger.isInfo()) {
logger.info("[FIELD] " + fieldName + " [" + fieldValue + "]");
}
if (JSON_CONTENT_TYPE.equalsIgnoreCase(item.getContentType())
|| JSON_FIELD_NAME.equals(fieldName)) {
jsonString = fieldValue;
}
}
}
if (logger.isInfo()) {
logger.info("Json body [" + jsonString + "]");
}
if (jsonString == null) {
jsonString = "{}";
} else {
// parsing json & add file contents
JSONObject jsonObject = (JSONObject) JSONValue.parse(jsonString);
if (jsonObject == null) {
jsonString = "{}";
} else {
JSONObject fileGroup = new JSONObject();
for (Map.Entry<String, String> entry : fileMap.entrySet()) {
fileGroup.put("fileName", entry.getKey());
fileGroup.put("fileContents", entry.getValue());
}
jsonObject.put(FILE_GROUP_NAME, fileGroup);
jsonString = jsonObject.toJSONString();
}
}
if (logger.isInfo()) {
logger.info("Json with file [" + jsonString + "]");
}
return jsonString;
} catch (Exception e) {
logger.error("Read multipart body error.", e);
throw e;
} finally {
if (fin != null) {
try {
fin.close();
} catch (Exception ex) {
// empty
}
}
}
}
private String checkRootPath(String path) {
if (StringUtils.isEmpty(path)) {
logger.info("upload dir not set(UPLOAD_ROOT_PATH), use system temp " + path);
return System.getProperty("java.io.tmpdir");
}
File file = new File(path);
if (!file.exists()) {
file.mkdirs();
}
return path;
}
private String uploadMultipartBody(HttpServletRequest request, String uploadRootPath) throws Exception {
String jsonString = null;
// Create a factory for disk-based file items
DiskFileItemFactory factory = new DiskFileItemFactory();
// Set the maximum size of the files to be uploaded
factory.setSizeThreshold(1024 * 1024);
// Set the temporary directory to store uploaded files
File tempDir = (File) request.getSession().getServletContext().getAttribute("javax.servlet.context.tempdir");
factory.setRepository(tempDir);
ServletFileUpload upload = new ServletFileUpload(factory);
try {
// if not exist, create folders
String uploadDir = checkRootPath(uploadRootPath);
List<FileItem> items = upload.parseRequest(request);
for (FileItem item : items) {
if (!item.isFormField()) {
// file
String fieldName = item.getFieldName();
String fileName = item.getName();
String uploadFilePath = uploadDir + File.separator + fileName;
File uploadFile = new File(uploadFilePath);
item.write(uploadFile);
if (logger.isInfo()) {
logger.info("[FILE]-------------------------------------------------->");
logger.info("Field name = " + fieldName);
logger.info("File name = " + fileName + " path = " + uploadFile.getAbsolutePath());
logger.info("[FILE]<--------------------------------------------------");
}
} else {
// regular form field
String fieldName = item.getFieldName();
String fieldValue = item.getString();
if (logger.isInfo()) {
logger.info("[FIELD] " + fieldName + " [" + fieldValue + "]");
}
if (JSON_CONTENT_TYPE.equalsIgnoreCase(item.getContentType())
|| JSON_FIELD_NAME.equals(fieldName)) {
jsonString = fieldValue;
}
}
}
if (logger.isInfo()) {
logger.info("Json body [" + jsonString + "]");
}
return jsonString;
} catch (Exception e) {
logger.error("Read multipart body error.", e);
throw e;
}
}
@SuppressWarnings({ "unchecked", "deprecation" })
public void service(String adptGrpName, String adptName, HttpServletRequest request, HttpServletResponse response) {
int traceLevel = 0;
AdapterVO adptVO = null;
AdapterPropManager manager = null;
Properties httpProp = null;
String responseType = null;
String urlDecodeYn = null;
String encode = null;
String traceLevelTemp = null;
String relayRequestHeaderKeys = null;
String headerGroupName = null;
boolean isParameterType = false;
String message = null;
StopWatch stopWatch = null;
Properties prop = null;
String paramValue = null;
String adptMsgType = null;
String errorResponseFormat = null;
String uploadRootPath = null;
try {
AdapterManager adapterManager = AdapterManager.getInstance();
adptVO = adapterManager.getAdapterVO(adptGrpName, adptName);
if (adptVO == null) {
throw new Exception("Adapter not found error");
}
manager = AdapterPropManager.getInstance();
httpProp = manager.getProperties(adptVO.getPropGroupName());
responseType = httpProp.getProperty(RESPONSE_TYPE, "SYNC");
urlDecodeYn = httpProp.getProperty(URL_DECODE_YN, "N");
// encode = httpProp.getProperty(ENCODE, "UTF-8");
encode = StringUtils.defaultIfBlank(adapterManager.getAdapterGroupVO(adptGrpName).getMessageEncode(),
"UTF-8");
traceLevelTemp = httpProp.getProperty(TRACE_LEVEL, "0");
relayRequestHeaderKeys = httpProp.getProperty(HEADER_KEYS);
headerGroupName = httpProp.getProperty(HEADER_GROUP);
errorResponseFormat = httpProp.getProperty(ERROR_RESPONSE_FORMAT);
uploadRootPath = httpProp.getProperty(UPLOAD_ROOT_PATH);
prop = new Properties();
prop.put(INBOUND_METHOD, request.getMethod());
prop.put(INBOUND_URI, request.getRequestURI());
prop.put(INBOUND_HEADER, getHeaders(request));
prop.put(INBOUND_EXTPARAMS, StringUtils.defaultString(request.getQueryString()));
if (StringUtils.equals(adptVO.getAdapterGroupVO().getType(), Keys.TYPE_REST)
|| StringUtils.equals(adptVO.getAdapterGroupVO().getType(), Keys.TYPE_HTTP_CUSTOM)) {
// /api/v1/public/getUserInfo.svc
String extUrl = StringUtils.removeStart(request.getRequestURI(), request.getContextPath());
prop.put(INBOUND_EXTURI, extUrl);
} else {
prop.put(INBOUND_EXTURI, getExtUri(request));
}
prop.put(Processor.REQUEST_ACTION, adptVO.getAdapterGroupVO().getRefClass());
prop.put(API_PATH, httpProp.getProperty(API_PATH, ""));
prop.put(PRE_FILTERS, httpProp.getProperty(PRE_FILTERS, ""));
prop.put(POST_FILTERS, httpProp.getProperty(POST_FILTERS, ""));
prop.put(PROPERTIES_NAME_HTTP_REQUEST_METHOD, request.getMethod());
prop.put(ALLOW_IP, httpProp.getProperty(ALLOW_IP, ""));
isParameterType = false;
try {
traceLevel = Integer.parseInt(traceLevelTemp);
} catch (Exception e) {
traceLevel = 0;
}
stopWatch = new StopWatch();
stopWatch.start();
logger.debug("시작 >> encode = [" + encode + "]");
switch (HttpMethodType.getValue(request.getMethod())) {
case GET:
case DELETE:
isParameterType = true;
break;
case POST:
case PUT:
if (StringUtils.contains(request.getContentType(), "application/x-www-form-urlencoded")) {
isParameterType = true;
} else {
isParameterType = false;
}
break;
default:
break;
}
if (isParameterType) {
paramValue = request.getQueryString();
if (paramValue == null)
paramValue = "";
if (traceLevel >= 3) {
HttpMemoryLogger.txlog(adptGrpName + adptName,
"RECV " + "[" + paramValue + "]" + CommonLib.getDumpMessage(paramValue));
}
// json으로 변환
StringBuilder sb = new StringBuilder();
sb.append("{");
Map<String, String[]> paramMap = assignParameterMap(request, adptGrpName, adptName, null, prop);
int i = 0;
for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
if (i > 0) {
sb.append(",");
}
sb.append("\"").append(entry.getKey()).append("\":");
String[] values = entry.getValue();
if (values.length > 1) {
// ["111", "222"]
sb.append("[");
for (int j = 0; j < values.length; j++) {
if (j > 0) {
sb.append(",");
}
sb.append("\"").append(JSONValue.escape(values[j])).append("\"");
}
sb.append("]");
} else {
sb.append("\"").append(JSONValue.escape(values[0])).append("\"");
}
i++;
}
sb.append("}");
paramValue = sb.toString();
} else {
if (ServletFileUpload.isMultipartContent(request)) {
// TODO : 아래의 로직은 업무에 맞게 수정이 필요함.
// 불필요할 경우 제거
// if(StringUtils.isEmpty(uploadRootPath)) {
// uploadRootPath = System.getProperty("java.io.tmpdir");
// }
// 임시로직 : UPLOAD_ROOT_PATH 없는 경우에는 JSON에 추가
if (StringUtils.isEmpty(uploadRootPath)) {
paramValue = readMultipartBody(request);
} else {
paramValue = uploadMultipartBody(request, uploadRootPath);
}
// TEST : 테스트용 임시코드
// response.setCharacterEncoding(encode);
// response.getWriter().print(paramValue);
// return;
} else {
ServletInputStream sis = request.getInputStream();
ByteBuffer bb = ByteBuffer.allocate(1024).setAutoExpand(true);
int i = 0;
byte[] cbuf = new byte[1024];
while ((i = sis.read(cbuf, 0, 1024)) != -1) {
if (i == 1024) {
bb.put(cbuf);
} else {
byte[] tail = new byte[i];
System.arraycopy(cbuf, 0, tail, 0, i);
bb.put(tail);
}
}
byte[] data = new byte[bb.position()];
bb.position(0);
bb.get(data);
paramValue = new String(data, encode);
if (traceLevel >= 3) {
HttpMemoryLogger.txlog(adptGrpName + adptName,
"RECV " + "[" + paramValue + "]" + CommonLib.getDumpMessage(paramValue));
}
}
}
if (paramValue == null) { // parameter가 없는 경우때문에 처리
paramValue = "";
}
if (logger.isDebug()) {
logger.debug("HttpAdapterServiceRest] RECV (" + adptGrpName + ") = [" + paramValue + "]\n"
+ CommonLib.getDumpMessage(paramValue));
}
if ("Y".equals(urlDecodeYn) && isParameterType) {
message = URLDecoder.decode(paramValue);
} else {
message = paramValue;
}
if (logger.isDebug()) {
String[] msgArgs = new String[2];
msgArgs[0] = adptGrpName;
msgArgs[1] = message;
String resMsg = ExceptionUtil.make("RICEAIAHA005", msgArgs);
logger.debug(logPrefix + resMsg);
}
adptMsgType = adptVO.getAdapterGroupVO().getMessageType();
if (StringUtils.equals(adptMsgType, MessageType.JSON)) {
response.setContentType(JSON_CONTENT_TYPE+"; charset="+encode);
}
// HEADER_GROUP 셋팅
if (MessageType.JSON.equals(adptMsgType) && StringUtils.isNotBlank(headerGroupName)
&& StringUtils.isNotBlank(relayRequestHeaderKeys)) {
JSONObject jsonMessage = (JSONObject) JSONValue.parse(message);
JSONObject headerJson = new JSONObject();
if (StringUtils.equalsIgnoreCase(relayRequestHeaderKeys, "ALL")) {
for (Enumeration<String> e = request.getHeaderNames(); e.hasMoreElements();) {
String key = e.nextElement();
headerJson.put(key, request.getHeader(key));
}
} else {
String[] relayKeyArr = org.springframework.util.StringUtils
.tokenizeToStringArray(relayRequestHeaderKeys, ",");
for (String key : relayKeyArr) {
String headerValue = request.getHeader(key);
if (StringUtils.isNotBlank(headerValue)) {
headerJson.put(key, headerValue);
}
}
}
if (headerJson.size() > 0) {
jsonMessage.put(headerGroupName, headerJson);
message = jsonMessage.toJSONString();
}
}
if (message == null) {
message = "";
}
// com.eactive.eai.custom.adapter.http.dynamic.filter.VirtualAccountCryptoFilter
prop = addCryptoFilter(prop);
// 로컬 서비스 호출 ,encoding 처리 추가
String result = (String) service(adptGrpName, adptName, message, prop, request, response);
if (logger.isDebug()) {
logger.debug("HttpAdapterServiceRest] result " + encode + " (" + adptGrpName + ") = [" + result + "]");
}
stopWatch.stop();
String responseData = "";
if (RESPONSE_TYPE_ASYNC.equals(responseType)) {
if (stopWatch.getTime() > slowTranTime && (logger.isInfo())) {
logger.info("HttpAdapterServiceRest] dummy response time = " + stopWatch.toString() + ", message = "
+ message);
}
if (result == null) {
responseData = ElinkConfig.getAsyncDummyDataForAdapterGroup(adptGrpName);
}
response.setCharacterEncoding(encode);
response.getWriter().print(responseData);
if (traceLevel >= 3) {
HttpMemoryLogger.txlog(adptGrpName + adptName,
"SEND " + "[" + responseData + "]" + CommonLib.getDumpMessage(responseData));
}
} else {
responseData = result;
logger.info("종료 >> encode = [" + encode + "]");
JSONObject dataObject = null;
if (MessageType.JSON.equals(adptMsgType)) {
dataObject = (JSONObject) JSONValue.parse(responseData);
}
// HEADER_GROUP 하위 필드를 response Header에 세팅한다.
HashMap<String, String> header = new HashMap<>();
boolean redirect = assignHttpHeaders(header, dataObject, headerGroupName);
if (logger.isDebug()) {
logger.debug("HttpAdapterServiceRest] response header field (" + adptGrpName + ") = ["
+ header.toString() + "]");
}
if (redirect) {
response.setStatus(302);
logger.debug("HttpAdapterServiceRest] set response status_code: 302");
} else {
String httpStatus = header.get(HTTP_STATUS);
if (httpStatus != null && !"".equals(httpStatus))
response.setStatus(Integer.parseInt(httpStatus));
header.remove(HTTP_STATUS);
}
// response header 셋팅
for (Map.Entry<String, String> entry : header.entrySet()) {
response.setHeader(entry.getKey(), entry.getValue());
}
if (dataObject != null) {
responseData = dataObject.toJSONString();
}
// UI와 통신시(UTF-8) 변환오류로 ENCODE 제거
response.setCharacterEncoding(encode);
response.getWriter().print(responseData);
if (logger.isDebug()) {
logger.debug("HttpAdapterServiceRest] SEND (" + adptGrpName + ") = [" + responseData + "]");
logger.debug("HttpAdapterServiceRest] SEND (" + adptGrpName + ") = "
+ CommonLib.getDumpMessage(responseData));
}
if (traceLevel >= 3) {
HttpMemoryLogger.txlog(adptGrpName + adptName,
"SEND " + "[" + responseData + "]" + CommonLib.getDumpMessage(responseData));
}
}
} catch (HttpStatusException e) {
if (traceLevel >= 3) {
HttpMemoryLogger.error(adptGrpName + adptName, e.toString(), e);
}
logger.warn("HttpAdapter] " + adptGrpName + "-" + adptName + ">>" + e.getMessage());
response.setStatus(e.getStatus());
try {
String errorMsg = MessageUtil.makeErrorMessageByMessageType(adptMsgType, encode, e.getCode(),
e.getMessage(), errorResponseFormat);
response.getWriter().println(errorMsg);
} catch (Exception ex) {
// IGNORE
}
} catch (JwtAuthException e) {
if (traceLevel >= 3) {
HttpMemoryLogger.error(adptGrpName + adptName, e.toString(), e);
}
logger.error(logPrefix + adptGrpName + "-" + adptName + ">>" + e.getMessage(), e);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
try {
String errorMsg = MessageUtil.makeErrorMessageByMessageType(adptMsgType, encode, e.getCode(),
e.getMessage(), errorResponseFormat);
response.getWriter().println(errorMsg);
} catch (Exception ex) {
// IGNORE
}
} catch (Exception e) {
if (traceLevel >= 3) {
HttpMemoryLogger.error(adptGrpName + adptName, e.toString(), e);
}
logger.error(logPrefix + adptGrpName + "-" + adptName + ">>" + e.getMessage(), e);
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
try {
response.getWriter().println(e.getMessage());
String errCode = ExceptionUtil.getErrorCode(e, "RECEAIAHA003");
throw new Exception(errCode);
} catch (Exception ex) {
// IGNORE
logger.warn(logPrefix + adptGrpName + "-" + adptName + ">>" + e.getMessage(), e);
}
} finally {
String uuid = prop.getProperty(TransactionContextKeys.TRANSACTION_UUID);
String url = prop.getProperty(HttpClientAdapterServiceKey.INBOUND_EXTURI);
String method = prop.getProperty(HttpClientAdapterServiceKey.INBOUND_METHOD);
String adapterGroupName = prop.getProperty(HttpClientAdapterServiceKey.ADAPTER_GROUP_NAME);
String adapterName = prop.getProperty(HttpClientAdapterServiceKey.ADAPTER_NAME);
int httpStatusCode = response.getStatus();
HttpAdapterExtraLogUtil.insertHttpAdapterExtraLog(uuid, 400, adapterGroupName, adapterName, new HashMap<>(), url, method, httpStatusCode);
}
}
private Map<String, String[]> assignParameterMap(HttpServletRequest request, String adptGrpName, String adptName,
Object requestBytes, Properties prop) {
// PathVariable 체크
if (StringUtils.equalsAnyIgnoreCase(request.getMethod(), HttpMethod.GET.name(), HttpMethod.DELETE.name())
&& StringUtils.isBlank(request.getQueryString())) {
try {
String actionName = prop.getProperty(Processor.REQUEST_ACTION);
RequestAction action = ActionFactory.createAction(actionName);
action.setAdapterInfo(adptGrpName, adptName, prop);
String[] keys = action.perform(requestBytes);
String requestPath = keys[0];
// PathVariable 지원 추가
String ruledPath = StandardMessageUtil.getMatchedKey(requestPath, actionName);
if (!StringUtils.equals(requestPath, ruledPath) && StringUtils.contains(ruledPath, "{")) {
Map<String, String> paramMap = new AntPathMatcher().extractUriTemplateVariables(ruledPath,
requestPath);
if (paramMap != null && paramMap.size() > 0) {
Map<String, String[]> returnMap = new HashMap<>();
for (String key : paramMap.keySet()) {
if (StringUtils.equalsIgnoreCase(key, "method")) {
continue;
}
returnMap.put(key, new String[] { paramMap.get(key) });
}
return returnMap;
}
}
} catch (Exception e) {
logger.error(e.getMessage());
}
}
return request.getParameterMap();
}
private void validateServiceAndAdapter(String adptGrpName, String adptName, byte[] requestBytes, Properties prop)
throws JwtAuthException {
}
/**
* request header hashmap으로 조립
*
* @param request
* @return
*/
private Properties getHeaders(HttpServletRequest request) {
Properties prop = new Properties();
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String key = headerNames.nextElement();
String value = request.getHeader(key);
prop.setProperty(key, value);
}
return prop;
}
/**
* adapter property의 HEADER_GROUP으로 정의된 (MFE_HEADER) 그룹의 하위 필드를 http Header에
* 세팅한다.
*
* @param header
* @param object
*/
private boolean assignHttpHeaders(HashMap<String, String> header, Object msg, String headerGroupName) {
boolean redirect = false;
if (msg == null || StringUtils.isBlank(headerGroupName)) {
return false;
}
if (msg instanceof JSONObject) {
JSONObject headerObject = (JSONObject) ((JSONObject) msg).get(headerGroupName);
if (headerObject == null) {
return false;
}
// List<String> headerKeys = new ArrayList<>();
for (Object key : headerObject.keySet()) {
Object obj = headerObject.get(key);
if ((obj instanceof JSONObject) || (obj instanceof JSONArray)) {
continue;
}
header.put((String) key, (String) obj);
if (StringUtils.equalsIgnoreCase((String) key, "Location")) {
redirect = true;
}
}
((JSONObject) msg).remove(headerGroupName);
}
return redirect;
}
/**
* 어댑터 이후의 URI값을 가져온다.
*
* @param request
* @return
*/
private String getExtUri(HttpServletRequest request) {
String orgUri = request.getRequestURI().replaceAll(request.getContextPath(), "");
String uri = getExtUri(orgUri, 3);
if (uri != null && uri.trim().length() > 0) {
return "/" + uri;
} else {
return "";
}
}
private static String getExtUri(String url, int length) {
String[] urls = url.split("/");
List<String> newUrls = new ArrayList<>();
Collections.addAll(newUrls, urls);
return StringUtils.join(newUrls.subList(length, urls.length).toArray(), "/");
}
// public static void main(String[] args) throws Exception {
// String orgUri = "/HTT/CbsInNetSys/abcd/123456";
// String result = "";
// result = getExtUri(orgUri, 3);
// System.out.println(result);
// }
}
@@ -66,7 +66,7 @@ public abstract class AbstractCryptoFilter {
* fromPath의 Base64 값을 복호화하여 toPath에 반영. JSON 파싱 1회.
* toPath = "/" fromPath 필드를 제거하고 복호화된 JSON을 body에 병합.
*/
protected String decryptField(String body, String moduleName, Map<String, String> runtimeCtx,
protected Object decryptField(String body, String moduleName, Map<String, String> runtimeCtx,
byte[] aad, String fromPath, String toPath) throws Exception {
JsonNode root = JsonPathUtil.toTree(body);
String encBase64 = JsonPathUtil.getAt(root, fromPath);
@@ -81,7 +81,7 @@ public abstract class AbstractCryptoFilter {
return JsonPathUtil.mergeAtRoot(root, fromPath, plainText);
}
JsonPathUtil.setAt(root, toPath, plainText);
return JsonPathUtil.fromTree(root);
return root;
}
// ------------------------------------------------------------------
@@ -99,7 +99,7 @@ public abstract class AbstractCryptoFilter {
* fromPath 값을 암호화하여 toPath(Base64) 반영. JSON 파싱 1회.
* fromPath = "/" body 전체를 암호화하여 toPath 필드명으로 래핑 (파싱 불필요).
*/
protected String encryptField(String body, String moduleName, Map<String, String> runtimeCtx,
protected Object encryptField(String body, String moduleName, Map<String, String> runtimeCtx,
byte[] aad, String fromPath, String toPath) throws Exception {
if (PATH_ROOT.equals(fromPath)) {
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, aad,
@@ -112,13 +112,13 @@ public abstract class AbstractCryptoFilter {
String plainText = JsonPathUtil.getAt(root, fromPath);
if (StringUtils.isBlank(plainText)) {
logger.warn("CryptoFilter] 암호화 대상 필드 없음: path=" + fromPath);
return body;
return root;
}
byte[] cipherBytes = CryptoModuleService.getInstance().encrypt(moduleName, runtimeCtx, aad,
plainText.getBytes(StandardCharsets.UTF_8));
String encBase64 = Base64.getEncoder().encodeToString(cipherBytes);
JsonPathUtil.setAt(root, toPath, encBase64);
return JsonPathUtil.fromTree(root);
return root;
}
// ------------------------------------------------------------------
@@ -12,8 +12,8 @@ import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.server.Keys;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import com.eactive.ext.djb.DamoManager;
import com.eactive.ext.kjb.safedb.KjbSafedbWrapper;
import com.eactive.ext.kjb.safedb.Utils;
@Component
public class EncryptionManager implements Lifecycle {
@@ -130,7 +130,7 @@ public class EncryptionManager implements Lifecycle {
if (StringUtils.isNotEmpty(plainText)) {
KjbSafedbWrapper wrapper = KjbSafedbWrapper.getInstance();
plainText = wrapper.encryptNotRnnoString(plainText);
if (logger.isInfo()) {
// originalAttribute 홀수 글자 마스킹
StringBuilder sb = new StringBuilder();
@@ -161,14 +161,26 @@ public class EncryptionManager implements Lifecycle {
public String decryptDBData(String dbData) {
if (StringUtils.isNotEmpty(dbData)) {
if (this.isEncrypted(dbData)) {
KjbSafedbWrapper safeDBWrapper = KjbSafedbWrapper.getInstance();
try {
dbData = safeDBWrapper.decryptNotRnno(dbData);
} catch (Exception e) {
// 복호화 실패시 원본 반환
String logData = dbData.length() <= 3 ? dbData : dbData.substring(0, 3) + "...";
logger.warn("DB 복호화 실패: 복호화하지 않고 원본 반환. dbData={} (Length : {})", logData, dbData.length());
return dbData;
if ("DAMO".equals(dbEncryptSolutionName)) {
com.eactive.ext.djb.DamoManager damoManager = new com.eactive.ext.djb.DamoManager();
try {
return damoManager.decrypt(dbData);
} catch (Exception e) {
// 복호화 실패시 원본 반환
String logData = dbData.length() <= 3 ? dbData : dbData.substring(0, 3) + "...";
logger.warn("DB 복호화 실패: 복호화하지 않고 원본 반환. dbData={} (Length : {})", logData, dbData.length());
return dbData;
}
} else if ("SAFEDB".equals(dbEncryptSolutionName)) {
KjbSafedbWrapper safeDBWrapper = KjbSafedbWrapper.getInstance();
try {
dbData = safeDBWrapper.decryptNotRnno(dbData);
} catch (Exception e) {
// 복호화 실패시 원본 반환
String logData = dbData.length() <= 3 ? dbData : dbData.substring(0, 3) + "...";
logger.warn("DB 복호화 실패: 복호화하지 않고 원본 반환. dbData={} (Length : {})", logData, dbData.length());
return dbData;
}
}
} else {
logger.debug("DB 복호화 경고: Base64 형식이 아님. 복호화하지 않고 원본 반환. dbData={}", dbData);
@@ -197,7 +209,7 @@ public class EncryptionManager implements Lifecycle {
data = data.trim();
// Base64 형식 체크
if (!Utils.isBase64(data)) {
if (!isBase64(data)) {
return false;
}
@@ -208,4 +220,13 @@ public class EncryptionManager implements Lifecycle {
return true;
}
public static boolean isBase64(String str) {
if (str == null || str.isEmpty()) {
return false;
}
String base64Pattern = "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$";
return str.matches(base64Pattern);
}
}
@@ -240,33 +240,32 @@ public class AccessTokenManagerByDB implements Lifecycle {
SessionManager.getInstance().getOutboundAccessToken(adapterGroupName, new Function<AccessTokenVO, AccessTokenVO>() {
@Override
public AccessTokenVO apply(AccessTokenVO accessToken) {
long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000);
// // 토큰이 없거나, 다음 스케줄 시간 전에 만료될 경우 재발급
if (accessToken == null) {
return issueToken(credential);
} else if(accessToken.getExpiration() != null) {
// 토큰이 있고 만료시간이 설정 경우
if(accessToken.getExpiration().before(new Date(intervalTime))){
// 다음 스케줄 전에 토큰이 만료되는 경우 재발급
return issueToken(credential);
@Override
public AccessTokenVO apply(AccessTokenVO accessToken) {
long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000);
// // 토큰이 없거나, 다음 스케줄 시간 전에 만료될 경우 재발급
if (accessToken == null) {
logger.debug("Token not exists for adapter group: {}", adapterGroupName);
return issueToken(credential);
} else if(accessToken.getExpiration() != null) {
// 토큰이 있고 만료시간이 설정 경우
if(accessToken.getExpiration().before(new Date(intervalTime))){
// 다음 스케줄 전에 토큰이 만료되는 경우 재발급
logger.debug("Token expired : {}, expiration date: {}", adapterGroupName, accessToken.getExpiration());
return issueToken(credential);
} else {
// 토큰이 아직 유효한 경우
logger.debug("Token still valid until next schedule for adapter group: {}, expiration date: {}", adapterGroupName, accessToken.getExpiration());
return accessToken;
}
} else {
// 토큰이 아직 유효한 경우
logger.debug("Token still valid until next schedule for adapter group: {}, expiration date: {}", adapterGroupName, accessToken.getExpiration());
//토큰이 있지만 만료 시간이 없는 경우
logger.debug("Token exists but expiration time is null for adapter group: {}", adapterGroupName);
return accessToken;
}
} else {
//토큰이 있지만 만료 시간이 없는 경우
logger.debug("Token exists but expiration time is null for adapter group: {}", adapterGroupName);
return accessToken;
}
}
});
logger.debug("Token issuance completed for adapter group: {}", adapterGroupName);
} catch (Exception e) {
logger.error("Token issuance failed for adapter group: {}", adapterGroupName, e);
@@ -327,12 +326,12 @@ public class AccessTokenManagerByDB implements Lifecycle {
logger.warn("No valid adapter configuration found for adapter group: {}", adapterGroupName);
}
}catch (Exception e) {
logger.error("Task execution failed for adapter group: {}", adapterGroupName, e);
}
return accessToken;
} catch (Exception e) {
logger.error("Task execution failed for adapter group: {}", adapterGroupName, e);
}
return accessToken;
}
public void stopToken(String adapterGroupName) {
@@ -474,18 +473,18 @@ public class AccessTokenManagerByDB implements Lifecycle {
if (accessToken == null || accessToken.isExpired()
|| StringUtils.equals(accessToken.getAccessToken(), oldToken)) {
if (isOAuthCredentialRegistered(adapterGroupName) == false) {
throw new Exception(
"There is no OAuthCredentialRegistered information, or whether to use it is 'N'.");
}
String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE");
HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
if (service != null) {
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
}
if (isOAuthCredentialRegistered(adapterGroupName) == false) {
throw new Exception(
"There is no OAuthCredentialRegistered information, or whether to use it is 'N'.");
}
String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE");
HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
if (service != null) {
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
}
}
return accessToken;
@@ -1,9 +1,9 @@
package com.eactive.eai.common.exception;
import java.nio.charset.Charset;
import java.util.Properties;
import org.apache.commons.lang3.StringUtils;
import org.json.simple.JSONValue;
import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
@@ -60,6 +60,9 @@ public class ExceptionHandler {
}
public static EAIMessage handle(EAIMessage eaiMessage) {
Properties prop = eaiMessage.getCallProp();
prop.setProperty("API_INTERNAL_ERROR_CODE", eaiMessage.getRspErrCd());
String inAdapterGroupName = eaiMessage.getSngSysItfTp();
AdapterManager manager = AdapterManager.getInstance();
AdapterGroupVO group = manager.getAdapterGroupVO(inAdapterGroupName);
@@ -788,7 +791,9 @@ public class ExceptionHandler {
}
resultEAIMessage.getMapper().setResponseType(
resultEAIMessage.getStandardMessage(), STDMessageKeys.RESPONSE_TYPE_CODE_E);
resultEAIMessage.setOrgRspErrCd(resultEAIMessage.getRspErrCd());
resultEAIMessage.setRspErrCd(EAIMessageKeys.EAI_SUCCESS_CODE, false);
return resultEAIMessage;
}
}
@@ -0,0 +1,32 @@
package com.eactive.eai.common.hsm;
import lombok.Data;
/**
* HsmCryptoService 캐시의 진단 정보. 원본(바이트/인코딩) 절대 포함하지 않고
* alias, 종류, 알고리즘, 캐시 시각/만료 여부만 노출한다.
*/
@Data
public class HsmCachedKeyInfo {
/** SECRET(대칭키) / PUBLIC(공개키) */
String keyType;
/** HSM KeyStore alias */
String alias;
/** AES, RSA 등 */
String algorithm;
/** 캐시에 등록된 시각 (epoch millis) */
long cachedAt;
/** 캐시 등록 후 경과 시간 (millis) */
long ageMillis;
/** CACHE_TTL_SEC 기준 만료 여부. 만료되어도 HSM 장애 시 fallback 으로 사용된다. */
boolean expired;
/** 키 길이(바이트). non-extractable 등으로 알 수 없으면 -1. 키 값 자체는 노출하지 않는다. */
int keyLength;
}
@@ -7,12 +7,17 @@ import java.security.PrivateKey;
import java.security.Provider;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.PostConstruct;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -44,9 +49,26 @@ public class HsmCryptoService implements PropertyChangeListener {
private static final String PROP_GROUP = "HSM";
private static final String PROP_CACHE_RELOAD_YN = "CACHE_RELOAD_YN";
private static final String PROP_CACHE_TTL_SEC = "CACHE_TTL_SEC";
private static long CACHE_TTL_MS = 10 * 60 * 1000; // 10분, 필요시 PropManager로 외부화
private static class CachedKey<T> {
final T key;
final long cachedAt;
CachedKey(T key) {
this.key = key;
this.cachedAt = System.currentTimeMillis();
}
boolean isExpired() {
return System.currentTimeMillis() - cachedAt > CACHE_TTL_MS;
}
}
// HSM 통신 최소화: 최초 1회 조회 JVM 메모리에 캐싱
private final ConcurrentHashMap<String, SecretKey> secretKeyCache = new ConcurrentHashMap<String, SecretKey>();
private final ConcurrentHashMap<String, PublicKey> publicKeyCache = new ConcurrentHashMap<String, PublicKey>();
private final ConcurrentHashMap<String, CachedKey<SecretKey>> secretKeyCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, CachedKey<PublicKey>> publicKeyCache = new ConcurrentHashMap<>();
@Autowired
private PropManager propManager;
@@ -70,6 +92,9 @@ public class HsmCryptoService implements PropertyChangeListener {
if ("Y".equalsIgnoreCase(reloadYn)) {
clearKeyCache();
}
String propCacheTtlSec = propManager.getProperty(PROP_GROUP, PROP_CACHE_TTL_SEC, "600");
CACHE_TTL_MS = (Integer.parseInt(propCacheTtlSec.trim())) * 1000; // 10분, 필요시 PropManager로 외부화
}
// -------------------------------------------------------------------------
@@ -78,54 +103,97 @@ public class HsmCryptoService implements PropertyChangeListener {
/**
* HSM KeyStore 에서 공개키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다.
* HSM 장애 만료된 캐시가 있으면 그것을 반환하여 서비스 연속성을 유지합니다.
*/
public PublicKey getPublicKey(String keyAlias) throws HsmException {
checkReady();
PublicKey cached = publicKeyCache.get(keyAlias);
if (cached != null) {
return cached;
// 1. 유효한 캐시 즉시 반환 (HSM 상태 무관)
CachedKey<PublicKey> cached = publicKeyCache.get(keyAlias);
if (cached != null && !cached.isExpired()) {
return cached.key;
}
try {
Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias);
if (cert == null) {
throw new HsmException("인증서를 찾을 수 없습니다. alias=" + keyAlias);
// 2. 캐시 미스 또는 만료 HSM 갱신 시도
if (HsmManager.getInstance().isReady()) {
try {
Certificate cert = HsmManager.getInstance().getKeyStore().getCertificate(keyAlias);
if (cert == null) {
throw new HsmException("인증서를 찾을 수 없습니다. alias=" + keyAlias);
}
PublicKey key = cert.getPublicKey();
publicKeyCache.put(keyAlias, new CachedKey<>(key));
logger.warn("HsmCryptoService] 공개키 캐시 등록: alias=" + keyAlias);
return key;
} catch (HsmException e) {
throw e;
} catch (Exception e) {
logger.warn("HsmCryptoService] 공개키 HSM 조회 실패: " + e.getMessage());
}
PublicKey key = cert.getPublicKey();
publicKeyCache.put(keyAlias, key);
logger.warn("HsmCryptoService] 공개키 캐시 등록: alias=" + keyAlias);
return key;
} catch (HsmException e) {
throw e;
} catch (Exception e) {
throw new HsmException("공개키 조회 실패: " + e.getMessage(), e);
}
// 3. HSM 조회 불가 만료 캐시 fallback
if (cached != null) {
logger.warn("HsmCryptoService] HSM 장애, 만료 공개키 캐시 fallback: alias=" + keyAlias);
return cached.key;
}
throw new HsmException("공개키 조회 불가: HSM 장애이며 캐시도 없습니다. alias=" + keyAlias);
}
/**
* HSM KeyStore 에서 AES 대칭키를 반환합니다. 최초 1회만 HSM 통신하고 이후 캐시를 반환합니다.
* HSM 생성 CKA_EXTRACTABLE=true (ctkmu -x 플래그) 생성된 키만 반환됩니다.
*
* [캐싱 전략]
* HSM 에서 가져온 P11SecretKey(PKCS11 핸들 래퍼) 그대로 캐싱하면, HSM Provider
* 재초기화될 세션 무효화로 인해 캐시된 키를 사용한 Cipher 연산이 실패한다.
* 따라서 getEncoded() 바이트를 추출하여 SecretKeySpec(JVM 메모리 ) 으로 변환
* 캐싱한다. encryptAes/decryptAes 이미 JVM 소프트웨어 Cipher 사용하므로,
* HSM Provider 상태와 완전히 독립적으로 동작한다.
*
* HSM 장애 만료된 캐시가 있으면 그것을 반환하여 서비스 연속성을 유지합니다.
*/
public SecretKey getSecretKey(String keyAlias) throws HsmException {
checkReady();
SecretKey cached = secretKeyCache.get(keyAlias);
if (cached != null) {
return cached;
// 1. 유효한 캐시 즉시 반환 (HSM 상태 무관)
CachedKey<SecretKey> cached = secretKeyCache.get(keyAlias);
if (cached != null && !cached.isExpired()) {
return cached.key;
}
try {
KeyStore keyStore = HsmManager.getInstance().getKeyStore();
java.security.Key key = keyStore.getKey(keyAlias, null);
if (!(key instanceof SecretKey)) {
throw new HsmException("AES 키를 찾을 수 없습니다 (CKA_EXTRACTABLE=true 확인 필요). alias=" + keyAlias);
// 2. 캐시 미스 또는 만료 HSM 갱신 시도
if (HsmManager.getInstance().isReady()) {
try {
KeyStore keyStore = HsmManager.getInstance().getKeyStore();
java.security.Key key = keyStore.getKey(keyAlias, null);
if (key == null) {
throw new HsmException("키를 찾을 수 없습니다. alias=" + keyAlias);
}
SecretKey secretKey = (SecretKey) key;
// P11SecretKey SecretKeySpec 변환: HSM Provider 의존성 제거
// getEncoded() null 이면 non-extractable 키이므로 원본 유지
byte[] keyBytes = secretKey.getEncoded();
if (keyBytes != null) {
secretKey = new SecretKeySpec(keyBytes, secretKey.getAlgorithm());
}
secretKeyCache.put(keyAlias, new CachedKey<>(secretKey));
logger.warn("HsmCryptoService] 대칭키 캐시 등록(갱신): alias=" + keyAlias);
return secretKey;
} catch (HsmException e) {
throw e;
} catch (Exception e) {
logger.warn("HsmCryptoService] 대칭키 HSM 조회 실패: " + e.getMessage());
}
SecretKey secretKey = (SecretKey) key;
secretKeyCache.put(keyAlias, secretKey);
logger.warn("HsmCryptoService] 대칭키 캐시 등록: alias=" + keyAlias);
return secretKey;
} catch (HsmException e) {
throw e;
} catch (Exception e) {
throw new HsmException("AES 키 조회 실패: " + e.getMessage(), e);
}
// 3. HSM 조회 불가 만료 캐시 fallback
if (cached != null) {
logger.warn("HsmCryptoService] HSM 장애, 만료 대칭키 캐시 fallback: alias=" + keyAlias);
return cached.key;
}
throw new HsmException("키 조회 불가: HSM 장애이며 캐시도 없습니다. alias=" + keyAlias);
}
/** 캐시를 비웁니다. HSM 키 교체 후 재로드가 필요할 때 호출합니다. */
@@ -135,6 +203,52 @@ public class HsmCryptoService implements PropertyChangeListener {
logger.warn("HsmCryptoService] 키 캐시 초기화 완료");
}
/**
* 현재 캐싱된 목록의 진단 정보를 반환합니다. 원본은 포함하지 않습니다.
* HSM 상태 조회 API(/manage/hsm/status)에서 사용합니다.
*/
public List<HsmCachedKeyInfo> getCachedKeyInfos() {
List<HsmCachedKeyInfo> infos = new ArrayList<>();
for (Map.Entry<String, CachedKey<SecretKey>> entry : secretKeyCache.entrySet()) {
SecretKey key = entry.getValue().key;
byte[] encoded = (key == null) ? null : key.getEncoded();
infos.add(toInfo("SECRET", entry.getKey(), entry.getValue(),
(key == null) ? null : key.getAlgorithm(),
(encoded == null) ? -1 : encoded.length));
}
for (Map.Entry<String, CachedKey<PublicKey>> entry : publicKeyCache.entrySet()) {
PublicKey key = entry.getValue().key;
byte[] encoded = (key == null) ? null : key.getEncoded();
infos.add(toInfo("PUBLIC", entry.getKey(), entry.getValue(),
(key == null) ? null : key.getAlgorithm(),
(encoded == null) ? -1 : encoded.length));
}
infos.sort(Comparator.comparing(HsmCachedKeyInfo::getKeyType)
.thenComparing(HsmCachedKeyInfo::getAlias));
return infos;
}
private HsmCachedKeyInfo toInfo(String keyType, String alias, CachedKey<?> cached,
String algorithm, int keyLength) {
HsmCachedKeyInfo info = new HsmCachedKeyInfo();
info.setKeyType(keyType);
info.setAlias(alias);
info.setAlgorithm(algorithm);
info.setCachedAt(cached.cachedAt);
info.setAgeMillis(System.currentTimeMillis() - cached.cachedAt);
info.setExpired(cached.isExpired());
info.setKeyLength(keyLength);
return info;
}
/** 현재 적용된 키 캐시 TTL(밀리초). HSM.CACHE_TTL_SEC 프로퍼티로 변경된다. */
public long getCacheTtlMs() {
return CACHE_TTL_MS;
}
// -------------------------------------------------------------------------
// RSA
// -------------------------------------------------------------------------
@@ -254,13 +368,4 @@ public class HsmCryptoService implements PropertyChangeListener {
return decryptAes(secretKey, iv, ciphertext, null);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private void checkReady() throws HsmException {
if (!HsmManager.getInstance().isReady()) {
throw new HsmException("HsmManager 가 초기화되지 않았습니다.");
}
}
}
@@ -1,26 +1,40 @@
package com.eactive.eai.common.hsm;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.security.AuthProvider;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.Provider;
import java.security.PublicKey;
import java.security.Security;
import java.security.cert.Certificate;
import java.util.Base64;
import java.security.UnrecoverableKeyException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import javax.crypto.SecretKey;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import com.eactive.eai.agent.encryption.EncryptionManager;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.lifecycle.Lifecycle;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.lifecycle.LifecycleListener;
import com.eactive.eai.common.lifecycle.LifecycleSupport;
import com.eactive.eai.common.property.PropGroupVO;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
@@ -29,33 +43,88 @@ import com.eactive.eai.common.util.Logger;
* SafeNet ProtectServer HSM 연동 관리자 (JDK 8 / SunPKCS11)
*
* PropManager 그룹 "HSM" 에서 읽는 :
* PKCS11_CONFIG - pkcs11.cfg 파일 내용
* PIN - HSM 슬롯 PIN
* PKCS11_CONFIG - primary pkcs11.cfg 파일 내용
* PIN - primary 슬롯 PIN
* PKCS11_CONFIG_SECONDARY - secondary pkcs11.cfg 파일 내용 (미설정 이중화 비활성)
* PIN_SECONDARY - secondary 슬롯 PIN (미설정 PIN 재사용)
* RELOAD_INTERVAL_MINUTES - KeyStore 주기적 재로드 간격()
*
* PKCS11_CONFIG 예시 (개행은 \n 으로 입력):
* name = ProtectServer
* library = /opt/safenet/protecttoolkit5/ptk/lib/libcryptoki.so
* slot = 0
*
*
* name = SoftHSM
* library = C:/SoftHSM2/lib/softhsm2-x64.dll
* slotListIndex = 0
*
* ---------------------------------------------------------------------
* [설정 이중화 / primary 자동 복귀]
* 기동(init) 재연결 항상 primary -> secondary 순서로 연결을 시도하고,
* 최초로 성공한 설정을 사용한다. secondary 절체된 뒤에는 주기적 재로드마다
* primary 재연결을 먼저 시도하므로 primary 복구되면 자동으로 되돌아온다.
*
* [세션 누적 방지 / 회로차단 로직]
* 재로드마다 KeyStore.getInstance(...).load(null, pin) 새로 호출하면
* PKCS11 세션(C_OpenSession) 계속 누적되고, 결국 HSM 파티션의 최대 세션 수를
* 초과하면서 reload 영구적으로 실패한다. 이를 방지하기 위해:
* 1) primary 동작 중이고 설정도 그대로면 "기존 keyStore 인스턴스" 다시
* load() 하여 세션을 재사용한다 (= primary 우선 시도와 동일한 의미)
* 2) 실제 조회(probe) 세션이 살아있는지 검증
* 3) 재로드가 실패하거나 프로퍼티가 변경되면 primary -> secondary 순서로
* Provider 완전히 재생성
* 4) 재생성마저 실패하면 마지막으로 성공한 keyStore 유지 (서비스 연속성 우선)
*
* [프로퍼티 즉시 반영]
* PropManager PropertyChangeListener 등록되어 있어, 관리 포털에서 HSM 그룹을
* reload 하면 다음 스케줄을 기다리지 않고 즉시 설정을 다시 읽어 재연결을 시도한다.
* 경우에도 신규 연결이 완전히 성공한 뒤에만 keyStore 멤버변수를 교체한다.
* ---------------------------------------------------------------------
*/
@Component
public class HsmManager implements Lifecycle {
public class HsmManager implements Lifecycle, PropertyChangeListener {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static final String GROUP_NAME = "HSM";
private static final String PROP_CONFIG = "PKCS11_CONFIG";
private static final String PROP_PIN = "PIN";
private static final String PROP_CONFIG_SECONDARY = "PKCS11_CONFIG_SECONDARY";
private static final String PROP_PIN_SECONDARY = "PIN_SECONDARY";
private static final String PROP_RELOAD_INTERVAL_MINUTES = "RELOAD_INTERVAL_MINUTES";
/** 설정 구분자. HSM 상태 조회 API 응답에도 그대로 노출된다. */
public static final String CONFIG_PRIMARY = "PRIMARY";
public static final String CONFIG_SECONDARY = "SECONDARY";
private static final long DEFAULT_RELOAD_INTERVAL_MINUTES = 10;
private Provider pkcs11Provider;
private KeyStore keyStore;
private volatile KeyStore keyStore;
private boolean started;
private final LifecycleSupport lifecycle = new LifecycleSupport(this);
private volatile char[] pin;
/** 현재 연결에 사용 중인 설정. 미연결이면 null. */
private volatile HsmConfig activeConfig;
// ---- 모니터링용 상태값 (HsmStatusController 에서 조회) ----
private volatile long activeSince;
private volatile long lastReloadAt;
private volatile String lastReloadResult;
private volatile String lastErrorMessage;
private volatile long lastErrorAt;
private volatile int reloadSuccessCount;
private volatile int reloadFailCount;
private volatile int failoverCount;
private volatile long reloadIntervalMinutes = DEFAULT_RELOAD_INTERVAL_MINUTES;
private ScheduledExecutorService scheduler;
private ScheduledFuture<?> reloadFuture;
private volatile boolean propListenerRegistered;
private HsmManager() {
}
@@ -63,6 +132,112 @@ public class HsmManager implements Lifecycle {
return ApplicationContextProvider.getContext().getBean(HsmManager.class);
}
// -------------------------------------------------------------------------
// 설정 후보 (primary / secondary)
// -------------------------------------------------------------------------
/**
* 번의 연결 시도에 필요한 설정 . PropManager 에서 읽어 복호화까지 마친 값이다.
*/
static final class HsmConfig {
final String name;
final String configContent;
final char[] pin;
HsmConfig(String name, String configContent, char[] pin) {
this.name = name;
this.configContent = configContent;
this.pin = pin;
}
/** PropManager 값이 바뀌었는지 판단하기 위한 비교. */
boolean sameAs(HsmConfig other) {
if (other == null) {
return false;
}
return name.equals(other.name)
&& configContent.equals(other.configContent)
&& Arrays.equals(pin, other.pin);
}
}
/**
* 연결 시도 결과. Security 등록 상태이며, 완전히 성공한 경우에만
* applyConnection() 통해 멤버변수로 승격된다.
*/
private static final class HsmConnection {
final HsmConfig config;
final Provider provider;
final KeyStore keyStore;
HsmConnection(HsmConfig config, Provider provider, KeyStore keyStore) {
this.config = config;
this.provider = provider;
this.keyStore = keyStore;
}
}
/**
* PropManager 에서 매번 새로 읽어 primary -> secondary 순서의 연결 후보를 만든다.
* 스케줄러/프로퍼티 변경 이벤트 모두 메서드를 통하므로 변경된 값이 즉시 반영된다.
* PKCS11_CONFIG 비어 있는 후보는 제외한다.
*/
private List<HsmConfig> loadConfigCandidates() {
PropManager propManager = PropManager.getInstance();
EncryptionManager encManager = EncryptionManager.getInstance();
List<HsmConfig> candidates = new ArrayList<>();
String primaryConfig = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_CONFIG));
String primaryPin = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_PIN));
if (StringUtils.isNotBlank(primaryConfig)) {
candidates.add(new HsmConfig(CONFIG_PRIMARY, normalizeConfig(primaryConfig), toPin(primaryPin)));
}
String secondaryConfig = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_CONFIG_SECONDARY));
if (StringUtils.isNotBlank(secondaryConfig)) {
// PIN_SECONDARY 미설정이면 primary PIN 재사용한다 (슬롯만 이중화하는 구성 지원)
String rawSecondaryPin = propManager.getProperty(GROUP_NAME, PROP_PIN_SECONDARY);
String secondaryPin = StringUtils.isNotBlank(rawSecondaryPin) ? decrypt(encManager, rawSecondaryPin) : primaryPin;
candidates.add(new HsmConfig(CONFIG_SECONDARY, normalizeConfig(secondaryConfig), toPin(secondaryPin)));
}
return candidates;
}
private static String decrypt(EncryptionManager encManager, String value) {
if (value == null) {
return null;
}
return encManager.decryptDBData(value);
}
private static String normalizeConfig(String configContent) {
return configContent.trim().replace("\\n", "\n");
}
private static char[] toPin(String pinStr) {
return (pinStr != null) ? pinStr.toCharArray() : null;
}
private static HsmConfig findByName(List<HsmConfig> candidates, String name) {
if (name == null) {
return null;
}
for (HsmConfig candidate : candidates) {
if (name.equals(candidate.name)) {
return candidate;
}
}
return null;
}
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
@Override
public void start() throws LifecycleException {
if (started) {
@@ -72,6 +247,8 @@ public class HsmManager implements Lifecycle {
try {
init();
startReloadScheduler();
registerPropertyChangeListener();
} catch (Exception e) {
throw new LifecycleException(ExceptionUtil.getErrorCode(e, "RECEAIHSM002"));
}
@@ -81,51 +258,385 @@ public class HsmManager implements Lifecycle {
}
private void init() throws Exception {
String configContent = PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG);
String pin = PropManager.getInstance().getProperty(GROUP_NAME, PROP_PIN);
if (configContent == null || configContent.trim().isEmpty()) {
applyReloadInterval();
List<HsmConfig> candidates = loadConfigCandidates();
if (candidates.isEmpty()) {
logger.warn("HsmManager] PKCS11_CONFIG 가 설정되지 않았습니다. HSM 초기화를 건너뜁니다.");
return;
}
pkcs11Provider = createProvider(configContent.trim().replace("\\n", "\n"));
HsmConnection connection = connectFirstAvailable(candidates);
applyConnection(connection);
// 이미 등록된 Provider 있으면 제거 재등록
Provider existing = Security.getProvider(pkcs11Provider.getName());
if (existing != null) {
Security.removeProvider(existing.getName());
logger.warn("HsmManager] 초기화 완료. config=" + connection.config.name
+ ", Provider=" + connection.provider.getName());
}
/**
* RELOAD_INTERVAL_MINUTES 다시 읽어 적용한다.
*
* @return 값이 변경되어 스케줄 재등록이 필요하면 true
*/
private boolean applyReloadInterval() {
long newInterval = reloadIntervalMinutes;
try {
String value = PropManager.getInstance().getProperty(GROUP_NAME, PROP_RELOAD_INTERVAL_MINUTES,
String.valueOf(DEFAULT_RELOAD_INTERVAL_MINUTES));
if (StringUtils.isNotBlank(value)) {
newInterval = Long.parseLong(value.trim());
}
} catch (Exception e) {
logger.warn("HsmManager] " + PROP_RELOAD_INTERVAL_MINUTES + " 값이 올바르지 않아 기존 값("
+ reloadIntervalMinutes + "분)을 유지합니다: " + e.getMessage());
return false;
}
Security.addProvider(pkcs11Provider);
keyStore = KeyStore.getInstance("PKCS11", pkcs11Provider);
char[] pinChars = (pin != null) ? pin.toCharArray() : null;
keyStore.load(null, pinChars);
if (newInterval <= 0) {
newInterval = DEFAULT_RELOAD_INTERVAL_MINUTES;
}
if (newInterval == reloadIntervalMinutes) {
return false;
}
reloadIntervalMinutes = newInterval;
return true;
}
logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName());
// -------------------------------------------------------------------------
// 연결
// -------------------------------------------------------------------------
/**
* 후보 설정을 primary -> secondary 순서로 시도하여 최초로 성공한 연결을 반환한다.
* 후보는 Provider 생성 + KeyStore.load + 목록 조회(probe)까지 모두 성공해야
* 정상으로 간주한다. 전부 실패하면 마지막 예외를 던진다.
*/
private HsmConnection connectFirstAvailable(List<HsmConfig> candidates) throws Exception {
Exception lastException = null;
for (HsmConfig config : candidates) {
try {
HsmConnection connection = connect(config);
logger.warn("HsmManager] HSM 연결 성공. config=" + config.name
+ ", Provider=" + connection.provider.getName());
return connection;
} catch (Exception e) {
lastException = e;
recordError(config.name + " 설정 연결 실패: " + e.getMessage());
logger.warn("HsmManager] HSM 연결 실패. config=" + config.name + " : " + e.getMessage(), e);
}
}
if (lastException != null) {
throw lastException;
}
throw new HsmException("HSM 연결 후보 설정이 없습니다.");
}
/**
* 단일 설정으로 Provider/KeyStore 새로 생성한다. Security 에는 아직 등록하지 않으므로
* 여기서 실패해도 현재 사용 중인 Provider/keyStore 영향을 받지 않는다.
*/
private HsmConnection connect(HsmConfig config) throws Exception {
Provider provider = createProvider(config.configContent);
KeyStore ks = KeyStore.getInstance("PKCS11", provider);
ks.load(null, config.pin);
logKeyStore(ks);
return new HsmConnection(config, provider, ks);
}
/**
* 신규 연결을 멤버변수로 승격한다. 기존 Provider 시점에서만 logout/제거된다.
*/
private void applyConnection(HsmConnection connection) {
Provider oldProvider = this.pkcs11Provider;
HsmConfig oldConfig = this.activeConfig;
if (oldProvider != null && oldProvider != connection.provider) {
if (oldProvider instanceof AuthProvider) {
try {
((AuthProvider) oldProvider).logout();
} catch (Exception logoutEx) {
logger.warn("HsmManager] 기존 세션 logout 실패(무시하고 진행): " + logoutEx.getMessage());
}
}
Security.removeProvider(oldProvider.getName());
}
// 동일 이름으로 이미 등록된 Provider 있으면 제거 재등록
Provider registered = Security.getProvider(connection.provider.getName());
if (registered != null && registered != connection.provider) {
Security.removeProvider(registered.getName());
}
Security.addProvider(connection.provider);
this.pkcs11Provider = connection.provider;
this.keyStore = connection.keyStore;
this.activeConfig = connection.config;
this.pin = connection.config.pin;
this.activeSince = System.currentTimeMillis();
if (oldConfig != null && !oldConfig.name.equals(connection.config.name)) {
failoverCount++;
logger.warn("HsmManager] HSM 설정 절체: " + oldConfig.name + " -> " + connection.config.name);
}
}
private void logKeyStore(KeyStore keyStore)
throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
java.util.Enumeration<String> aliases = keyStore.aliases();
StringBuilder aliasList = new StringBuilder();
while (aliases.hasMoreElements()) {
if (aliasList.length() > 0) aliasList.append(", ");
String alias = aliases.nextElement();
aliasList.append(alias);
java.security.Key key = keyStore.getKey(alias, null);
if (key instanceof SecretKey) {
SecretKey secretKey = (SecretKey) key;
if(secretKey.getEncoded() != null) {
String encBase64 = Base64.getEncoder().encodeToString(secretKey.getEncoded());
logger.debug("HsmManager] HSM - {} : [{}]", alias, encBase64);
} else {
logger.debug("HsmManager] HSM - secretKey null {} : [{}]", alias, secretKey);
}
SecretKey secretKey = (SecretKey) key;
if (secretKey.getEncoded() == null) {
logger.warn("HsmManager] HSM - secretKey null {} : [{}]", alias, secretKey);
}
}
}
logger.warn("HsmManager] HSM 키 목록: [" + aliasList + "]");
}
// -------------------------------------------------------------------------
// 주기적 재로드
// -------------------------------------------------------------------------
/**
* 별도 스레드에서 주기적으로 KeyStore 다시 로드하여
* HSM 새로 생성/추가된 키를 인식하도록 한다.
*/
private void startReloadScheduler() {
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "hsm-keystore-reloader");
t.setDaemon(true);
return t;
});
reloadFuture = scheduler.scheduleWithFixedDelay(
this::reloadKeyStoreSafely,
reloadIntervalMinutes,
reloadIntervalMinutes,
TimeUnit.MINUTES
);
logger.warn("HsmManager] KeyStore 주기적 재로드 스케줄러 시작. interval=" + reloadIntervalMinutes + "");
}
/** RELOAD_INTERVAL_MINUTES 변경 시 스케줄을 다시 등록한다. */
private synchronized void rescheduleReload() {
if (scheduler == null || scheduler.isShutdown()) {
return;
}
if (reloadFuture != null) {
reloadFuture.cancel(false);
}
reloadFuture = scheduler.scheduleWithFixedDelay(
this::reloadKeyStoreSafely,
reloadIntervalMinutes,
reloadIntervalMinutes,
TimeUnit.MINUTES
);
logger.warn("HsmManager] KeyStore 재로드 주기 변경 적용: " + reloadIntervalMinutes + "");
}
/**
* 스케줄러에서 호출되는 래퍼. 예외가 스케줄러 스레드를 죽이지 않도록 반드시 catch 한다.
* (ScheduledExecutorService task 에서 예외가 던져지면 이후 스케줄을 자동으로 중단시킨다)
*/
private void reloadKeyStoreSafely() {
try {
reloadKeyStoreIfNeeded();
} catch (Throwable t) {
logger.warn("HsmManager] KeyStore 주기적 재로드 실패: " + t.getMessage(), t);
}
}
/**
* KeyStore 다시 로드한다. 외부(getSecretKey )에서 미스 발생
* 즉시 재시도용으로 직접 호출할 수도 있다.
*
* 처리 순서:
* 1) PropManager 에서 설정을 매번 새로 읽는다 (변경값 즉시 반영)
* 2) 설정이 변경되었거나 아직 연결이 없으면 primary -> secondary 순으로 전체 재연결
* 3) 현재 secondary 동작 중이면 primary 복귀를 먼저 시도
* 4) 외에는 기존 keyStore 인스턴스에 다시 load() (PKCS11 세션 재사용).
* 실패하면 primary -> secondary 순으로 전체 재연결
*
* 어느 경로든 신규 연결이 완전히 성공한 경우에만 keyStore 멤버변수를 교체한다.
*/
public synchronized void reloadKeyStoreIfNeeded() throws Exception {
List<HsmConfig> candidates = loadConfigCandidates();
if (candidates.isEmpty()) {
logger.warn("HsmManager] PKCS11_CONFIG 가 설정되지 않아 재로드를 건너뜁니다.");
return;
}
HsmConfig active = this.activeConfig;
HsmConfig currentCandidate = findByName(candidates, active == null ? null : active.name);
// 2) 미연결 상태이거나 현재 사용 중인 설정값 자체가 변경된 경우
if (active == null || pkcs11Provider == null || currentCandidate == null
|| !currentCandidate.sameAs(active)) {
logger.warn("HsmManager] HSM 설정 변경 또는 미연결 상태 감지 → 전체 재연결을 수행합니다.");
reconnect(candidates);
return;
}
// 3) secondary 동작 중이면 primary 복귀를 우선 시도
HsmConfig preferred = candidates.get(0);
if (!preferred.name.equals(active.name)) {
try {
HsmConnection connection = connect(preferred);
applyConnection(connection);
recordReloadSuccess(preferred.name + " 설정으로 복귀 성공");
logger.warn("HsmManager] " + preferred.name + " 설정으로 복귀했습니다.");
return;
} catch (Exception e) {
logger.warn("HsmManager] " + preferred.name + " 복귀 시도 실패. 현재 " + active.name
+ " 설정을 유지합니다: " + e.getMessage());
}
}
// 4) 현재 연결 유지 재로드 (세션 재사용)
try {
if (keyStore != null) {
keyStore.load(null, pin);
logKeyStore(keyStore);
logger.warn("HsmManager] KeyStore 재로드 완료 (config=" + active.name + ", 기존 세션 재사용).");
} else {
KeyStore ks = KeyStore.getInstance("PKCS11", pkcs11Provider);
ks.load(null, pin);
logKeyStore(ks);
this.keyStore = ks;
logger.warn("HsmManager] KeyStore 신규 생성 완료 (config=" + active.name + ").");
}
recordReloadSuccess(active.name + " 설정 재로드 성공");
} catch (Exception e) {
logger.warn("HsmManager] KeyStore 재로드 실패:" + e.getMessage(), e);
logger.warn("HsmManager] 후보 설정(PRIMARY→SECONDARY) 전체 재연결을 시도합니다.");
reconnect(candidates);
}
}
/**
* 세션 누적, 네트워크 단절, 설정 변경 등으로 일반 재로드가 이상 복구되지 않을
* primary -> secondary 순서로 Provider 완전히 새로 생성한다.
*
* 신규 Provider/KeyStore 준비가 완전히 성공한 후에만 기존 Provider 제거하고 교체한다.
* 모든 후보가 실패하면 기존 Provider keyStore 그대로 유지한다
* (서비스 중단보다 마지막 정상 상태 보존을 우선).
*/
private void reconnect(List<HsmConfig> candidates) throws Exception {
try {
HsmConnection connection = connectFirstAvailable(candidates);
applyConnection(connection);
recordReloadSuccess(connection.config.name + " 설정 재연결 성공");
logger.warn("HsmManager] HSM 재연결 성공. config=" + connection.config.name);
} catch (Exception e) {
recordReloadFailure(e.getMessage());
logger.warn("HsmManager] HSM 재연결 실패. 이전 keyStore 를 그대로 유지합니다: " + e.getMessage(), e);
throw e;
}
}
private void recordReloadSuccess(String detail) {
lastReloadAt = System.currentTimeMillis();
lastReloadResult = "SUCCESS - " + detail;
reloadSuccessCount++;
}
private void recordReloadFailure(String detail) {
lastReloadAt = System.currentTimeMillis();
lastReloadResult = "FAIL - " + detail;
reloadFailCount++;
recordError(detail);
}
private void recordError(String message) {
lastErrorMessage = message;
lastErrorAt = System.currentTimeMillis();
}
// -------------------------------------------------------------------------
// PropManager 변경 즉시 반영
// -------------------------------------------------------------------------
private void registerPropertyChangeListener() {
if (propListenerRegistered) {
return;
}
try {
PropManager.getInstance().addPropertyChangeListener(this);
propListenerRegistered = true;
logger.warn("HsmManager] PropManager PropertyChangeListener 등록 완료");
} catch (Exception e) {
logger.warn("HsmManager] PropManager PropertyChangeListener 등록 실패(주기적 재로드로 대체): "
+ e.getMessage());
}
}
private void unregisterPropertyChangeListener() {
if (!propListenerRegistered) {
return;
}
try {
PropManager.getInstance().removePropertyChangeListener(this);
} catch (Exception e) {
logger.warn("HsmManager] PropManager PropertyChangeListener 해제 실패(무시): " + e.getMessage());
}
propListenerRegistered = false;
}
/**
* 관리 포털에서 PropManager.reload("HSM") 또는 setProperty 호출하면 수신된다.
* 재로드 자체는 HSM 통신을 수반하므로 호출 스레드를 막지 않도록 스케줄러 스레드에 위임한다.
*/
@Override
public void propertyChange(PropertyChangeEvent evt) {
if (!isHsmGroupEvent(evt)) {
return;
}
if (applyReloadInterval()) {
rescheduleReload();
}
ScheduledExecutorService currentScheduler = this.scheduler;
if (currentScheduler == null || currentScheduler.isShutdown()) {
return;
}
logger.warn("HsmManager] HSM 프로퍼티 변경 감지 → 설정 재적용을 요청합니다.");
currentScheduler.execute(this::reloadKeyStoreSafely);
}
/**
* PropManager reload(group) propertyName 그룹명을, setProperty 키명을 담고
* source 에는 항상 해당 그룹의 PropGroupVO 담는다. 경우를 모두 인식한다.
*/
private boolean isHsmGroupEvent(PropertyChangeEvent evt) {
if (evt == null) {
return false;
}
if (GROUP_NAME.equals(evt.getPropertyName())) {
return true;
}
Object source = evt.getSource();
return (source instanceof PropGroupVO) && GROUP_NAME.equals(((PropGroupVO) source).getName());
}
// -------------------------------------------------------------------------
// Provider 생성
// -------------------------------------------------------------------------
/**
* JDK 버전에 따라 SunPKCS11 Provider 생성한다.
*
@@ -161,11 +672,29 @@ public class HsmManager implements Lifecycle {
}
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
unregisterPropertyChangeListener();
if (reloadFuture != null) {
reloadFuture.cancel(false);
}
if (scheduler != null) {
scheduler.shutdown();
}
if (pkcs11Provider != null) {
if (pkcs11Provider instanceof AuthProvider) {
try {
((AuthProvider) pkcs11Provider).logout();
} catch (Exception e) {
logger.warn("HsmManager] 종료 시 logout 실패(무시): " + e.getMessage());
}
}
Security.removeProvider(pkcs11Provider.getName());
}
pkcs11Provider = null;
keyStore = null;
activeConfig = null;
activeSince = 0;
started = false;
lifecycle.fireLifecycleEvent(STOPPED_EVENT, this);
@@ -202,4 +731,114 @@ public class HsmManager implements Lifecycle {
public boolean isReady() {
return started && pkcs11Provider != null && keyStore != null;
}
/**
* isReady() 달리 실제 HSM 호출로 세션 생존 여부까지 확인하는 헬스체크.
* 모니터링/헬스체크 엔드포인트에서 사용을 권장한다.
*/
public boolean isHealthy() {
if (!isReady()) {
return false;
}
try {
logKeyStore(keyStore);
return true;
} catch (Exception e) {
return false;
}
}
// -------------------------------------------------------------------------
// 모니터링용 조회 (HsmStatusController)
// -------------------------------------------------------------------------
/** 현재 연결에 사용 중인 설정 이름(PRIMARY/SECONDARY). 미연결이면 null. */
public String getActiveConfigName() {
HsmConfig config = this.activeConfig;
return (config == null) ? null : config.name;
}
/** 현재 secondary 설정으로 절체된 상태인지 여부. */
public boolean isUsingSecondaryConfig() {
return CONFIG_SECONDARY.equals(getActiveConfigName());
}
/** 현재 연결에 실제로 사용된 pkcs11.cfg 내용. PIN 은 포함되지 않는다. */
public String getActiveConfigContent() {
HsmConfig config = this.activeConfig;
return (config == null) ? null : config.configContent;
}
/**
* 현재 PropManager 기준으로 실제 적용될 pkcs11.cfg 내용을 후보별로 반환한다.
* PIN 포함하지 않는다. (설정이름 -> cfg 내용, primary 우선순)
*/
public java.util.Map<String, String> getResolvedConfigContents() {
java.util.Map<String, String> contents = new java.util.LinkedHashMap<>();
try {
for (HsmConfig config : loadConfigCandidates()) {
contents.put(config.name, config.configContent);
}
} catch (Exception e) {
logger.warn("HsmManager] 연결 후보 설정 조회 실패: " + e.getMessage());
}
return contents;
}
/** 현재 KeyStore 의 alias 목록. HSM 통신이 발생한다. */
public List<String> getKeyAliases() throws Exception {
KeyStore ks = this.keyStore;
if (ks == null) {
return Collections.emptyList();
}
List<String> aliases = new ArrayList<>();
java.util.Enumeration<String> e = ks.aliases();
while (e.hasMoreElements()) {
aliases.add(e.nextElement());
}
return aliases;
}
public String getProviderName() {
Provider provider = this.pkcs11Provider;
return (provider == null) ? null : provider.getName();
}
public long getReloadIntervalMinutes() {
return reloadIntervalMinutes;
}
/** 현재 설정으로 연결된 시각(epoch millis). 미연결이면 0. */
public long getActiveSince() {
return activeSince;
}
public long getLastReloadAt() {
return lastReloadAt;
}
public String getLastReloadResult() {
return lastReloadResult;
}
public String getLastErrorMessage() {
return lastErrorMessage;
}
public long getLastErrorAt() {
return lastErrorAt;
}
public int getReloadSuccessCount() {
return reloadSuccessCount;
}
public int getReloadFailCount() {
return reloadFailCount;
}
/** primary <-> secondary 절체가 발생한 횟수. */
public int getFailoverCount() {
return failoverCount;
}
}
@@ -104,8 +104,9 @@ public class InflowControlDAO extends BaseDAO {
for (InflowControl inflowControl : inflowControls) {
InflowTargetVO vo = new InflowTargetVO();
vo.setName(inflowControl.getId().getName());
vo.setThreshold(inflowControl.getThreshold());
vo.setThresholdPerSecond(inflowControl.getThresholdpersecond());
vo.setThreshold(inflowControl.getThreshold() == null ? 0 : inflowControl.getThreshold());
vo.setThresholdPerSecond(
inflowControl.getThresholdpersecond() == null ? 0 : inflowControl.getThresholdpersecond());
vo.setThresholdTimeUnit(inflowControl.getThresholdtimeunit());
vo.setActivate(!"0".equals(inflowControl.getUseyn()));
targetList.put(vo.getName(), vo);
@@ -1,6 +1,7 @@
package com.eactive.eai.common.logger;
import java.util.List;
import java.util.Properties;
import com.eactive.eai.adapter.ElinkAdapter;
@@ -150,7 +151,7 @@ public class DBLogTransactionLogger implements TransactionLogger {
// 템플릿만 남겨두고, 나머지는 사이트에 맞게 수정 필요함.
//---------------------------------------------------->
boolean itsmEnabled = false;
if((itsmEnabled) && !MessageUtil.checkRspErrCd(message.getRspErrCd())) {
if((itsmEnabled) && !MessageUtil.checkRspErrCd(message.getLogRspErrCd())) {
if(logger.isDebug()) {
logger.debug(guidLogPrefix + " ITSM Error Message Notify! ");
}
@@ -176,6 +177,83 @@ public class DBLogTransactionLogger implements TransactionLogger {
}
}
/**
* 1. 기능 : 여러 건의 로깅 정보를 트랜잭션으로 적재 (COMMIT 1회)
* 2. 처리 개요 :
* - log() 건별 처리와 달리 COMMIT 횟수를 배치 크기만큼 줄인다.
* - LOG_TYPE 판정 규칙은 log() 동일하게 유지한다.
* 3. 주의사항
* - 실시간 모니터링(EAIServiceMonitor) 전달은 발행측 EAILogSender.send()에서
* 이미 처리하므로 여기서는 다루지 않는다.
*
* @param items {EAIMessage, Properties} 쌍의 목록
**/
public void logBatch(List<Object[]> items) {
if (items == null || items.isEmpty()) return;
logCount += items.size();
try {
insertLogBatch(items);
} catch (Exception e) {
errCount++;
if (logger.isError()) logger.error("DBLogTransactionLogger] logBatch ERROR. - " + e.getMessage(), e);
}
}
public static void insertLogBatch(List<Object[]> items) {
if (items == null || items.isEmpty()) return;
// Direct DB Logging - 판정 규칙은 log() 동일
String logType = "DB";
String setLogType = PropManager.getInstance().getProperty("LOG_TYPE");
if (setLogType != null) {
logType = setLogType;
}
if (!"DB".equals(logType) || !EAIDBLogControl.isEnable()) {
for (Object[] item : items) {
writeFileLog((EAIMessage) item[0], (Properties) item[1]);
}
return;
}
EAILogBatchWriter writer = ApplicationContextProvider.getContext().getBean(EAILogBatchWriter.class);
try {
writer.writeBatch(items);
} catch (Exception be) {
// 배치는 건만 실패해도 트랜잭션 전체가 롤백된다.
// 정상 건까지 유실되지 않도록 건별 독립 트랜잭션으로 재시도한다.
if (logger.isError()) {
logger.error("DBLogTransactionLogger] insertLogBatch failed, retry one by one. size=" + items.size(), be);
}
for (Object[] item : items) {
EAIMessage eaiMessage = (EAIMessage) item[0];
Properties prop = (Properties) item[1];
try {
writer.writeOne(eaiMessage, prop);
} catch (Exception e) {
String message = e.getMessage();
// DB Connection Error일 경우에만 DB 로깅을 중단한다.
if ("ConnectionError".equals(message) || StringUtils.contains(message, "JDBCConnectionException")
|| StringUtils.contains(message, "Unable to acquire JDBC Connection")) {
EAIDBLogControl.setEnable(false);
}
if (logger.isError()) {
logger.error("DBLogTransactionLogger] insertLogBatch single retry failed. - " + message, e);
}
writeFileLog(eaiMessage, prop);
}
}
}
}
private static void writeFileLog(EAIMessage eaiMessage, Properties prop) {
try {
EAIFileLogger.getInstance().setLog(eaiMessage, prop);
} catch (Exception fe) {
if (logger.isError()) logger.error("DBLogTransactionLogger] file log failed. - " + fe.getMessage(), fe);
}
}
public static void insertLog(EAIMessage eaiMessage, Properties prop) throws EAILogException {
String guidLogPrefix = "DBLogTransactionLogger] GUID["+ eaiMessage.getMapper().getGuid(eaiMessage.getStandardMessage())
+"] UUID["+eaiMessage.getSvcOgNo()+"] ";
@@ -276,6 +276,8 @@ public class EAIFileLogger
String psvItfTp = svcMsg.getPsvItfTp(); // 수동 Syunc/Async
int logPssSno = message.getLogPssSno();
String rspErrCd = message.getRspErrCd();
// 내부오류 대체응답 원본 에러코드 기준으로 로깅한다.
String logRspErrCd = message.getLogRspErrCd();
// Duplication Error 방지를 위해
// 로그처리일련번호를 저장 : 2009.07.13
@@ -629,7 +631,7 @@ public class EAIFileLogger
sb.appendAndDelimeter( NullControl.addSpace(message.getSngSysItfTp())); //기동시스템어댑터업무그룹명
sb.appendAndDelimeter( NullControl.addSpace(message.getLydMsgID())); //현재메시지ID명
sb.appendAndDelimeter( NullControl.addSpace(message.getRspErrCd())); //응답에러코드명
sb.appendAndDelimeter( NullControl.addSpace(logRspErrCd)); //응답에러코드명
sb.appendAndDelimeter( msgPssTm); //메시지처리시각
sb.appendAndDelimeter( String.valueOf(message.getSvrLogLvl())); //서버로그레벨번호
//index 20
@@ -715,8 +717,9 @@ public class EAIFileLogger
sb.appendAndDelimeter( NullControl.addSpace(message.getRspnsChngMsgType())); // 응답변환유형
// log level 'E' or 'F'
if ("E".equals(message.getRspErrCd().substring(1, 2)) || "F".equals(message.getRspErrCd().substring(1, 2))) {
sb.appendAndDelimeter( NullControl.addSpace(message.getRspErrCd())); //EAI에러코드
String logErrLvl = StringUtils.substring(logRspErrCd, 1, 2);
if ("E".equals(logErrLvl) || "F".equals(logErrLvl)) {
sb.appendAndDelimeter( NullControl.addSpace(logRspErrCd)); //EAI에러코드
sb.appendAndDelimeter( StringUtil.chunkString(message.getRspErrMsg(),1000)); //EAI에러내용
}
else {
@@ -0,0 +1,49 @@
package com.eactive.eai.common.logger;
import java.util.List;
import java.util.Properties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.eactive.eai.common.message.EAIMessage;
/**
* 1. 기능 : 비동기 거래로그를 여러 묶어 트랜잭션으로 적재
* 2. 처리 개요 :
* - EAILogDAO는 클래스 레벨 @Transactional(기본 propagation REQUIRED)이므로
* writeBatch 안에서 호출하면 별도 트랜잭션을 열지 않고 바깥 트랜잭션에 합류한다.
* - 결과적으로 N건이 COMMIT 1회로 처리되어 Oracle log file sync 대기가 1/N로 줄어든다.
* 3. 주의사항
* - 배치 건이라도 예외가 나면 트랜잭션 전체가 롤백된다.
* 호출측(DBLogTransactionLogger.insertLogBatch)에서 건별 재시도로 폴백해야 한다.
*/
@Service
public class EAILogBatchWriter {
@Autowired
private EAILogDAO dao;
/**
* N건을 하나의 트랜잭션으로 적재한다. (COMMIT 1회)
*
* @param items {EAIMessage, Properties} 쌍의 목록
*/
@Transactional
public void writeBatch(List<Object[]> items) throws Exception {
for (Object[] item : items) {
dao.addEAISvcLog((EAIMessage) item[0], (Properties) item[1]);
}
}
/**
* 배치 실패 건별 재시도용. 건이 독립 트랜잭션이므로
* 특정 건의 실패가 나머지 건에 영향을 주지 않는다.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeOne(EAIMessage message, Properties prop) throws Exception {
dao.addEAISvcLog(message, prop);
}
}
@@ -220,6 +220,8 @@ public class EAILogDAO {
String psvItfTp = svcMsg.getPsvItfTp(); // 수동 Syunc/Async
int logPssSno = message.getLogPssSno();
String rspErrCd = message.getRspErrCd();
// 내부오류 대체응답 원본 에러코드 기준으로 로깅한다.
String logRspErrCd = message.getLogRspErrCd();
// Duplication Error 방지를 위해
// 로그처리일련번호를 저장 : 2009.07.13
@@ -565,7 +567,7 @@ public class EAILogDAO {
// 현재메시지ID명
eaiLog.setPrsntmsgidname(message.getLydMsgID());
// 응답에러코드명
eaiLog.setRspnserrcdname(message.getRspErrCd());
eaiLog.setRspnserrcdname(logRspErrCd);
// 메시지처리시각
eaiLog.setMsgprcssyms(msgPssTm);
// 서버로그레벨번호
@@ -659,10 +661,10 @@ public class EAILogDAO {
eaiLog.setRspnschngmsgtype(message.getRspnsChngMsgType());
// log level 'E' or 'F'
if ("E".equals(message.getRspErrCd().substring(1, 2))
|| "F".equals(message.getRspErrCd().substring(1, 2))) {
String logErrLvl = StringUtils.substring(logRspErrCd, 1, 2);
if ("E".equals(logErrLvl) || "F".equals(logErrLvl)) {
// EAI에러코드
eaiLog.setEaierrcd(message.getRspErrCd());
eaiLog.setEaierrcd(logRspErrCd);
// EAI에러내용
eaiLog.setEaierrctnt(StringUtil.chunkString(message.getRspErrMsg(), 1000));
}
@@ -724,11 +726,11 @@ public class EAILogDAO {
}
// 에러로그를 별도의 테이블에 저장하도록 한다.
if (!MessageUtil.checkRspErrCd(message.getRspErrCd())) {
if (!MessageUtil.checkRspErrCd(logRspErrCd)) {
try {
// 거래통제, 유량제어에 의한 에러는 저장하지 않도록 한다.
if (!(EAIMessageKeys.EAI_BLOCKED_CODE.equals(message.getRspErrCd())
|| EAIMessageKeys.EAI_INFLOW_BLOCKED_CODE.equals(message.getRspErrCd()))) {
if (!(EAIMessageKeys.EAI_BLOCKED_CODE.equals(logRspErrCd)
|| EAIMessageKeys.EAI_INFLOW_BLOCKED_CODE.equals(logRspErrCd))) {
addErrorLog(message); // 에러로그
}
} catch (Exception ex) {
@@ -741,6 +743,8 @@ public class EAILogDAO {
public void addErrorLog(EAIMessage message) throws DAOException {
try {
String serverName = EAIServerManager.getInstance().getLocalServerName();
// 내부오류 대체응답 원본 에러코드 기준으로 로깅한다.
String logRspErrCd = message.getLogRspErrCd();
EAIErrorLog eaiErrorLog = (EAIErrorLog) applicationContext.getBean(RollingTable.class, EAIErrorLog.class,
message.getMsgRcvTm());
@@ -760,16 +764,16 @@ public class EAILogDAO {
// EAI서비스명
eaiErrorLog.setEaisvcname(message.getEAISvcCd());
// 응답에러코드명
eaiErrorLog.setRspnserrcdname(message.getRspErrCd());
eaiErrorLog.setRspnserrcdname(logRspErrCd);
// 기동시스템어댑터업무그룹명
eaiErrorLog.setGstatsysadptrbzwkgroupname(message.getSngSysItfTp());
// 수동시스템어댑터업무그룹명
eaiErrorLog.setPsvsysadptrbzwkgroupname(message.getCurrentSvcMsg().getPsvSysItfTp());
if ("E".equals(message.getRspErrCd().substring(1, 2))
|| "F".equals(message.getRspErrCd().substring(1, 2))) {
String logErrLvl = StringUtils.substring(logRspErrCd, 1, 2);
if ("E".equals(logErrLvl) || "F".equals(logErrLvl)) {
// EAI에러코드
eaiErrorLog.setEaierrcd(message.getRspErrCd());
eaiErrorLog.setEaierrcd(logRspErrCd);
// EAI에러내용
eaiErrorLog.setEaierrctnt(StringUtil.chunkString(message.getRspErrMsg(), 500));
}
@@ -1,5 +1,6 @@
package com.eactive.eai.common.logger;
import java.util.List;
import java.util.Properties;
import org.apache.commons.lang3.SerializationUtils;
@@ -71,7 +72,7 @@ public class EAILogSender {
}
}
else if(svcLogLvl == 1) {
isLogging = ! MessageUtil.checkRspErrCd(message.getRspErrCd());
isLogging = ! MessageUtil.checkRspErrCd(message.getLogRspErrCd());
}
else {
isLogging = false;
@@ -105,23 +106,48 @@ public class EAILogSender {
// 실시간 모니터링 로그
EAIServiceMonitor servicemonitor = EAIServiceMonitor.getInstance();
if(EAIMessageKeys.EAI_BLOCKED_CODE.equals(message.getRspErrCd())) {
if(logger.isWarn()) {
logger.warn(guidLogPrefix + " 거래통제 실시간 모니터링 SKIP - " + message.getEAISvcCd()
+ ", "+message.getMapper().getGuid(message.getStandardMessage()) );
}
}else if (EAIMessageKeys.EAI_INFLOW_BLOCKED_CODE.equals(message.getRspErrCd())) {
if(logger.isWarn()) {
logger.warn(guidLogPrefix + " 유량제어 실시간 모니터링 SKIP - " + message.getEAISvcCd()
+ ", "+ message.getMapper().getGuid(message.getStandardMessage()) );
}
}else {
servicemonitor.receiveLogMessage(message);
}
servicemonitor.receiveLogMessage(message);
// if(EAIMessageKeys.EAI_BLOCKED_CODE.equals(message.getRspErrCd())) {
// if(logger.isWarn()) {
// logger.warn(guidLogPrefix + " 거래통제 실시간 모니터링 SKIP - " + message.getEAISvcCd()
// + ", "+message.getMapper().getGuid(message.getStandardMessage()) );
// }
// }else if (EAIMessageKeys.EAI_INFLOW_BLOCKED_CODE.equals(message.getRspErrCd())) {
// if(logger.isWarn()) {
// logger.warn(guidLogPrefix + " 유량제어 실시간 모니터링 SKIP - " + message.getEAISvcCd()
// + ", "+ message.getMapper().getGuid(message.getStandardMessage()) );
// }
// }else {
// servicemonitor.receiveLogMessage(message);
// }
}
}
public static void logDirect(EAIMessage message, Properties prop) throws EAILogException {
txLogger.log(message, prop);
txLogger.log(message, prop);
}
/**
* 여러 건을 트랜잭션(COMMIT 1회)으로 적재한다.
* 비동기 로깅 컨슈머(CustomEventHandler) 모아둔 배치를 넘길 사용한다.
*
* @param items {EAIMessage, Properties} 쌍의 목록
*/
public static void logDirectBatch(List<Object[]> items) {
if (items == null || items.isEmpty()) return;
if (txLogger instanceof DBLogTransactionLogger) {
((DBLogTransactionLogger) txLogger).logBatch(items);
return;
}
// 배치를 지원하지 않는 TransactionLogger 구현이면 건별 처리로 폴백한다.
for (Object[] item : items) {
try {
txLogger.log((EAIMessage) item[0], (Properties) item[1]);
} catch (Exception e) {
logger.error("logDirectBatch fallback failed.", e);
}
}
}
}
@@ -1,16 +1,22 @@
package com.eactive.eai.common.logger;
import java.util.List;
import com.eactive.eai.common.logger.mapper.HttpAdapterExtraLogMapper;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.data.entity.onl.logger.HttpAdapterExtraLog;
import org.apache.commons.lang.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class HttpLoggingService {
@Autowired
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
@Autowired
private HttpAdapterExtraLogMapper mapper;
@Autowired
@@ -19,11 +25,26 @@ public class HttpLoggingService {
@Autowired
private HttpAdapterExtraLogFileLogger fileLogger;
/**
* 여러 건을 트랜잭션(COMMIT 1회)으로 적재한다.
* HttpAdapterExtraLogLogger가 @Transactional(REQUIRED)이므로 트랜잭션에 합류한다.
*
* 배치 건이라도 실패하면 전체가 롤백되므로,
* 호출측에서 insertHttpAdapterExtraLog() 건별 재시도해야 한다.
*/
@Transactional
public void insertHttpAdapterExtraLogBatch(List<HttpAdapterExtraLogVo> voList) throws Throwable {
for (HttpAdapterExtraLogVo vo : voList) {
dbLogger.save(mapper.toEntity(vo));
}
}
public void insertHttpAdapterExtraLog(HttpAdapterExtraLogVo httpAdapterExtraLogVo) throws Throwable{
HttpAdapterExtraLog httpAdapterExtraLog = mapper.toEntity(httpAdapterExtraLogVo);
if (EAIDBLogControl.isEnable()) {
try {
dbLogger.save(httpAdapterExtraLog);
logger.debug("inserted to DB");
} catch(Exception e){
String message = e.getMessage();
// 오류 메시지 추가 - Could not open JPA EntityManager for transaction; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
@@ -33,9 +54,11 @@ public class HttpLoggingService {
EAIDBLogControl.setEnable(false);
}
fileLogger.writeFileLog(httpAdapterExtraLog);
logger.debug("wrote to File");
}
} else {
fileLogger.writeFileLog(httpAdapterExtraLog);
logger.debug("wrote to File");
}
}
}
@@ -3,59 +3,124 @@ package com.eactive.eai.common.logger.async;
import java.util.ArrayList;
import java.util.List;
import com.eactive.eai.common.logger.EAILogException;
import com.eactive.eai.common.logger.EAILogSender;
import com.eactive.eai.common.message.EAIMessage;
import com.eactive.eai.common.util.Logger;
import com.lmax.disruptor.EventHandler;
import com.lmax.disruptor.LifecycleAware;
import com.lmax.disruptor.TimeoutHandler;
public class CustomEventHandler implements EventHandler<LoggingEvent> {
/**
* 1. 기능 : 거래로그 이벤트를 모아 트랜잭션(COMMIT 1회)으로 적재
* 2. 처리 개요 :
* - Disruptor의 endOfBatch는 "지금 링버퍼에 더 처리할 이벤트가 없다" 신호다.
* 이것을 flush 조건으로 쓰면 한산할 때는 건당 즉시 적재되어 지연이 늘지 않고,
* 부하가 몰릴 때만 배치가 커진다. 별도 타임아웃 flush 스레드가 필요 없다.
* - batchSize는 하한이 아니라 상한이다. "100건 모일 때까지 대기" 아니라
* "한 트랜잭션이 100건을 넘지 않게 끊는다" 의미다.
* - 안전망으로 TimeoutHandler를 구현한다. 유휴 상태가 지속되면 Disruptor가
* onTimeout() 호출하므로, 버퍼에 남은 로그가 방치되지 않는다.
* (WaitStrategy가 TimeoutBlockingWaitStrategy = 설정값 "TIME" 동작. 기본값)
* 3. 주의사항
* - LoggingEvent.clear() EAIMessage 내용까지 비우므로 버퍼에 담은 호출하면 된다.
* 슬롯 참조만 끊고, EAIMessage 해제는 적재 완료 releaseMessages()에서 처리한다.
*/
public class CustomEventHandler implements EventHandler<LoggingEvent>, LifecycleAware, TimeoutHandler {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
String name;
int sleepMs;
private int batchSize = 1;
private int count = 0;
private final List<LoggingEvent> eventList = new ArrayList<>();
public CustomEventHandler() {
}
private final String name;
private final int sleepMs;
private final int batchSize;
private final List<Object[]> buffer;
public CustomEventHandler(String name, int sleepMs, int batchSize) {
this.name = name;
this.sleepMs = sleepMs;
this.batchSize = batchSize;
this.batchSize = (batchSize < 1) ? 1 : batchSize;
this.buffer = new ArrayList<Object[]>(this.batchSize);
}
@Override
public void onEvent(LoggingEvent event, long sequence, boolean endOfBatch) throws Exception {
if(sleepMs > 0) Thread.sleep(sleepMs);
if(batchSize > 1) {
eventList.add(event);
if (++count >= batchSize) {
processBatch();
eventList.clear();
count = 0;
}
if (sleepMs > 0) Thread.sleep(sleepMs);
EAIMessage message = event.getMessage();
if (message != null) {
buffer.add(new Object[] { message, event.getProperty() });
}
// 링버퍼 슬롯은 재사용되므로 참조만 끊는다. (event.clear() 사용 금지 - 상단 주석 참고)
event.setMessage(null);
event.setProperty(null);
if (endOfBatch || buffer.size() >= batchSize) {
flush();
}
else {
EAIMessage message = event.getMessage();
if(logger.isInfo()) {
logger.info(String.format("CustomWorkHandler: %s LoggingEvent: %s %s\n"
,name, message.getSvcOgNo() ,message.getLogPssSno())
);
}
EAILogSender.logDirect(event.getMessage(), event.getProperty());
if(event != null) {
event.clear();
event = null;
}
}
}
private void processBatch() throws EAILogException {
for(LoggingEvent event:eventList) {
EAILogSender.logDirect(event.getMessage(), event.getProperty());
private void flush() {
if (buffer.isEmpty()) return;
int size = buffer.size();
// info 레벨이 꺼져 있으면 시각 측정 자체를 하지 않는다.
final boolean measure = logger.isInfo();
final long t0 = measure ? System.currentTimeMillis() : 0L;
try {
EAILogSender.logDirectBatch(buffer);
} catch (Throwable th) {
logger.error(String.format("%s] batch log failed. size=%d", name, size), th);
} finally {
// DB 적재 시간만 측정한다. releaseMessages() EAIMessage 100건의
// setBizData(null)/svcMsgs.clear() 도는 비용이라 측정에 섞이면 된다.
final long elapsed = measure ? (System.currentTimeMillis() - t0) : 0L;
releaseMessages();
buffer.clear();
if (measure) {
logger.info("{} batch logging. size={}, elapsed={}ms", name, size, elapsed);
}
}
}
/** 적재가 끝난 EAIMessage의 내부 버퍼를 해제한다. (기존 LoggingEvent.clear()가 하던 역할) */
private void releaseMessages() {
for (Object[] item : buffer) {
EAIMessage message = (EAIMessage) item[0];
if (message == null) continue;
try {
message.clear();
} catch (Exception e) {
// 해제 실패는 적재 결과에 영향이 없으므로 무시한다.
}
}
}
/**
* 유휴 Disruptor가 호출하는 안전망.
* 정상 흐름에서는 endOfBatch로 이미 flush되지만, onEvent가 예외로 중단되어
* 버퍼가 남은 거래가 끊기는 경우를 대비한다.
*/
@Override
public void onTimeout(long sequence) throws Exception {
if (buffer.isEmpty()) return;
if (logger.isDebug()) {
logger.debug(String.format("%s] flush by timeout. remain=%d", name, buffer.size()));
}
flush();
}
@Override
public void onStart() {
if (logger.isWarn()) {
logger.warn(String.format(">> %s start. batchSize = %d", name, batchSize));
}
}
/** 컨슈머 스레드 종료 시 버퍼에 남은 로그를 반드시 적재한다. */
@Override
public void onShutdown() {
flush();
if (logger.isWarn()) {
logger.warn(String.format("<< %s shutdown.", name));
}
}
}
@@ -0,0 +1,135 @@
package com.eactive.eai.common.logger.async;
import java.util.ArrayList;
import java.util.List;
import com.eactive.eai.common.logger.EAIDBLogControl;
import com.eactive.eai.common.logger.HttpAdapterExtraLogVo;
import com.eactive.eai.common.logger.HttpLoggingService;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.Logger;
import com.lmax.disruptor.EventHandler;
import com.lmax.disruptor.LifecycleAware;
import com.lmax.disruptor.TimeoutHandler;
/**
* 1. 기능 : HTTP 헤더 로그 이벤트를 모아 트랜잭션(COMMIT 1회)으로 적재
* 2. 처리 개요 :
* - endOfBatch를 flush 조건으로 사용한다. 한산할 때는 건당 즉시 적재되고
* 부하가 몰릴 때만 배치가 커지므로 별도 타임아웃 스레드가 필요 없다.
* - batchSize는 하한이 아니라 상한이다. "100건 모일 때까지 대기" 아니라
* "한 트랜잭션이 100건을 넘지 않게 끊는다" 의미다.
* - 안전망으로 TimeoutHandler를 구현한다. 유휴 Disruptor가 onTimeout()
* 호출하므로 버퍼에 남은 로그가 방치되지 않는다.
* (WaitStrategy가 TimeoutBlockingWaitStrategy = 설정값 "TIME" 동작. 기본값)
* - 배치 실패 기존 건별 경로(insertHttpAdapterExtraLog) 재시도한다.
* 경로가 DB 장애 판정과 파일로그 폴백을 이미 담고 있다.
* 3. 주의사항
* - HttpLoggingService 빈은 필드에 캐싱한다. 이벤트마다 타입 기반 getBean을
* 호출하면 컨슈머 처리량이 떨어진다.
*/
public class HttpLoggingBatchEventHandler implements EventHandler<HttpLoggingEvent>, LifecycleAware, TimeoutHandler {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private final String name;
private final int batchSize;
private final List<HttpAdapterExtraLogVo> buffer;
private HttpLoggingService service;
public HttpLoggingBatchEventHandler(String name, int batchSize) {
this.name = name;
this.batchSize = (batchSize < 1) ? 1 : batchSize;
this.buffer = new ArrayList<HttpAdapterExtraLogVo>(this.batchSize);
}
private HttpLoggingService service() {
if (service == null) {
service = ApplicationContextProvider.getContext().getBean(HttpLoggingService.class);
}
return service;
}
@Override
public void onEvent(HttpLoggingEvent event, long sequence, boolean endOfBatch) throws Exception {
HttpAdapterExtraLogVo vo = event.getHttpAdapterExtraLogVo();
if (vo != null) {
buffer.add(vo);
}
// 링버퍼 슬롯은 재사용되므로 참조를 끊는다. VO 내용은 유지된다.
event.clear();
if (endOfBatch || buffer.size() >= batchSize) {
flush();
}
}
private void flush() {
if (buffer.isEmpty()) return;
int size = buffer.size();
// info 레벨이 꺼져 있으면 시각 측정 자체를 하지 않는다.
final boolean measure = logger.isInfo();
final long t0 = measure ? System.currentTimeMillis() : 0L;
try {
if (EAIDBLogControl.isEnable()) {
service().insertHttpAdapterExtraLogBatch(buffer);
if (logger.isDebug()) {
logger.debug(String.format("%s] flushed %d http log(s) in one transaction", name, size));
}
} else {
writeEach();
}
} catch (Throwable th) {
// 배치는 건만 실패해도 전체가 롤백된다. 건별로 재시도해 정상 건을 살린다.
logger.error(String.format("%s] batch http log failed, retry one by one. size=%d", name, size), th);
writeEach();
} finally {
buffer.clear();
}
if (measure) {
logger.info("{} batch logging. size={}, elapsed={}ms", name, size, System.currentTimeMillis() - t0);
}
}
/** 기존 건별 경로. DB 장애 판정과 파일로그 폴백이 이 안에 있다. */
private void writeEach() {
for (HttpAdapterExtraLogVo vo : buffer) {
try {
service().insertHttpAdapterExtraLog(vo);
} catch (Throwable th) {
logger.error("failed to insert async http log ", th);
}
}
}
/**
* 유휴 Disruptor가 호출하는 안전망.
* 정상 흐름에서는 endOfBatch로 이미 flush되지만, onEvent가 예외로 중단되어
* 버퍼가 남은 거래가 끊기는 경우를 대비한다.
*/
@Override
public void onTimeout(long sequence) throws Exception {
if (buffer.isEmpty()) return;
if (logger.isDebug()) {
logger.debug(String.format("%s] flush by timeout. remain=%d", name, buffer.size()));
}
flush();
}
@Override
public void onStart() {
if (logger.isWarn()) {
logger.warn(String.format(">> %s start. batchSize = %d", name, batchSize));
}
}
/** 컨슈머 스레드 종료 시 버퍼에 남은 로그를 반드시 적재한다. */
@Override
public void onShutdown() {
flush();
if (logger.isWarn()) {
logger.warn(String.format("<< %s shutdown.", name));
}
}
}
@@ -8,6 +8,14 @@ import lombok.Data;
public class HttpLoggingEvent {
private HttpAdapterExtraLogVo httpAdapterExtraLogVo;
/**
* 링버퍼 슬롯의 참조만 끊는다.
* VO 자체는 컨슈머가 배치 버퍼에 담아 사용하므로 내용을 비우면 된다.
*/
public void clear() {
this.httpAdapterExtraLogVo = null;
}
public final static EventFactory<HttpLoggingEvent> EVENT_FACTORY = new EventFactory<HttpLoggingEvent>() {
public HttpLoggingEvent newInstance() {
return new HttpLoggingEvent();
@@ -17,6 +17,8 @@ public class HttpLoggingPoolObject {
Disruptor<HttpLoggingEvent> disruptor = null;
RingBuffer<HttpLoggingEvent> ringBuffer = null;
int id = 0;
// 실제 링버퍼 크기로 생성자에서 설정한다. shutdown() 값과 remainingCapacity를
// 비교하므로 하드코딩하면 queue.size 변경 종료되지 않는다.
int queueMax = (int)Math.pow(2, 10);
int workerSize = 0;
@@ -25,7 +27,6 @@ public class HttpLoggingPoolObject {
}
private WaitStrategy getWaitStrategy(String waitStrategy) {
WaitStrategy ws = null;
if(ConfigKeys.LOGGER_ASYNC_WAITSTRATEGY_BLOCK.equals(waitStrategy)) {
// throughput and low-latency are not as important as CPU resource
return new BlockingWaitStrategy();
@@ -47,14 +48,23 @@ public class HttpLoggingPoolObject {
if(ConfigKeys.LOGGER_ASYNC_WAITSTRATEGY_YIELD.equals(waitStrategy)) {
return new YieldingWaitStrategy();
}
return ws;
// 없는 값이면 null이 아니라 기본값(TIME) 돌려준다.
// null을 넘기면 컨슈머 스레드가 NPE로 죽어 로깅이 통째로 멈춘다.
// TimeoutBlockingWaitStrategy여야 배치 핸들러의 onTimeout 안전망도 동작한다.
if(waitStrategy != null && logger.isWarn()) {
logger.warn(String.format(">> unknown waitStrategy [%s], fallback to TIME(100ms)", waitStrategy));
}
return new TimeoutBlockingWaitStrategy(100 * 1000, TimeUnit.MICROSECONDS);
}
public HttpLoggingPoolObject(int id, int queueSize, int workerSize, String waitStrategy) {
public HttpLoggingPoolObject(int id, int queueSize, int workerSize, String waitStrategy, int batchSize) {
this.id = id;
this.queueMax = queueSize;
this.workerSize = workerSize;
if(logger.isWarn()) {
logger.warn(String.format(">> Disruptor-%d queueSize = %d", id, queueSize));
logger.warn(String.format(">> Disruptor-%d workerSize = %d", id, workerSize));
logger.warn(String.format(">> Disruptor-%d batchSize = %d", id, batchSize));
logger.warn(String.format(">> Disruptor-%d waitStrategy = %s", id, waitStrategy));
}
CustomThreadFactory tFactory = new CustomThreadFactory();
@@ -63,12 +73,21 @@ public class HttpLoggingPoolObject {
ProducerType.SINGLE,
getWaitStrategy(waitStrategy));
// BlockingWaitStrategy | SleepingWaitStrategy | YieldingWaitStrategy | BusySpinWaitStrategy
WorkHandler<HttpLoggingEvent>[] handlers = new WorkHandler[workerSize];
for(int i=0; i< handlers.length; i++) {
WorkHandler handler = new HttpLoggingWorkHandler();
handlers[i] = handler;
if(workerSize > 1) {
// 건별 COMMIT 경로. WorkHandler에는 endOfBatch가 없어 배치 적재가 불가능하다.
WorkHandler<HttpLoggingEvent>[] handlers = new WorkHandler[workerSize];
for(int i=0; i< handlers.length; i++) {
WorkHandler handler = new HttpLoggingWorkHandler();
handlers[i] = handler;
}
disruptor.handleEventsWithWorkerPool(handlers);
}
else {
// 배치 COMMIT 경로. 컨슈머 병렬도는 디스크럽터 개수(pool.maxsize) 확보한다.
HttpLoggingBatchEventHandler handler =
new HttpLoggingBatchEventHandler(String.format("HttpLoggingBatchEventHandler%d-%d", id, 0), batchSize);
disruptor.handleEventsWith(handler);
}
disruptor.handleEventsWithWorkerPool(handlers);
try {
@@ -18,7 +18,8 @@ public class HttpLoggingPoolObjectFactory extends BasePooledObjectFactory<HttpLo
int queueSize = ElinkConfig.getAsyncQueueSize();
int workers = ElinkConfig.getAsyncWorkers();
String waitStrategy = ElinkConfig.getWaitStrategy();
return new HttpLoggingPoolObject(i++, queueSize, workers, waitStrategy);
int batchSize = ElinkConfig.getAsyncBatchSize();
return new HttpLoggingPoolObject(i++, queueSize, workers, waitStrategy, batchSize);
}
@Override
@@ -22,6 +22,8 @@ public class LoggingPoolObject {
Disruptor<LoggingEvent> disruptor = null;
RingBuffer<LoggingEvent> ringBuffer = null;
int id = 0;
// 실제 링버퍼 크기로 생성자에서 설정한다. shutdown() 값과 remainingCapacity를
// 비교하므로 하드코딩하면 queue.size 변경 종료되지 않는다.
int queueMax = (int)Math.pow(2, 10);
int workerSize = 0;
@@ -30,7 +32,6 @@ public class LoggingPoolObject {
}
private WaitStrategy getWaitStrategy(String waitStrategy) {
WaitStrategy ws = null;
if(ConfigKeys.LOGGER_ASYNC_WAITSTRATEGY_BLOCK.equals(waitStrategy)) {
// throughput and low-latency are not as important as CPU resource
return new BlockingWaitStrategy();
@@ -52,33 +53,45 @@ public class LoggingPoolObject {
if(ConfigKeys.LOGGER_ASYNC_WAITSTRATEGY_YIELD.equals(waitStrategy)) {
return new YieldingWaitStrategy();
}
return ws;
// 없는 값이면 null이 아니라 기본값(TIME) 돌려준다.
// null을 넘기면 컨슈머 스레드가 NPE로 죽어 로깅이 통째로 멈춘다.
// TimeoutBlockingWaitStrategy여야 배치 핸들러의 onTimeout 안전망도 동작한다.
if(waitStrategy != null && logger.isWarn()) {
logger.warn(String.format(">> unknown waitStrategy [%s], fallback to TIME(100ms)", waitStrategy));
}
return new TimeoutBlockingWaitStrategy(100 * 1000, TimeUnit.MICROSECONDS);
}
public LoggingPoolObject(int id, int queueSize, int workerSize, String waitStrategy) {
public LoggingPoolObject(int id, int queueSize, int workerSize, String waitStrategy, int batchSize) {
this.id = id;
this.queueMax = queueSize;
this.workerSize = workerSize;
if(logger.isWarn()) {
logger.warn(String.format(">> Disruptor-%d queueSize = %d", id, queueSize));
logger.warn(String.format(">> Disruptor-%d workerSize = %d", id, workerSize));
logger.warn(String.format(">> Disruptor-%d batchSize = %d", id, batchSize));
logger.warn(String.format(">> Disruptor-%d waitStrategy = %s", id, waitStrategy));
}
CustomThreadFactory tFactory = new CustomThreadFactory();
disruptor = new Disruptor<LoggingEvent>(LoggingEvent.EVENT_FACTORY, queueSize, tFactory,
ProducerType.SINGLE,
ProducerType.SINGLE,
getWaitStrategy(waitStrategy));
// BlockingWaitStrategy | SleepingWaitStrategy | YieldingWaitStrategy | BusySpinWaitStrategy
if(workerSize > 1) {
// 건별 COMMIT 경로. WorkHandler에는 endOfBatch가 없어 배치 적재가 불가능하다.
// 커밋 횟수를 줄이려면 worker.size=1로 두고 아래 배치 EventHandler를 사용한다.
WorkHandler<LoggingEvent>[] handlers = new WorkHandler[workerSize];
for(int i=0; i< handlers.length; i++) {
// TODO : 현재는 delay 없이 처리하도록 하고, 추후 DB부하를 줄이려면 sleep을 정의.
CustomWorkHandler handler = new CustomWorkHandler(String.format("CustomWorkHandler%d-%d",id, i), 0, 1);
CustomWorkHandler handler = new CustomWorkHandler(String.format("CustomWorkHandler%d-%d",id, i), 0, 1);
handlers[i] = handler;
}
disruptor.handleEventsWithWorkerPool(handlers);
disruptor.handleEventsWithWorkerPool(handlers);
}
else {
CustomEventHandler handler = new CustomEventHandler(String.format("CustomEventHandler%d-%d",id, 0), 0, 1);
// 배치 COMMIT 경로. 컨슈머 병렬도는 디스크럽터 개수(pool.maxsize) 확보한다.
CustomEventHandler handler = new CustomEventHandler(String.format("CustomEventHandler%d-%d",id, 0), 0, batchSize);
disruptor.handleEventsWith(handler);
}
@@ -19,7 +19,8 @@ public class LoggingPoolObjectFactory extends BasePooledObjectFactory<LoggingPoo
int queueSize = ElinkConfig.getAsyncQueueSize();
int workers = ElinkConfig.getAsyncWorkers();
String waitStrategy = ElinkConfig.getWaitStrategy();
return new LoggingPoolObject(i++, queueSize, workers, waitStrategy);
int batchSize = ElinkConfig.getAsyncBatchSize();
return new LoggingPoolObject(i++, queueSize, workers, waitStrategy, batchSize);
}
@Override
@@ -111,6 +111,8 @@ public class EAIMessage implements EAIMessageKeys, Serializable, Comparable<EAIM
private String authCheckdYn;
private Properties callProp;
private String orgRspErrCd;
public EAIMessage() {
this.svcMsgs = new ArrayList<>();
@@ -673,6 +675,23 @@ public class EAIMessage implements EAIMessageKeys, Serializable, Comparable<EAIM
public void setCallProp(Properties callProp) {
this.callProp = callProp;
}
public String getOrgRspErrCd() {
return orgRspErrCd;
}
public void setOrgRspErrCd(String orgRspErrCd) {
this.orgRspErrCd = orgRspErrCd;
}
/**
* 로깅/모니터링용 응답에러코드를 반환한다.
* 내부오류를 어댑터 에러메시지 핸들러로 대체 응답한 경우
* rspErrCd 에는 정상코드가 설정되므로 원본 에러코드(orgRspErrCd) 우선한다.
*/
public String getLogRspErrCd() {
return (orgRspErrCd != null && orgRspErrCd.length() > 0) ? orgRspErrCd : rspErrCd;
}
/**
* [비동기 전달 전용] 컨텍스트 전달용 Map 설정
@@ -292,7 +292,7 @@ public class EAIServiceMonitor implements Lifecycle {
msgPssTm = msg.getMsgPssTm();
msgRcvTm = msg.getMsgRcvTm();
logPssSno = msg.getLogPssSno();
rspErrCd = msg.getRspErrCd();
rspErrCd = StringUtils.defaultString(msg.getLogRspErrCd());
eaiSvcCd = msg.getEAISvcCd();
svcOgNo = msg.getSvcOgNo();
bwkCls = msg.getBwkCls();
@@ -316,13 +316,18 @@ public class EAIServiceMonitor implements Lifecycle {
if (rspErrCd.length() >= 12) {
error = rspErrCd.substring(1, 2);
}
if ("RECEAIINA001".equals(rspErrCd)) {
error = "S";
}
// 에러와 타임아웃을 분리 : 이동훈
if (errorCode[0].equals(error.toUpperCase()) || errorCode[1].equals(error.toUpperCase())) {
iErrorCode = 1;
// Timeout
if (timeOutCodes.indexOf(rspErrCd) > 0) {
// 목록 번째 코드는 indexOf 0 이므로 '> 0' 이면 매칭되지 않는다. (isTimeOutCodes() 동일하게 '>= 0')
if (timeOutCodes.indexOf(rspErrCd) >= 0) {
iErrorCode = 2;
}
// 업무에러코드에 없을 경우 통신(시스템)에러로 처리함
@@ -0,0 +1,28 @@
package com.eactive.eai.common.security;
import lombok.Data;
/**
* CryptoModuleManager 진단정보 DTO.
*
* 원본 암호키(encKey/decKey) IV는 절대 포함하지 않는다. 메타데이터와
* 도출 전략(KeyDerivationStrategy) 로드 상태만 담는다.
*/
@Data
public class CryptoModuleDiagnosticDTO {
String cryptoName;
String cryptoDesc;
String algType;
String cipherMode;
String padding;
boolean hasIv;
String keySourceType;
String keyDerivStrategy;
boolean strategyLoaded;
String strategyClassName;
String strategyLoadError;
String cacheYn;
Integer cacheTtlSec;
String useYn;
}
@@ -1,5 +1,6 @@
package com.eactive.eai.common.security;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
@@ -153,6 +154,58 @@ public class CryptoModuleManager implements Lifecycle {
return buildExtension(vo, iv, derivedKey.getEncKey(), derivedKey.getDecKey());
}
/**
* 등록된 전체 암호화모듈의 진단정보를 반환한다.
* 원본 (encKey/decKey/ivHex) 포함하지 않는다.
*/
public List<CryptoModuleDiagnosticDTO> describeAll() {
List<CryptoModuleDiagnosticDTO> result = new ArrayList<>();
for (String cryptoName : configMap.keySet()) {
result.add(describe(cryptoName));
}
return result;
}
/**
* 단일 암호화모듈의 진단정보를 반환한다. DYNAMIC 방식인 경우 resolveStrategy를 통해
* 전략 클래스 로드 성공 여부를 함께 확인한다. 원본 (encKey/decKey/ivHex) 포함하지 않는다.
*/
public CryptoModuleDiagnosticDTO describe(String cryptoName) {
CryptoModuleConfigVO vo = getVO(cryptoName);
CryptoModuleDiagnosticDTO dto = new CryptoModuleDiagnosticDTO();
dto.setCryptoName(vo.getCryptoName());
dto.setCryptoDesc(vo.getCryptoDesc());
dto.setAlgType(vo.getAlgType());
dto.setCipherMode(vo.getCipherMode());
dto.setPadding(vo.getPadding());
dto.setHasIv(vo.getIvHex() != null);
dto.setKeySourceType(vo.getKeySourceType());
dto.setCacheYn(vo.getCacheYn());
dto.setCacheTtlSec(vo.getCacheTtlSec());
dto.setUseYn(vo.getUseYn());
if (!"STATIC".equalsIgnoreCase(vo.getKeySourceType()) && vo.getKeyDerivStrategy() != null) {
dto.setKeyDerivStrategy(vo.getKeyDerivStrategy());
try {
KeyDerivationStrategy strategy = resolveStrategy(vo.getKeyDerivStrategy());
dto.setStrategyLoaded(true);
dto.setStrategyClassName(strategy.getClass().getName());
} catch (Exception e) {
dto.setStrategyLoaded(false);
dto.setStrategyLoadError(e.getMessage());
}
}
return dto;
}
/**
* 동적 캐시에 존재하는 캐시 목록을 반환한다. (전략별 buildCacheKey 결과이며, 원본 값이 아니다)
*/
public List<String> listDynamicCacheKeys() {
return new ArrayList<>(dynamicKeyCache.keySet());
}
private CryptoModuleConfigVO getVO(String cryptoName) {
CryptoModuleConfigVO vo = configMap.get(cryptoName);
if (vo == null) {
@@ -177,7 +230,7 @@ public class CryptoModuleManager implements Lifecycle {
private KeyDerivationStrategy resolveStrategy(String fqcn) {
return strategyCache.computeIfAbsent(fqcn, key -> {
try {
Class<?> clazz = Class.forName(key);
Class<?> clazz = Class.forName(key.trim());
return (KeyDerivationStrategy) clazz.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new IllegalArgumentException("전략 클래스 로드 실패: " + key, e);
@@ -1,62 +0,0 @@
package com.eactive.eai.common.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 {
@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);
}
}
@@ -246,6 +246,8 @@ public abstract class SessionManager implements Lifecycle {
public abstract CacheInfoVO getSocketCache();
public abstract CacheInfoVO getOutboundAccessTokenCache();
public abstract String getMasterInstId();
// Convert terminalId to userId
@@ -887,7 +887,12 @@ public class SessionManagerForEhcache extends SessionManager {
public CacheInfoVO getSocketCache() {
return convert(cacheSocket);
}
@Override
public CacheInfoVO getOutboundAccessTokenCache() {
throw new UnsupportedOperationException();
}
@Override
public void putWebSocketTimeout(String key, SessionVO value) {
cacheWebSocketTimeout.put(new Element(key, value));
@@ -818,6 +818,11 @@ public class SessionManagerForIgnite extends SessionManager {
public CacheInfoVO getSocketCache() {
return convert(cacheSocket);
}
@Override
public CacheInfoVO getOutboundAccessTokenCache() {
return convert(cacheOutBoundAccessToken);
}
@Override
public void putWebSocketTimeout(String key, SessionVO value) {
@@ -2,24 +2,23 @@ package com.eactive.eai.common.util;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.core5.http.Header;
import com.eactive.eai.common.logger.HttpAdapterExtraHeaderVo;
import com.eactive.eai.common.logger.HttpAdapterExtraLogVo;
import com.eactive.eai.common.logger.HttpLoggingService;
import com.eactive.eai.common.logger.async.AsyncHttpLoggingPoolManager;
import com.eactive.eai.env.ElinkConfig;
import org.apache.hc.core5.http.Header;
import org.apache.commons.lang3.StringUtils;
public class HttpAdapterExtraLogUtil {
public static final int MAX_HEADER_VALUE_SIZE = 400;
public static final String BODY_FIELD_NAME = "eapim-adapter-send-body";
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
public static boolean isHttpHeaderMode() {
@@ -62,14 +61,23 @@ public class HttpAdapterExtraLogUtil {
httpAdapterExtraLogVo.setHttpMethod(httpMethod);
for (HttpAdapterExtraHeaderVo httpAdapterExtraHeaderVo : headerVoList) {
String name = httpAdapterExtraHeaderVo.getName();
// if(StringUtils.isNotBlank(name) && "authorization".equals(name.toLowerCase())) {
// httpAdapterExtraHeaderVo.setValue("{hidden}");
// }
if(StringUtils.isNotBlank(name) && BODY_FIELD_NAME.equals(name))
continue;
String value = httpAdapterExtraHeaderVo.getValue();
if (value == null) {
httpAdapterExtraHeaderVo.setValue(" ");
}
if(StringUtils.isNotBlank(value) && value.length() > MAX_HEADER_VALUE_SIZE) {
value = value.substring(0, MAX_HEADER_VALUE_SIZE) + "...";
httpAdapterExtraHeaderVo.setValue(value);
}else if(value == null){
httpAdapterExtraHeaderVo.setValue(" ");
}
}
httpAdapterExtraLogVo.setHeaderList(headerVoList);
httpAdapterExtraLogVo.setHttpStatus(httpStatus);
@@ -110,7 +118,7 @@ public class HttpAdapterExtraLogUtil {
}
public static List<HttpAdapterExtraHeaderVo> convertHeaderToListOfHttpAdapterExtraHeaderVo(Header[] headers) {
Set<String> seenNames = new HashSet<>();
// Set<String> seenNames = new HashSet<>();
return Arrays.stream(headers)
// .filter(header -> seenNames.add(header.getName())) // 중복된 이름을 스킵
.map(header -> new HttpAdapterExtraHeaderVo(header.getName(), header.getValue()))
@@ -0,0 +1,412 @@
package com.eactive.eai.common.util;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import org.json.simple.JSONObject;
/**
* Jackson JsonNode 범용 접근 유틸리티
*
* "." 구분자로 필드 단계를 이동하고, "[n]" 으로 배열 인덱스에 접근하는
* path 표현식을 지원한다.
*
* 사용 :
* JacksonUtil.getText(root, "term_agreements[0].is_agreed")
* JacksonUtil.getBoolean(root, "term_agreements[0].is_agreed", false)
* JacksonUtil.getNode(root, "data.list[2].child[0].name")
*
* 경로 중간에 필드가 없거나, 배열 인덱스가 범위를 벗어나거나,
* 배열이 아닌 노드에 인덱스 접근을 시도하는 경우 모두
* 예외를 던지지 않고 null / 기본값을 반환한다.
*/
public final class JacksonUtil {
/** "fieldName" 또는 "fieldName[0]" 형태의 토큰을 분해하는 패턴 */
private static final Pattern TOKEN_PATTERN = Pattern.compile("([^\\[\\]]*)((?:\\[\\d+\\])*)");
private static final Pattern INDEX_PATTERN = Pattern.compile("\\[(\\d+)\\]");
private static final ObjectMapper OBJECT_MAPPER = newNumberSafeMapper();
/**
* JSON 숫자를 double 좁히지 않고 BigDecimal , 수신한 자릿수 그대로 유지하는 ObjectMapper.
*
* 입력측(파싱) - 2개 옵션이 함께 필요하다.
* readTree() 파싱한 writeValueAsString() 으로 다시 문자열을 만드는 왕복에서,
* 기본 설정이면 100000000.00 1.0E8 변형된다.
* USE_BIG_DECIMAL_FOR_FLOATS 켜고 withExactBigDecimals(true) 빼면 기본
* JsonNodeFactory stripTrailingZeros() 적용해 scale 음수가 되어 1E+8 된다.
* 옵션을 함께 켜야 수신한 값이 그대로 보존된다.
*
* 출력측(직렬화) - WRITE_BIGDECIMAL_AS_PLAIN 추가로 필요하다.
* DecimalNode 직렬화는 결국 BigDecimal.toString() 이고, 이것은
* scale 음수이거나 adjusted exponent -6 미만일 지수 표기를 쓴다.
* 2개 옵션으로 파싱을 제대로 해도, 내보낼 작은 소수가 깨진다.
* 0.00000012 -> 1.2E-7 , -0.0000005 -> -5E-7
* 금액처럼 scale 0 이상인 값은 영향이 없지만, 이율/환율은 깨진다.
* (Jackson 2.12.7 실측, 2026-08-27)
*
* 선행 0 허용 - ALLOW_LEADING_ZEROS_FOR_NUMBERS.
* JSON 표준은 숫자의 선행 0 금지하므로, 상대가 0 패딩된 코드값을 따옴표 없이 보내면
* 파서가 아래 오류로 거부한다.
* Invalid numeric value: Leading zeroes not allowed
* 전문 자체를 읽고 실패하는 것보다 값을 받아들이는 쪽이 낫다고 판단해 옵션으로 허용한다.
* 옵션은 00001 숫자 1 만든다. 선행 0 보존되지 않는다.
* - 표준전문 항목이 NUMBER/LL_NUMBER 선언돼 있으면 StandardItem.toTypeValue()
* 어차피 선행 0 깎으므로 결과가 같다.
* - STRING/ZZ_STRING 으로 선언된 0 패딩 코드값이라면 자릿수가 사라진다.
* 그런 항목은 상대에게 따옴표를 붙여 보내달라고 요청하는 것이 정답이다.
* (Jackson 2.12.7 실측, 2026-08-28)
*/
public static ObjectMapper newNumberSafeMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
objectMapper.setNodeFactory(JsonNodeFactory.withExactBigDecimals(true));
objectMapper.enable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN);
objectMapper.getFactory().configure(
JsonReadFeature.ALLOW_LEADING_ZEROS_FOR_NUMBERS.mappedFeature(), true);
return objectMapper;
}
/**
* JSON 문자열 리터럴 안의 이스케이프되지 않은 제어문자(0x00~0x1F) JSON 이스케이프로 바꾼다.
*
* JSON 표준은 문자열 안의 제어문자를 반드시 이스케이프하도록 요구하므로, 파서는 raw 개행 등을
* 만나면 아래 오류로 파싱을 거부한다.
* Illegal unquoted character ((CTRL-CHAR, code 10)): has to be escaped using backslash
* 그런데 연동 상대 시스템들이 개행을 이스케이프하지 않고 그대로 보내는 사례가 있다.
* 파서 옵션(ALLOW_UNESCAPED_CONTROL_CHARS)으로 푸는 대신, 입력을 표준 JSON 으로
* 정규화해서 파서는 strict 유지한다.
*
* 중요: 문자열 리터럴 "" 있는 것만 바꾼다. JSON 토큰 사이의 개행/탭을 공백으로
* 허용하므로, 구조적 공백까지 치환하면 pretty-print JSON 오히려 깨진다.
*
* 자체는 보존된다. raw 개행은 \n 으로 바뀌어 파싱 다시 개행 문자가 된다.
* 제어문자가 데이터가 아니라 쓰레기 (고정길이 전문의 0x00 패딩 )이라면
* 메서드에 의존하지 말고 파싱 전에 제거해야 한다.
*
* @param json 원본 JSON 문자열. null 이면 null 반환
* @return 제어문자가 이스케이프된 JSON. 바꿀 없으면 원본을 그대로 반환
*/
public static String escapeControlChars(String json) {
if (json == null) {
return null;
}
// 빠른 경로: 제어문자가 아예 없으면 원본 그대로 (대부분의 전문이 여기 해당)
boolean found = false;
for (int i = 0; i < json.length(); i++) {
if (json.charAt(i) < 0x20) {
found = true;
break;
}
}
if (!found) {
return json;
}
StringBuilder sb = new StringBuilder(json.length() + 16);
boolean inString = false;
boolean escaped = false;
for (int i = 0; i < json.length(); i++) {
char c = json.charAt(i);
if (!inString) {
// 문자열 : 구조적 공백(개행/ ) 건드리지 않는다
if (c == '"') {
inString = true;
}
sb.append(c);
continue;
}
if (escaped) {
// 백슬래시 글자는 그대로 통과 (이스케이프된 따옴표/백슬래시,
// 유니코드 이스케이프의 선두 u ). 이미 이스케이프된 것은 건드리지 않는다.
sb.append(c);
escaped = false;
continue;
}
if (c == '\\') {
sb.append(c);
escaped = true;
continue;
}
if (c == '"') {
sb.append(c);
inString = false;
continue;
}
if (c < 0x20) {
switch (c) {
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
case '\b': sb.append("\\b"); break;
case '\f': sb.append("\\f"); break;
default: sb.append(String.format("\\u%04x", (int) c)); break;
}
continue;
}
sb.append(c);
}
return sb.toString();
}
private JacksonUtil() {
// 인스턴스화 방지
}
// ------------------------------------------------------------------
// 기본 트리 탐색
// ------------------------------------------------------------------
/**
* Object(JSONObject/String/JsonNode ) JsonNode로 변환한다.
* KakaopayFilter.readTree() 일반화한 버전.
*/
public static JsonNode readTree(Object jsonData, ObjectMapper objectMapper)
throws JsonMappingException, JsonProcessingException {
if (jsonData == null) {
return null;
}
if (jsonData instanceof JsonNode) {
return (JsonNode) jsonData;
}
String jsonStr = null;
if (jsonData instanceof JSONObject) {
jsonStr = ((JSONObject) jsonData).toJSONString();
} else if (jsonData instanceof String) {
jsonStr = (String) jsonData;
} else {
// POJO 등은 writeValueAsString을 통해 변환
jsonStr = objectMapper.writeValueAsString(jsonData);
}
if (jsonStr == null) {
return null;
}
// 상대 시스템이 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 파싱한다.
// 이미 표준을 지킨 JSON 이면 원본을 그대로 반환하므로 사실상 무해하다.
return objectMapper.readTree(escapeControlChars(jsonStr));
}
public static JsonNode readTree(Object jsonData) throws JsonMappingException, JsonProcessingException {
return readTree(jsonData, OBJECT_MAPPER);
}
public static String writeAsString(JsonNode node) throws JsonProcessingException {
return OBJECT_MAPPER.writeValueAsString(node);
}
public static ObjectNode createObjectNode() {
return OBJECT_MAPPER.createObjectNode();
}
/**
* path 표현식으로 JsonNode를 탐색한다.
* 경로가 존재하지 않으면 null을 반환한다 (MissingNode가 아닌 진짜 null).
*
* @param root 탐색을 시작할 JsonNode
* @param path : "term_agreements[0].is_agreed", "data.list[2].name"
*/
public static JsonNode getNode(JsonNode root, String path) {
if (root == null || path == null || path.isEmpty()) {
return null;
}
JsonNode current = root;
for (String rawToken : path.split("\\.")) {
if (current == null || current.isMissingNode() || current.isNull()) {
return null;
}
String fieldName = extractFieldName(rawToken);
List<Integer> indices = extractIndices(rawToken);
// 필드명이 있으면 먼저 필드로 이동 ( 문자열이면 현재 노드 유지 - 최상위 배열 접근용)
if (!fieldName.isEmpty()) {
if (!current.has(fieldName)) {
return null;
}
current = current.get(fieldName);
}
// 이어지는 [n][m]... 인덱스를 순서대로 적용
for (Integer idx : indices) {
if (current == null || !current.isArray() || idx < 0 || idx >= current.size()) {
return null;
}
current = current.get(idx);
}
}
return current;
}
// ------------------------------------------------------------------
// 타입별 getter (전부 null-safe, 기본값 지원)
// ------------------------------------------------------------------
public static String getText(JsonNode root, String path) {
return getText(root, path, null);
}
public static String getText(JsonNode root, String path, String defaultValue) {
JsonNode node = getNode(root, path);
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asText(defaultValue);
}
public static boolean getBoolean(JsonNode root, String path, boolean defaultValue) {
JsonNode node = getNode(root, path);
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asBoolean(defaultValue);
}
public static int getInt(JsonNode root, String path, int defaultValue) {
JsonNode node = getNode(root, path);
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asInt(defaultValue);
}
public static long getLong(JsonNode root, String path, long defaultValue) {
JsonNode node = getNode(root, path);
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asLong(defaultValue);
}
public static double getDouble(JsonNode root, String path, double defaultValue) {
JsonNode node = getNode(root, path);
return (node == null || node.isMissingNode() || node.isNull()) ? defaultValue : node.asDouble(defaultValue);
}
/** path가 가리키는 노드가 실제로 존재하는지 (null/missing이 아닌지) */
public static boolean exists(JsonNode root, String path) {
JsonNode node = getNode(root, path);
return node != null && !node.isMissingNode() && !node.isNull();
}
/** path가 가리키는 노드가 배열일 때 그 크기를 반환, 배열이 아니거나 없으면 -1 */
public static int size(JsonNode root, String path) {
JsonNode node = getNode(root, path);
return (node != null && node.isArray()) ? node.size() : -1;
}
/** path가 가리키는 ArrayNode를 List<JsonNode>로 반환, 없으면 빈 리스트 */
public static List<JsonNode> getList(JsonNode root, String path) {
List<JsonNode> result = new ArrayList<>();
JsonNode node = getNode(root, path);
if (node != null && node.isArray()) {
for (JsonNode item : node) {
result.add(item);
}
}
return result;
}
// ------------------------------------------------------------------
// 설정 (필요 사용 - 존재하는 경로에 대해서만 동작)
// ------------------------------------------------------------------
/**
* path가 가리키는 위치의 텍스트 값을 변경한다.
* 부모 컨테이너(ObjectNode/ArrayNode) 존재해야 하며, 중간 경로가 없으면 false를 반환한다.
* (자동으로 중간 경로를 생성하지는 않음)
*/
public static boolean setText(JsonNode root, String path, String value) {
return setValue(root, path, value);
}
private static boolean setValue(JsonNode root, String path, String value) {
int lastDot = path.lastIndexOf('.');
String parentPath = (lastDot == -1) ? "" : path.substring(0, lastDot);
String lastToken = (lastDot == -1) ? path : path.substring(lastDot + 1);
JsonNode parent = parentPath.isEmpty() ? root : getNode(root, parentPath);
if (parent == null) {
return false;
}
String fieldName = extractFieldName(lastToken);
List<Integer> indices = extractIndices(lastToken);
JsonNode target = parent;
if (!fieldName.isEmpty()) {
if (!target.has(fieldName)) {
return false;
}
target = target.get(fieldName);
}
// 마지막 인덱스 전까지 이동
for (int i = 0; i < indices.size() - 1; i++) {
int idx = indices.get(i);
if (target == null || !target.isArray() || idx < 0 || idx >= target.size()) {
return false;
}
target = target.get(idx);
}
if (!indices.isEmpty()) {
// 배열의 특정 인덱스 교체
int lastIdx = indices.get(indices.size() - 1);
if (target == null || !target.isArray() || lastIdx < 0 || lastIdx >= target.size()) {
return false;
}
// set(int, String) 오버로드는 jackson-databind 2.13 부터다. 실제 런타임은 2.12.7 이므로
// TextNode 감싸 set(int, JsonNode) 바인딩해야 NoSuchMethodError 나지 않는다.
((ArrayNode) target).set(lastIdx, TextNode.valueOf(value));
return true;
} else {
// 객체 필드 교체 (target은 fieldName으로 이미 이동된 상태이므로, parent 기준 재설정 필요)
if (parent.isObject() && fieldName != null && !fieldName.isEmpty()) {
((ObjectNode) parent).put(fieldName, value);
return true;
}
return false;
}
}
// ------------------------------------------------------------------
// 내부 파싱 헬퍼
// ------------------------------------------------------------------
/** "term_agreements[0]" -> "term_agreements" / "[0]" -> "" */
private static String extractFieldName(String token) {
Matcher m = TOKEN_PATTERN.matcher(token);
if (m.matches()) {
return m.group(1) == null ? "" : m.group(1);
}
return token;
}
/** "term_agreements[0][1]" -> [0, 1] / "term_agreements" -> [] */
private static List<Integer> extractIndices(String token) {
List<Integer> indices = new ArrayList<>();
Matcher m = INDEX_PATTERN.matcher(token);
while (m.find()) {
indices.add(Integer.parseInt(m.group(1)));
}
return indices;
}
}
@@ -9,7 +9,6 @@ import com.eactive.eai.common.messagekey.MessageKeyGroupVO;
import com.eactive.eai.common.messagekey.MessageKeyManager;
import com.eactive.eai.common.messagekey.MessageKeyVO;
import com.eactive.eai.transformer.message.ISO8583MessageFactory;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.solab.iso8583.IsoMessage;
import org.apache.commons.lang3.StringUtils;
import org.json.simple.JSONValue;
@@ -23,8 +22,6 @@ public final class MessageKeyExtractor {
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* Private 생성자
* Instance를 생성하지 못함
@@ -47,6 +47,61 @@ public final class MessageUtil {
}
/**
* 문자열에서 제어문자(0x00~0x1F, 0x7F) 걸러낸다.
*
* 값이 JSON / XML / 고정길이 전문 어디로 나갈지 모르는 자리 - 대표적으로 템플릿 치환 -
* 에서 쓴다. 출력 포맷마다 이스케이프 방식이 달라 포맷을 알아야 하는데, 제어문자를 아예
* 걷어내면 포맷과 무관하게 안전해진다.
*
* 포맷에서 제어문자가 일으키는 문제:
* - JSON : raw 제어문자는 문자열 안에 없다
* ("Illegal unquoted character ((CTRL-CHAR, code 10))")
* - XML : 0x09/0x0A/0x0D 제외한 제어문자는 문자 참조로도 표현할 없어
* 수신측 파서가 거부한다
* - 전문 : 제어문자도 1바이트를 차지해 고정길이 자리수가 어긋난다
*
* 처리 규칙
* - /개행/캐리지리턴(0x09/0x0A/0x0D) : 구분 의미가 있으므로 공백 1칸으로 치환
* - 제어문자 DEL(0x7F) : 제거
*
* 연속 공백을 합치지는 않는다(CRLF 공백 2칸이 된다). 변형을 최소화하기 위함이다.
*
* @param s 원본 문자열. null/ 문자열이면 그대로 반환
* @return 제어문자가 걸러진 문자열. 걸러낼 없으면 원본을 그대로 반환
*/
public static String stripControlChars(String s) {
if (s == null || s.isEmpty()) {
return s;
}
// 빠른 경로: 제어문자가 없으면 원본 그대로 (대부분의 값이 여기 해당)
boolean found = false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c < 0x20 || c == 0x7F) {
found = true;
break;
}
}
if (!found) {
return s;
}
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '\t' || c == '\n' || c == '\r') {
sb.append(' ');
} else if (c < 0x20 || c == 0x7F) {
continue;
} else {
sb.append(c);
}
}
return sb.toString();
}
// ASCII Bytes에서 특정길이의 값을 추출하는 Method
public static String getAscBytes(byte[] message, int startPos, int length) {
if (message == null || message.length < startPos) {
@@ -0,0 +1,49 @@
package com.eactive.eai.common.util;
import org.apache.commons.lang3.StringUtils;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.HttpAdapterExtraLogUtil;
public class RestSendBodyLogUtils {
private static final String PROP_LOG_MAX_DATA_SIZE = "log.max.data.size";
private static final String PROP_GROUP = "RestSendBodyLog";
private RestSendBodyLogUtils() {
throw new IllegalStateException("Utility class");
}
/**
* API ID 어댑터 body 로그 남기는 여부
*
* @param apiId
* @return
*/
public static boolean isBodyLoggingApi(String apiId) {
if (StringUtils.isEmpty(apiId))
return false;
String logging = PropManager.getInstance().getProperty(PROP_GROUP, apiId, "N");
return StringUtils.equals("Y", logging);
}
/**
* body 로그에 남길 만큼 자르기
*
* @param body
* @return
*/
public static String getBodyByMaxSize(String body) {
String maxDataSize = PropManager.getInstance().getProperty(PROP_GROUP, PROP_LOG_MAX_DATA_SIZE);
int imaxDataSize = HttpAdapterExtraLogUtil.MAX_HEADER_VALUE_SIZE;
if (maxDataSize != null)
imaxDataSize = Integer.parseInt(maxDataSize);
if (body.length() > imaxDataSize)
return StringUtils.substring(body, 0, imaxDataSize) + "...";
return body;
}
}
@@ -6,6 +6,9 @@ import java.lang.reflect.Method;
import java.net.InetAddress;
import java.nio.charset.Charset;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.ExportException;
import java.security.Security;
import java.util.Arrays;
@@ -24,6 +27,7 @@ import com.eactive.eai.common.dao.Keys;
import com.eactive.eai.common.exception.ExceptionUtil;
import com.eactive.eai.common.lifecycle.LifecycleException;
import com.eactive.eai.common.lifecycle.LifecycleManager;
import com.eactive.eai.common.logger.async.AsyncHttpLoggingPoolManager;
import com.eactive.eai.common.logger.async.AsyncLoggingPoolManager;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.routing.rmi.RemoteProxy;
@@ -36,7 +40,6 @@ import com.eactive.eai.common.util.ServiceLocator;
import com.eactive.eai.common.util.ServiceLocatorException;
import com.eactive.eai.env.ConfigKeys;
import com.eactive.eai.env.ElinkConfig;
import com.eactive.eai.common.logger.async.AsyncHttpLoggingPoolManager;
/**
* eLink FrameWork이 초기화(Deploy) 실행되어야 작업을 정의
@@ -180,14 +183,25 @@ public class AppInitializer implements InitializingBean, DisposableBean {
this.shutdownWaitIntervalMs = shutdownWaitIntervalMs;
}
private Registry rmiRegistry;
@SuppressWarnings("deprecation")
private void initRmiServer(int registryPort, int servicePort) throws RemoteException {
try {
rmiRegistry = LocateRegistry.createRegistry(registryPort);
} catch (ExportException e) {
// 혹시 이전 정리 실패로 이미 떠있다면 재사용 시도
Logger.getLogger(Logger.LOGGER_DEFAULT)
.warn("Registry already exists on port " + registryPort + ", reusing", e);
rmiRegistry = LocateRegistry.getRegistry(registryPort);
}
rmiServiceExporter.setServiceName("RemoteProxy");
rmiServiceExporter.setService(remoteProxy);
rmiServiceExporter.setServiceInterface(serviceInterface);
rmiServiceExporter.setRegistryPort(registryPort);
rmiServiceExporter.setServicePort(servicePort);
rmiServiceExporter.setAlwaysCreateRegistry(true);
rmiServiceExporter.setAlwaysCreateRegistry(false);
rmiServiceExporter.afterPropertiesSet();
}
+2
View File
@@ -13,6 +13,8 @@ public interface ConfigKeys {
public static final String LOGGER_ASYNC_INITPOOLS = "logger.async.pool.initsize";
public static final String LOGGER_ASYNC_QUEUES = "logger.async.queue.size";
public static final String LOGGER_ASYNC_WORKERS = "logger.async.worker.size";
// 트랜잭션(COMMIT 1회) 묶어 적재할 최대 로그 건수
public static final String LOGGER_ASYNC_BATCHSIZE = "logger.async.batch.size";
public static final String LOGGER_ASYNC_WAITSTRATEGY = "logger.async.worker.waitstrategy";
public static final String LOGGER_ASYNC_WAITSTRATEGY_BLOCK = "BLOCK";
+21 -1
View File
@@ -37,7 +37,8 @@ public class ElinkConfig implements ConfigKeys {
private static int asyncPoolInitSize = 8;
private static int asyncQueueSize = 1024;
private static int asyncWorkers = 16;
private static int asyncBatchSize = 100;
private static String waitStrategy = LOGGER_ASYNC_WAITSTRATEGY;
private static ConcurrentHashMap<String, String> asyncDummyData = new ConcurrentHashMap<String, String>();
@@ -71,6 +72,7 @@ public class ElinkConfig implements ConfigKeys {
sb.append( String.format("%s = %s\n", LOGGER_ASYNC_INITPOOLS, asyncPoolInitSize) );
sb.append( String.format("%s = %s\n", LOGGER_ASYNC_QUEUES, asyncQueueSize) );
sb.append( String.format("%s = %s\n", LOGGER_ASYNC_WORKERS, asyncWorkers) );
sb.append( String.format("%s = %s\n", LOGGER_ASYNC_BATCHSIZE, asyncBatchSize) );
sb.append( String.format("%s = %s\n", LOGGER_ASYNC_WAITSTRATEGY, waitStrategy) );
sb.append(">> Async Dummy Configuration\n");
sb.append(String.format("%s = %s\n", HTTP_ASYNC_DEFAULT_DUMMY_DATA, asyncDefaultDummyData));
@@ -169,6 +171,16 @@ public class ElinkConfig implements ConfigKeys {
asyncWorkers = 16;
}
try {
sCount = env.getProperty(LOGGER_ASYNC_BATCHSIZE, "100");
asyncBatchSize = Integer.parseInt(sCount);
if (asyncBatchSize < 1) {
asyncBatchSize = 1;
}
} catch (Exception ex) {
asyncBatchSize = 100;
}
try {
waitStrategy = env.getProperty(LOGGER_ASYNC_WAITSTRATEGY, LOGGER_ASYNC_WAITSTRATEGY_TIME);
} catch (Exception ex) {
@@ -271,6 +283,14 @@ public class ElinkConfig implements ConfigKeys {
ElinkConfig.asyncWorkers = asyncWorkers;
}
public static int getAsyncBatchSize() {
return asyncBatchSize;
}
public static void setAsyncBatchSize(int asyncBatchSize) {
ElinkConfig.asyncBatchSize = asyncBatchSize;
}
public static String getWaitStrategy() {
return waitStrategy;
}
@@ -123,7 +123,7 @@ public class RequestProcessor extends RequestProcessorSupport {
// UUID 생성 : UUID에서 - 없는 32자리
String uuid = prop.getProperty(TransactionContextKeys.TRANSACTION_UUID);
uuid = uuid == null ? UUIDGenerator.getUUID().toString().replaceAll("-", "") : uuid;
uuid = uuid == null ? instid+UUIDGenerator.getUUID().toString().replaceAll("-", "") : uuid;
// UUID 생성 : UUID = server구분4자리 + UUID
/*
String uuid = "";
@@ -1007,7 +1007,14 @@ public class RequestProcessor extends RequestProcessorSupport {
if (logger.isError()) logger.error(guidLogPrefix + " : 거래통제 Log 실패 - " + vo.getRspErrorMsg());
}
return restrictResponse;
AdapterGroupVO adptGrpVO = AdapterManager.getInstance().getAdapterGroupVO(vo.getAdapterGroupName());
if (!Keys.IF_STANDARD.equals(vo.getStdMsgTypeCode()) && StringUtils.equalsAny(adptGrpVO.getType(),
Keys.TYPE_REST, Keys.TYPE_HTTP, Keys.TYPE_HTTP_CUSTOM)) {
throw new HttpStatusException(eaiMsg.getRspErrMsg(), eaiMsg.getRspErrCd(),
HttpStatus.SERVICE_UNAVAILABLE.value());
} else {
return restrictResponse;
}
}
else {
// ExceptionHandler에 의해 에러 응답송신
@@ -11,6 +11,7 @@ import org.springframework.http.HttpStatus;
import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.http.HttpStatusException;
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
import com.eactive.eai.adapter.http.dynamic.filter.JwtAuthException;
import com.eactive.eai.common.exception.ExceptionHandler;
import com.eactive.eai.common.message.EAIMessage;
@@ -111,7 +112,13 @@ public abstract class RequestProcessorSupport implements Processor
**/
public Object execute(Object message, Properties prop) throws HttpStatusException
{
long msgRcvTm = System.currentTimeMillis();
String receivedTimestamp = prop.getProperty(HttpAdapterServiceKey.INBOUND_REQUESTED_TIME);
long msgRcvTm = 0L;
if(receivedTimestamp == null)
msgRcvTm = System.currentTimeMillis();
else
msgRcvTm = Long.parseLong(receivedTimestamp);
increaseRcvCount();
local.set(new Long(msgRcvTm));
@@ -0,0 +1,96 @@
package com.eactive.eai.manage.crypto;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.eactive.eai.common.security.CryptoModuleDiagnosticDTO;
/**
* 암호화모듈(CryptoModuleManager) 진단 암복호화 테스트 API.
*
* 원본 암호키(encKey/decKey/ivHex) 응답에 포함하지 않는다. 메타데이터와
* 도출 전략(KeyDerivationStrategy) 로드 상태만 노출한다.
*
* GET /manage/crypto/list 등록된 전체 암호화모듈 진단정보
* GET /manage/crypto/{cryptoName} 단일 암호화모듈 진단정보
* GET /manage/crypto/cache/dynamic-keys 동적 캐시 목록 (원본 아님)
* POST /manage/crypto/{cryptoName}/test/encrypt 테스트 암호화 (Base64 입출력)
* POST /manage/crypto/{cryptoName}/test/decrypt 테스트 복호화 (Base64 입출력)
*/
@RestController
@RequestMapping("/manage/crypto")
public class CryptoModuleManageController {
@Autowired
private CryptoModuleManageService cryptoModuleManageService;
@GetMapping("/list")
public ResponseEntity<?> listAll() {
List<CryptoModuleDiagnosticDTO> list = cryptoModuleManageService.listAll();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("data", list);
result.put("count", list.size());
return ResponseEntity.ok(result);
}
@GetMapping("/{cryptoName}")
public ResponseEntity<?> get(@PathVariable String cryptoName) {
return respond(() -> cryptoModuleManageService.get(cryptoName));
}
@GetMapping("/cache/dynamic-keys")
public ResponseEntity<?> listDynamicCacheKeys() {
List<String> keys = cryptoModuleManageService.listDynamicCacheKeys();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("data", keys);
result.put("count", keys.size());
return ResponseEntity.ok(result);
}
@PostMapping("/{cryptoName}/test/encrypt")
public ResponseEntity<?> testEncrypt(@PathVariable String cryptoName, @RequestBody CryptoTestRequestDTO request) {
return respond(() -> {
String cipherTextBase64 = cryptoModuleManageService.testEncrypt(cryptoName,
request.getRuntimeContext(), request.getPlainTextBase64(), request.getAadBase64());
Map<String, Object> data = new HashMap<>();
data.put("cipherTextBase64", cipherTextBase64);
return data;
});
}
@PostMapping("/{cryptoName}/test/decrypt")
public ResponseEntity<?> testDecrypt(@PathVariable String cryptoName, @RequestBody CryptoTestRequestDTO request) {
return respond(() -> {
String plainTextBase64 = cryptoModuleManageService.testDecrypt(cryptoName,
request.getRuntimeContext(), request.getCipherTextBase64(), request.getAadBase64());
Map<String, Object> data = new HashMap<>();
data.put("plainTextBase64", plainTextBase64);
return data;
});
}
private ResponseEntity<?> respond(Callable<Object> action) {
Map<String, Object> result = new HashMap<>();
try {
result.put("success", true);
result.put("data", action.call());
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return ResponseEntity.ok(result);
}
}
@@ -0,0 +1,61 @@
package com.eactive.eai.manage.crypto;
import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.eactive.eai.common.security.CryptoModuleDiagnosticDTO;
import com.eactive.eai.common.security.CryptoModuleExtension;
import com.eactive.eai.common.security.CryptoModuleManager;
/**
* CryptoModuleManager 진단 암복호화 테스트를 위임하는 서비스.
*
* createExtension(cryptoName, runtimeContext) STATIC 방식인 경우 내부에서
* createExtension(cryptoName) 으로 위임하므로, 테스트 runtimeContext 유무와 무관하게
* 동일 메서드를 사용한다.
*/
@Service
public class CryptoModuleManageService {
public List<CryptoModuleDiagnosticDTO> listAll() {
return CryptoModuleManager.getInstance().describeAll();
}
public CryptoModuleDiagnosticDTO get(String cryptoName) {
return CryptoModuleManager.getInstance().describe(cryptoName);
}
public List<String> listDynamicCacheKeys() {
return CryptoModuleManager.getInstance().listDynamicCacheKeys();
}
public String testEncrypt(String cryptoName, Map<String, String> runtimeContext,
String plainTextBase64, String aadBase64) throws Exception {
byte[] plain = Base64.getDecoder().decode(plainTextBase64);
CryptoModuleExtension ext = CryptoModuleManager.getInstance()
.createExtension(cryptoName, runtimeContext(runtimeContext));
byte[] cipher = aadBase64 != null
? ext.encrypt(plain, Base64.getDecoder().decode(aadBase64))
: ext.encrypt(plain);
return Base64.getEncoder().encodeToString(cipher);
}
public String testDecrypt(String cryptoName, Map<String, String> runtimeContext,
String cipherTextBase64, String aadBase64) throws Exception {
byte[] cipherBytes = Base64.getDecoder().decode(cipherTextBase64);
CryptoModuleExtension ext = CryptoModuleManager.getInstance()
.createExtension(cryptoName, runtimeContext(runtimeContext));
byte[] plain = aadBase64 != null
? ext.decrypt(cipherBytes, Base64.getDecoder().decode(aadBase64))
: ext.decrypt(cipherBytes);
return Base64.getEncoder().encodeToString(plain);
}
private Map<String, String> runtimeContext(Map<String, String> runtimeContext) {
return runtimeContext != null ? runtimeContext : Collections.emptyMap();
}
}
@@ -0,0 +1,17 @@
package com.eactive.eai.manage.crypto;
import java.util.Map;
import lombok.Data;
/**
* 암복호화 테스트 요청 DTO. 평문/암문은 Base64로 주고받는다.
*/
@Data
public class CryptoTestRequestDTO {
String plainTextBase64;
String cipherTextBase64;
String aadBase64;
Map<String, String> runtimeContext;
}
@@ -0,0 +1,82 @@
package com.eactive.eai.manage.hsm;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Callable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* HSM 연동 현황 조회 API. (ToolsController 동일한 응답 형태)
*
* PIN 비밀값과 원본은 응답에 포함하지 않는다.
*
* GET /manage/hsm/status 연결 설정(PRIMARY/SECONDARY), 적용 프로퍼티,
* KeyStore alias, 캐싱된 목록 전체 현황
* ?healthCheck=true 실제 HSM 통신으로 세션 생존까지 확인
* GET /manage/hsm/properties HSM 프로퍼티 그룹의 현재 (PIN 마스킹)
* GET /manage/hsm/keystore/aliases 현재 KeyStore alias 목록 (HSM 통신 발생)
* GET /manage/hsm/cache/keys HsmCryptoService 캐싱된 목록
* POST /manage/hsm/reload 즉시 재로드 (PRIMARY SECONDARY 연결 시도)
* POST /manage/hsm/cache/clear 캐시 초기화
*/
@RestController
@RequestMapping("/manage/hsm")
public class HsmStatusController {
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
@Autowired
private HsmStatusService hsmStatusService;
@GetMapping("/status")
public ResponseEntity<?> status(
@RequestParam(name = "healthCheck", required = false, defaultValue = "false") boolean healthCheck) {
return respond(() -> hsmStatusService.getStatus(healthCheck));
}
@GetMapping("/properties")
public ResponseEntity<?> properties() {
return respond(() -> hsmStatusService.getMaskedProperties());
}
@GetMapping("/keystore/aliases")
public ResponseEntity<?> keyStoreAliases() {
return respond(() -> hsmStatusService.getKeyAliases());
}
@GetMapping("/cache/keys")
public ResponseEntity<?> cachedKeys() {
return respond(() -> hsmStatusService.getCachedKeys());
}
@PostMapping("/reload")
public ResponseEntity<?> reload() {
return respond(() -> hsmStatusService.reloadNow());
}
@PostMapping("/cache/clear")
public ResponseEntity<?> clearCache() {
return respond(() -> hsmStatusService.clearKeyCache());
}
private ResponseEntity<?> respond(Callable<Object> action) {
Map<String, Object> result = new HashMap<>();
try {
result.put("success", true);
result.put("data", action.call());
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return ResponseEntity.ok().contentType(APPLICATION_JSON_UTF8).body(result);
}
}
@@ -0,0 +1,78 @@
package com.eactive.eai.manage.hsm;
import java.util.List;
import java.util.Map;
import com.eactive.eai.common.hsm.HsmCachedKeyInfo;
import lombok.Data;
/**
* HSM 연동 현황 진단 정보.
*
* PIN 비밀값과 원본은 포함하지 않는다. PKCS11_CONFIG 라이브러리 경로/슬롯 정보만
* 담고 있어 그대로 노출한다.
*/
@Data
public class HsmStatusDTO {
// ---- 기동/연결 상태 ----
/** HsmManager Lifecycle 기동 여부 */
boolean started;
/** Provider 와 KeyStore 가 모두 준비된 상태인지 (HSM 통신 없음) */
boolean ready;
/** 실제 HSM 키 조회까지 성공하는지 (HSM 통신 발생, 조회 요청 시에만 채움) */
Boolean healthy;
/** 현재 연결에 사용 중인 설정: PRIMARY / SECONDARY / null(미연결) */
String activeConfigName;
/** secondary 로 절체된 상태인지 */
boolean usingSecondaryConfig;
/** 현재 등록된 SunPKCS11 Provider 이름 */
String providerName;
/** 현재 설정으로 연결된 시각 */
String activeSince;
// ---- 설정 ----
/** PropManager 기준 연결 후보 설정과 실제 적용될 pkcs11.cfg 내용 (primary 우선순) */
Map<String, String> resolvedConfigs;
/** HSM 프로퍼티 그룹의 현재 값 (PIN 류는 마스킹) */
Map<String, String> properties;
/** KeyStore 주기적 재로드 간격(분) */
long reloadIntervalMinutes;
// ---- 재로드 이력 ----
String lastReloadAt;
String lastReloadResult;
String lastErrorAt;
String lastErrorMessage;
int reloadSuccessCount;
int reloadFailCount;
/** primary <-> secondary 절체 발생 횟수 */
int failoverCount;
// ---- ----
/** 현재 KeyStore 의 alias 목록 (HSM 통신 발생) */
List<String> keyAliases;
/** alias 조회 실패 시 사유. 성공이면 null */
String keyAliasError;
/** HsmCryptoService 에 캐싱된 키 목록 (키 원본 미포함) */
List<HsmCachedKeyInfo> cachedKeys;
/** 키 캐시 TTL(밀리초) */
long cacheTtlMs;
}
@@ -0,0 +1,147 @@
package com.eactive.eai.manage.hsm;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.eactive.eai.common.hsm.HsmCachedKeyInfo;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.hsm.HsmManager;
import com.eactive.eai.common.property.PropManager;
/**
* HSM 연동 현황 조회 서비스.
*
* HsmManager 연결 상태(primary/secondary), 적용 중인 HSM 프로퍼티, HsmCryptoService
* 캐시 목록을 곳에 모아 진단용으로 제공한다.
*/
@Service
public class HsmStatusService {
private static final String PROP_GROUP = "HSM";
/** 이 문자열이 포함된 프로퍼티 키의 값은 마스킹한다. */
private static final String[] SECRET_KEY_TOKENS = { "PIN", "PASSWORD", "PASSWD", "SECRET" };
private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
@Autowired
private HsmCryptoService hsmCryptoService;
/**
* @param includeHealthCheck true 이면 실제 HSM 통신으로 세션 생존까지 확인한다.
*/
public HsmStatusDTO getStatus(boolean includeHealthCheck) {
HsmManager hsmManager = HsmManager.getInstance();
HsmStatusDTO status = new HsmStatusDTO();
status.setStarted(hsmManager.isStarted());
status.setReady(hsmManager.isReady());
status.setActiveConfigName(hsmManager.getActiveConfigName());
status.setUsingSecondaryConfig(hsmManager.isUsingSecondaryConfig());
status.setProviderName(hsmManager.getProviderName());
status.setActiveSince(formatTime(hsmManager.getActiveSince()));
status.setResolvedConfigs(hsmManager.getResolvedConfigContents());
status.setProperties(getMaskedProperties());
status.setReloadIntervalMinutes(hsmManager.getReloadIntervalMinutes());
status.setLastReloadAt(formatTime(hsmManager.getLastReloadAt()));
status.setLastReloadResult(hsmManager.getLastReloadResult());
status.setLastErrorAt(formatTime(hsmManager.getLastErrorAt()));
status.setLastErrorMessage(hsmManager.getLastErrorMessage());
status.setReloadSuccessCount(hsmManager.getReloadSuccessCount());
status.setReloadFailCount(hsmManager.getReloadFailCount());
status.setFailoverCount(hsmManager.getFailoverCount());
try {
status.setKeyAliases(hsmManager.getKeyAliases());
} catch (Exception e) {
status.setKeyAliases(Collections.<String>emptyList());
status.setKeyAliasError(e.getMessage());
}
status.setCachedKeys(getCachedKeys());
status.setCacheTtlMs(hsmCryptoService.getCacheTtlMs());
if (includeHealthCheck) {
status.setHealthy(Boolean.valueOf(hsmManager.isHealthy()));
}
return status;
}
/** HsmCryptoService 에 캐싱된 키 목록 (키 원본 미포함). */
public List<HsmCachedKeyInfo> getCachedKeys() {
return hsmCryptoService.getCachedKeyInfos();
}
/** 현재 KeyStore 의 alias 목록. HSM 통신이 발생한다. */
public List<String> getKeyAliases() throws Exception {
return HsmManager.getInstance().getKeyAliases();
}
/**
* HSM 프로퍼티 그룹의 현재 . PIN 비밀값은 마스킹한다.
* PropManager 저장된 원본(암호화 저장 암호문) 그대로이며, 실제 적용되는
* 복호화 config 내용은 HsmStatusDTO.resolvedConfigs 에서 확인한다.
*/
public Map<String, String> getMaskedProperties() {
Map<String, String> masked = new TreeMap<>();
try {
Properties properties = PropManager.getInstance().getProperties(PROP_GROUP);
for (String key : properties.stringPropertyNames()) {
masked.put(key, mask(key, properties.getProperty(key)));
}
} catch (Exception e) {
masked.put("_error", "HSM 프로퍼티 그룹 조회 실패: " + e.getMessage());
}
return masked;
}
/**
* 재로드를 즉시 수행한다. primary -> secondary 순으로 연결을 시도하며,
* 신규 연결이 완전히 성공한 경우에만 KeyStore 교체된다.
*/
public String reloadNow() throws Exception {
HsmManager hsmManager = HsmManager.getInstance();
hsmManager.reloadKeyStoreIfNeeded();
return "재로드 완료. activeConfig=" + hsmManager.getActiveConfigName()
+ ", result=" + hsmManager.getLastReloadResult();
}
/** HsmCryptoService 키 캐시를 비운다. */
public String clearKeyCache() {
hsmCryptoService.clearKeyCache();
return "키 캐시 초기화 완료";
}
// -------------------------------------------------------------------------
private String mask(String key, String value) {
if (value == null) {
return null;
}
String upperKey = key.toUpperCase();
for (String token : SECRET_KEY_TOKENS) {
if (upperKey.contains(token)) {
return "****(len=" + value.length() + ")";
}
}
return value;
}
private String formatTime(long epochMillis) {
if (epochMillis <= 0) {
return null;
}
return new SimpleDateFormat(DATE_FORMAT).format(new Date(epochMillis));
}
}
@@ -0,0 +1,104 @@
package com.eactive.eai.manage.session;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.eactive.eai.common.session.CacheInfoVO;
/**
* SessionManager(SessionManagerForIgnite/SessionManagerForEhcache) 캐시 상태 모니터링 API.
*
* GET /manage/session/cache/status 전체 캐시 요약 (구현체별 XML 텍스트, Ignite/Ehcache)
* GET /manage/session/cache/all 개별 캐시(login/http-login/evict-master/terminal/convert-user-id/socket) 목록
* GET /manage/session/cache/login 로그인(WebSocket) 캐시
* GET /manage/session/cache/http-login HTTP 로그인 캐시
* GET /manage/session/cache/evict-master 싱글 어댑터 evict master 캐시
* GET /manage/session/cache/terminal 단말(ATM ) 캐시
* GET /manage/session/cache/convert-user-id 단말ID 사용자ID 변환 캐시
* GET /manage/session/cache/socket 소켓 세션 캐시
* GET /manage/session/cache/outbound-access-token 아웃바운드 AccessTokenVO 캐시 (Ignite 백엔드에서만 지원)
* GET /manage/session/master-inst-id 현재 evict master 인스턴스명
*/
@RestController
@RequestMapping("/manage/session")
public class SessionCacheManageController {
@Autowired
private SessionCacheManageService sessionCacheManageService;
@GetMapping("/cache/status")
public ResponseEntity<?> getCacheStatus() {
return ok(sessionCacheManageService.getCacheStatus());
}
@GetMapping("/master-inst-id")
public ResponseEntity<?> getMasterInstId() {
return ok(sessionCacheManageService.getMasterInstId());
}
@GetMapping("/cache/all")
public ResponseEntity<?> getAllCaches() {
List<CacheInfoVO> list = sessionCacheManageService.getAllCaches();
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("data", list);
result.put("count", list.size());
return ResponseEntity.ok(result);
}
@GetMapping("/cache/login")
public ResponseEntity<?> getLoginCache() {
return ok(sessionCacheManageService.getLoginCache());
}
@GetMapping("/cache/http-login")
public ResponseEntity<?> getHttpLoginCache() {
return ok(sessionCacheManageService.getHttpLoginCache());
}
@GetMapping("/cache/evict-master")
public ResponseEntity<?> getEvictMasterCache() {
return ok(sessionCacheManageService.getEvictMasterCache());
}
@GetMapping("/cache/terminal")
public ResponseEntity<?> getTerminalCache() {
return ok(sessionCacheManageService.getTerminalCache());
}
@GetMapping("/cache/convert-user-id")
public ResponseEntity<?> getConvertUserIdCache() {
return ok(sessionCacheManageService.getConvertUserIdCache());
}
@GetMapping("/cache/socket")
public ResponseEntity<?> getSocketCache() {
return ok(sessionCacheManageService.getSocketCache());
}
@GetMapping("/cache/outbound-access-token")
public ResponseEntity<?> getOutboundAccessTokenCache() {
try {
return ok(sessionCacheManageService.getOutboundAccessTokenCache());
} catch (UnsupportedOperationException e) {
Map<String, Object> result = new HashMap<>();
result.put("success", false);
result.put("message", "현재 SessionManager 백엔드는 OutboundAccessToken 캐시를 지원하지 않습니다.");
return ResponseEntity.ok(result);
}
}
private ResponseEntity<?> ok(Object data) {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("data", data);
return ResponseEntity.ok(result);
}
}
@@ -0,0 +1,71 @@
package com.eactive.eai.manage.session;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import com.eactive.eai.common.session.CacheInfoVO;
import com.eactive.eai.common.session.SessionManager;
/**
* SessionManager(SessionManagerForIgnite/SessionManagerForEhcache) 캐시 조회를 위임하는 서비스.
*
* SessionManager.getInstance() 설정(session_manager.cacheType) 따라 Ignite/Ehcache
* 구현체 하나의 싱글턴을 반환하며, 구현체 모두 아래 조회 메서드를 지원한다.
*/
@Service
public class SessionCacheManageService {
public String getCacheStatus() {
return SessionManager.getInstance().getCacheStatus();
}
public String getMasterInstId() {
return SessionManager.getInstance().getMasterInstId();
}
public CacheInfoVO getLoginCache() {
return SessionManager.getInstance().getLoginCache();
}
public CacheInfoVO getHttpLoginCache() {
return SessionManager.getInstance().getHttpLoginCache();
}
public CacheInfoVO getEvictMasterCache() {
return SessionManager.getInstance().getEvictMasterCache();
}
public CacheInfoVO getTerminalCache() {
return SessionManager.getInstance().getTerminalCache();
}
public CacheInfoVO getConvertUserIdCache() {
return SessionManager.getInstance().getConvertUserIdCache();
}
public CacheInfoVO getSocketCache() {
return SessionManager.getInstance().getSocketCache();
}
public CacheInfoVO getOutboundAccessTokenCache() {
return SessionManager.getInstance().getOutboundAccessTokenCache();
}
public List<CacheInfoVO> getAllCaches() {
List<CacheInfoVO> result = new ArrayList<>();
result.add(getLoginCache());
result.add(getHttpLoginCache());
result.add(getEvictMasterCache());
result.add(getTerminalCache());
result.add(getConvertUserIdCache());
result.add(getSocketCache());
try {
result.add(getOutboundAccessTokenCache());
} catch (UnsupportedOperationException e) {
// Ehcache 백엔드는 OutboundAccessToken 캐시를 지원하지 않는다.
}
return result;
}
}
@@ -0,0 +1,15 @@
package com.eactive.eai.manage.tools;
import lombok.Data;
/**
* EncryptionManager의 현재 설정 상태. encrypt/decrypt 결과가 원문 그대로인 이유
* (encryptYN=N이면 encryptDBData가 원문을 그대로 반환) 함께 확인하기 위한 진단 정보다.
*/
@Data
public class EncryptionManagerStatusDTO {
String encryptYN;
String dbEncryptSolutionName;
boolean encryptEnabled;
}
@@ -0,0 +1,123 @@
package com.eactive.eai.manage.tools;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Callable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* base64 / hex / DAMO 변환을 브라우저나 Postman 등에서 바로 테스트할 있는 유틸리티 API.
*
* POST /manage/tools/base64/encode 문자열 Base64
* POST /manage/tools/base64/decode Base64 문자열
* POST /manage/tools/hex/encode 문자열 Hex
* POST /manage/tools/hex/decode Hex 문자열
* POST /manage/tools/damo/encrypt 평문 DAMO 암호문 (DamoManager 직접 호출)
* POST /manage/tools/damo/decrypt DAMO 암호문 평문 (DamoManager 직접 호출)
*
* POST /manage/tools/encryption-manager/encrypt EncryptionManager.encryptDBData 확인용
* POST /manage/tools/encryption-manager/decrypt EncryptionManager.decryptDBData 확인용
* GET /manage/tools/encryption-manager/status encryptYN/dbEncryptSolutionName 현재 설정 확인
*
* GET /manage/tools/version 배포된 게이트웨이(eapim-online.war) git 버전/빌드시각 확인.
* eapim-online(WAR를 만드는 루트 프로젝트) build.gradle의
* generateVersionInfo 태스크가 생성하는 classpath 리소스
* version.info(git describe 결과) 기반. 재빌드 없이 기동한 로컬
* 환경 파일이 없으면 success=false로 응답한다
*
* base64/hex 변환은 요청 body의 charset(생략 UTF-8) 기준으로 문자열 바이트를 변환한다.
*/
@RestController
@RequestMapping("/manage/tools")
public class ToolsController {
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
@Autowired
private ToolsService toolsService;
@PostMapping("/base64/encode")
public ResponseEntity<?> base64Encode(@RequestBody ToolsTextRequestDTO request) {
return respond(() -> toolsService.base64Encode(request.getText(), request.getCharset()));
}
@PostMapping("/base64/decode")
public ResponseEntity<?> base64Decode(@RequestBody ToolsTextRequestDTO request) {
return respond(() -> toolsService.base64Decode(request.getText(), request.getCharset()));
}
@PostMapping("/hex/encode")
public ResponseEntity<?> hexEncode(@RequestBody ToolsTextRequestDTO request) {
return respond(() -> toolsService.hexEncode(request.getText(), request.getCharset()));
}
@PostMapping("/hex/decode")
public ResponseEntity<?> hexDecode(@RequestBody ToolsTextRequestDTO request) {
return respond(() -> toolsService.hexDecode(request.getText(), request.getCharset()));
}
@PostMapping("/damo/encrypt")
public ResponseEntity<?> damoEncrypt(@RequestBody ToolsTextRequestDTO request) {
return respond(() -> toolsService.damoEncrypt(request.getText()));
}
@PostMapping("/damo/decrypt")
public ResponseEntity<?> damoDecrypt(@RequestBody ToolsTextRequestDTO request) {
return respond(() -> toolsService.damoDecrypt(request.getText()));
}
@PostMapping("/encryption-manager/encrypt")
public ResponseEntity<?> encryptionManagerEncrypt(@RequestBody ToolsTextRequestDTO request) {
return respond(() -> toolsService.encryptionManagerEncrypt(request.getText()));
}
@PostMapping("/encryption-manager/decrypt")
public ResponseEntity<?> encryptionManagerDecrypt(@RequestBody ToolsTextRequestDTO request) {
return respond(() -> toolsService.encryptionManagerDecrypt(request.getText()));
}
@GetMapping("/encryption-manager/status")
public ResponseEntity<?> encryptionManagerStatus() {
return respond(() -> toolsService.encryptionManagerStatus());
}
@GetMapping("/version")
public ResponseEntity<?> getVersionInfo() {
Map<String, Object> result = new HashMap<>();
try {
VersionInfoDTO versionInfo = toolsService.getVersionInfo();
if (versionInfo == null) {
result.put("success", false);
result.put("message", "version.info 파일이 없습니다. gradle의 generateVersionInfo 태스크(또는 build) 실행 후 재기동하세요.");
} else {
result.put("success", true);
result.put("data", versionInfo);
}
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return ResponseEntity.ok().contentType(APPLICATION_JSON_UTF8).body(result);
}
private ResponseEntity<?> respond(Callable<Object> action) {
Map<String, Object> result = new HashMap<>();
try {
result.put("success", true);
result.put("data", action.call());
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return ResponseEntity.ok().contentType(APPLICATION_JSON_UTF8).body(result);
}
}
@@ -0,0 +1,126 @@
package com.eactive.eai.manage.tools;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
import javax.xml.bind.DatatypeConverter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import com.eactive.eai.agent.encryption.EncryptionManager;
/**
* base64 / hex / DAMO 변환을 테스트하기 위한 서비스.
*
* DAMO 암복호화는 EncryptionManager와 동일하게 com.eactive.ext.djb.DamoManager를 직접 사용한다.
* EncryptionManager.encryptDBData/decryptDBData는 encryptYN=Y로 설정된 환경에서만 동작하므로
* (비활성 환경에서는 원문을 그대로 반환), 환경 설정과 무관하게 항상 실제 암복호화를 확인할 있도록
* DamoManager를 직접 호출한다.
*
* encryptionManagerEncrypt/Decrypt는 위와 별개로, 실제 운영 코드가 사용하는
* EncryptionManager.encryptDBData/decryptDBData를 그대로 호출해 현재 서버 설정(encryptYN,
* dbEncryptSolutionName) 반영된 실제 동작을 확인하기 위한 용도다.
*/
@Service
public class ToolsService {
private static final String MODULE_KEY_PREFIX = "module.";
private Charset resolveCharset(String charset) {
return StringUtils.isNotBlank(charset) ? Charset.forName(charset) : StandardCharsets.UTF_8;
}
public String base64Encode(String text, String charset) {
byte[] bytes = text.getBytes(resolveCharset(charset));
return Base64.getEncoder().encodeToString(bytes);
}
public String base64Decode(String base64Text, String charset) {
byte[] bytes = Base64.getDecoder().decode(base64Text);
return new String(bytes, resolveCharset(charset));
}
public String hexEncode(String text, String charset) {
byte[] bytes = text.getBytes(resolveCharset(charset));
return DatatypeConverter.printHexBinary(bytes);
}
public String hexDecode(String hexText, String charset) {
byte[] bytes = DatatypeConverter.parseHexBinary(hexText.trim());
return new String(bytes, resolveCharset(charset));
}
public String damoEncrypt(String text) {
return new com.eactive.ext.djb.DamoManager().encrypt(text);
}
public String damoDecrypt(String text) {
return new com.eactive.ext.djb.DamoManager().decrypt(text);
}
public String encryptionManagerEncrypt(String text) {
return EncryptionManager.getInstance().encryptDBData(text);
}
public String encryptionManagerDecrypt(String text) {
return EncryptionManager.getInstance().decryptDBData(text);
}
public EncryptionManagerStatusDTO encryptionManagerStatus() {
EncryptionManager manager = EncryptionManager.getInstance();
EncryptionManagerStatusDTO status = new EncryptionManagerStatusDTO();
status.setEncryptYN(manager.getEncryptYN());
status.setDbEncryptSolutionName(manager.getDBEncryptSolutionName());
status.setEncryptEnabled(manager.isEncrypt());
return status;
}
/**
* eapim-online(WAR를 생성하는 루트 프로젝트) build.gradle의 generateVersionInfo 태스크가
* processResources 이전에 생성하는 classpath 리소스 version.info(version=git describe 결과,
* buildTime=...) 읽는다. WAR는 WEB-INF/classes와 WEB-INF/lib가 클래스로더를 공유하므로
* 클래스(elink-online-common 모듈)에서 조회해도 정상적으로 찾을 있다.
* 재빌드 없이 IDE에서 바로 기동한 로컬 환경 파일이 없는 경우 null을 반환한다.
*/
public VersionInfoDTO getVersionInfo() {
try (InputStream in = getClass().getResourceAsStream("/version.info")) {
if (in == null) {
return null;
}
return parseVersionInfo(in);
} catch (IOException e) {
throw new RuntimeException("version.info 읽기 실패", e);
}
}
/** version.info 내용을 파싱한다. 리소스 조회와 분리해 테스트에서 직접 검증할 수 있게 한다. */
VersionInfoDTO parseVersionInfo(InputStream in) throws IOException {
Properties props = new Properties();
// Properties.load(InputStream) ISO-8859-1로 고정 해석되어 UTF-8로 기록된
// version.info의 한글(git tag ) 깨진다. Reader로 감싸 UTF-8로 디코딩해서 넘긴다.
props.load(new InputStreamReader(in, StandardCharsets.UTF_8));
VersionInfoDTO dto = new VersionInfoDTO();
dto.setVersion(props.getProperty("version"));
dto.setBuildTime(props.getProperty("buildTime"));
// module.<서브모듈명>=<git describe 결과> 형태의 키를 모아 서브모듈별 버전으로 담는다.
Map<String, String> moduleVersions = new TreeMap<>();
for (String key : props.stringPropertyNames()) {
if (key.startsWith(MODULE_KEY_PREFIX)) {
moduleVersions.put(key.substring(MODULE_KEY_PREFIX.length()), props.getProperty(key));
}
}
dto.setModuleVersions(moduleVersions);
return dto;
}
}
@@ -0,0 +1,16 @@
package com.eactive.eai.manage.tools;
import lombok.Data;
/**
* base64/hex/damo 변환 테스트 요청 DTO.
*
* charset은 base64/hex 변환에서 문자열 바이트 변환에 사용되며, 생략 UTF-8이 적용된다.
* damo 암복호화는 String 기반 API(DamoManager) 그대로 사용하므로 charset을 사용하지 않는다.
*/
@Data
public class ToolsTextRequestDTO {
String text;
String charset;
}
@@ -0,0 +1,25 @@
package com.eactive.eai.manage.tools;
import java.util.Map;
import lombok.Data;
/**
* 빌드 시점에 생성되는 version.info(classpath 리소스) 내용을 담는 DTO.
* eapim-online(WAR 루트 프로젝트) build.gradle의 generateVersionInfo 태스크가
* processResources 이전에 생성한다.
*/
@Data
public class VersionInfoDTO {
/** git describe --tags --always --dirty 결과 (예: 20260722_개발배포, 또는 태그 없으면 커밋 해시). */
String version;
String buildTime;
/**
* elink-online-common git submodule 각각의 git describe 결과.
* 키는 submodule 디렉토리명(elink-online-common ), 값은 describe 결과.
* 루트(eapim-online) describe/dirty만으로는 어느 서브모듈이 바뀌었는지 없어서 별도로 담는다.
*/
Map<String, String> moduleVersions;
}
@@ -140,7 +140,7 @@ reader.FLAT=com.eactive.eai.message.parser.FlatReader
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
readerMap.clear();
readerMap = null;
// readerMap = null;
started = false;
lifecycle.fireLifecycleEvent(STOPPED_EVENT, this);
}
@@ -208,6 +208,10 @@ reader.FLAT=com.eactive.eai.message.parser.FlatReader
@SuppressWarnings("rawtypes")
private void initReaderFactory(Properties config) {
if (readerMap == null) {
readerMap = new ConcurrentHashMap<>();
}
Class cl = null;
StandardReader reader = null;
@@ -8,13 +8,13 @@ import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.eactive.eai.common.util.JacksonUtil;
import com.eactive.eai.message.EncodingVar;
import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage;
import com.eactive.eai.message.StandardType;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -22,6 +22,11 @@ import com.fasterxml.jackson.databind.node.JsonNodeType;
public class JsonReader implements StandardReader {
static Logger logger = LoggerFactory.getLogger(JsonReader.class);
// ObjectMapper 설정이 끝나면 thread-safe 하고 생성 비용이 크므로 재사용한다.
// 숫자 보존 옵션(입력/출력) JacksonUtil.newNumberSafeMapper() 참조.
private static final ObjectMapper MAPPER = JacksonUtil.newNumberSafeMapper();
private char FIELD_SEPARATOR = '.';
private boolean ZERO_BASE_INDEX = true;
@@ -36,24 +41,28 @@ public class JsonReader implements StandardReader {
} else {
jsonString = (String) obj;
}
// 상대 시스템이 개행 제어문자를 이스케이프하지 않고 그대로 보내는 경우가 있다.
// 그대로 파싱하면 "Illegal unquoted character ((CTRL-CHAR, code 10))" 실패하므로
// 문자열 리터럴 안의 제어문자만 표준 이스케이프로 정규화한 파싱한다.
// (파서 옵션으로 푸는 대신 입력을 표준 JSON 으로 맞추는 방식)
jsonString = JacksonUtil.escapeControlChars(jsonString);
JsonNode jsonNode = null;
ObjectMapper mapper = null;
ObjectMapper mapper = MAPPER;
JsonFactory factory = null;
JsonParser parser = null;
mapper = new ObjectMapper();
mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
factory = mapper.getFactory();
try {
parser = factory.createParser(jsonString);
} catch (IOException e) {
e.printStackTrace();
//e.printStackTrace();
throw e;
}
try {
jsonNode = mapper.readTree(parser);
} catch (IOException e) {
e.printStackTrace();
//e.printStackTrace();
throw e;
}
@@ -684,11 +684,17 @@ public abstract class DefaultProcess extends Process {
AdapterVO inboundAdapterVO = AdapterManager.getInstance().getAdapterVO(inboudnAdapterGroupName, inboudnAdapterName);
String errorResponseHandlerClass = AdapterPropManager.getInstance().getProperty(inboundAdapterVO.getPropGroupName(), "ERR_MSG_HANDLER");
if(StringUtils.isBlank(errorResponseHandlerClass)) {
String errorCode = mapper.getErrorCode(resStandardMessage);
String errorMsg = StringUtils.trim(mapper.getErrorMsg(resStandardMessage));
String errorDesc = StringUtils.trim(resStandardMessage.findItemValue("MSG.MAIN_MSG.outp_msg_desc"));
this.resEaiMsg.setRspErr("RECEAIINA001", String.format("[%s] %s (%s)", errorCode, errorMsg, errorDesc));
return;
if ((com.eactive.eai.adapter.Keys.IF_STANDARD.equals(this.adptrMsgPtrnCd)
||com.eactive.eai.adapter.Keys.IF_SUBSTANDARD.equals(this.adptrMsgPtrnCd))) {
String errorCode = mapper.getErrorCode(resStandardMessage);
String errorMsg = StringUtils.trim(mapper.getErrorMsg(resStandardMessage));
String errorDesc = StringUtils.trim(resStandardMessage.findItemValue("MSG.MAIN_MSG.outp_msg_desc"));
this.resEaiMsg.setRspErr("RECEAIINA001", String.format("[%s] %s (%s)", errorCode, errorMsg, errorDesc));
return;
} else {
this.resEaiMsg.setRspErr("RECEAIINA001", "비표준 오류 응답 수신");
return;
}
}
logger.info(inboudnAdapterName+", Handle Error Message Class-"+errorResponseHandlerClass);
@@ -696,11 +702,21 @@ public abstract class DefaultProcess extends Process {
AdapterErrorMessageHandler adapterErrorMessageHandler = AdapterErrorMessageHandlerFactory.createHandler(errorResponseHandlerClass);
Object responseObj = adapterErrorMessageHandler.generateNonStandardErrorResponseMessage(inboudnAdapterGroupName, inboudnAdapterName, this.callProp, this.tgtTranObject, this.resEaiMsg);
if(responseObj != null) {
resStandardMessage.setBizData(responseObj, inboundAdapterGroupVO.getMessageEncode());
if ((com.eactive.eai.adapter.Keys.IF_STANDARD.equals(this.adptrMsgPtrnCd)
||com.eactive.eai.adapter.Keys.IF_SUBSTANDARD.equals(this.adptrMsgPtrnCd))) {
String errorCode = mapper.getErrorCode(resStandardMessage);
String errorMsg = StringUtils.trim(mapper.getErrorMsg(resStandardMessage));
String errorDesc = StringUtils.trim(resStandardMessage.findItemValue("MSG.MAIN_MSG.outp_msg_desc"));
this.resEaiMsg.setRspErr("RECEAIINA001", String.format("[%s] %s (%s)", errorCode, errorMsg, errorDesc));
} else {
this.resEaiMsg.setRspErr("RECEAIINA001", "비표준 오류 응답 수신");
}
this.resEaiMsg.setRspErrCd(EAIMessageKeys.BWK_FAILMSG_CODE, false);
if(responseObj != null) {
resStandardMessage.setBizData(responseObj, inboundAdapterGroupVO.getMessageEncode());
this.resEaiMsg.setOrgRspErrCd(this.resEaiMsg.getRspErrCd());
this.resEaiMsg.setRspErrCd(EAIMessageKeys.BWK_FAILMSG_CODE, false);
}
}
}
@@ -273,7 +273,10 @@ public class HTTPProcess extends DefaultProcess {
try {
// 응답전문으로 응답 구조체(resEaiMsg) SET
resEaiMsg = setRcvLogInfo(resEaiMsg, this.adptrMsgType, this.resObject, this.outboundCharset);
this.resObject = resEaiMsg.getStandardMessage().getBizDataBytes();
// JSON/XML String 으로 넘긴다. byte[] 넘기면 charset 정보가 유실되어 변환 깨진다.
this.resObject = MessageUtil.isBytesMessage(this.adptrMsgType)
? resEaiMsg.getStandardMessage().getBizDataBytes()
: resEaiMsg.getStandardMessage().getBizData();
} catch (Exception e) {
String[] msgArgs = new String[1];
msgArgs[0] = this.reqEaiMsg.getEAISvcCd();
@@ -289,7 +292,10 @@ public class HTTPProcess extends DefaultProcess {
logger.debug(guidLogPrefix + "SUB표준 업무데이터 추출");
try {
this.resEaiMsg = convertToStandardMessage(resEaiMsg, adptrMsgType, this.resObject, this.outboundCharset);
this.resObject = resEaiMsg.getStandardMessage().getBizDataBytes();
// JSON/XML String 으로 넘긴다. byte[] 넘기면 charset 정보가 유실되어 변환 깨진다.
this.resObject = MessageUtil.isBytesMessage(this.adptrMsgType)
? resEaiMsg.getStandardMessage().getBizDataBytes()
: resEaiMsg.getStandardMessage().getBizData();
} catch (Exception e) {
e.printStackTrace();
String[] msgArgs = new String[1];
@@ -883,5 +889,20 @@ public class HTTPProcess extends DefaultProcess {
standardMessage.setData(key, value);
});
}
// 시스템환경구분코드 설정 (D/T/P)
EAIServerManager eaiServer = EAIServerManager.getInstance();
String sysEnvDvcd = getSysEnvDvcd(eaiServer);
if(StringUtils.equals("P", sysEnvDvcd)) {
StandardMessage standardMessage = this.reqEaiMsg.getStandardMessage();
this.reqEaiMsg.getMapper().setOperationEnv(standardMessage, "P");
}
}
private String getSysEnvDvcd(EAIServerManager server) {
if (server.isPEAIServer()) return "P"; // 운영
if (server.isSEAIServer()) return "T"; // 검증/테스트
return "D"; // 개발
}
}
@@ -98,6 +98,10 @@ public class RESTProcess extends HTTPProcess {
this.timeout = iTimeoutValue * 1000;
this.tempProp.put(INTERFACE_TIME_OUT, "" + this.timeout);
this.tempProp.put("API_SERVICE_CODE", this.reqEaiMsg.getEAISvcCd());
this.tempProp.put("OUT_REQ_EAI_MSG", this.reqEaiMsg);
this.tempProp.put("OUT_REQ_STD_MSG", this.reqEaiMsg.getStandardMessage());
try {
// API별 이용
String apiId = reqEaiMsg.getEAISvcCd();
@@ -694,35 +698,37 @@ public class RESTProcess extends HTTPProcess {
public void setOutboundErrorMessage() throws Exception {
this.logPssSno = TranLogUtil.getResLogSeq(this.svcPssTp, this.svcPssSeq, this.isComp);
boolean setResObject = false;
// 에러에 대한 응답메시지
if(this.tempProp.get(HttpAdapterServiceKey.OUTBOUND_PROPERTY_MAP) instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> map = (Map<String, Object>) this.tempProp.get(HttpAdapterServiceKey.OUTBOUND_PROPERTY_MAP);
this.callProp.put(HttpAdapterServiceKey.OUTBOUND_PROPERTY_MAP, map);
}
boolean setResObject = false;
AdapterGroupVO outboundAdapterGroupVO = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (outboundAdapterGroupVO != null) {
AdapterVO outboundAdapterVo = null;
if(adapterName != null)
outboundAdapterVo = outboundAdapterGroupVO.getAdapterVO(adapterName);
else
outboundAdapterVo = outboundAdapterGroupVO.nextAdapterVO();
if (outboundAdapterVo != null) {
String errorHandlerClass = AdapterPropManager.getInstance().getProperty(
outboundAdapterVo.getPropGroupName(), "ERR_MSG_HANDLER");
if (StringUtils.isNotBlank(errorHandlerClass)) {
if (logger.isInfo())
logger.info("ExceptionHandler] errorSyncSend ERR_MSG_HANDLER=" + errorHandlerClass);
AdapterErrorMessageHandler handler = AdapterErrorMessageHandlerFactory.createHandler(errorHandlerClass);
this.resObject = handler.generateOutboundErrorResponseMessage(adapterGroupName, adapterGroupName,
callProp, this.reqObject, this.resEaiMsg);
setResObject = true;
// OUTBOUND_PROPERTY_MAP 있는 경우, 타겟서버와 통신은 정상적으로 이루진 경우 처리한다.
AdapterGroupVO outboundAdapterGroupVO = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (outboundAdapterGroupVO != null) {
AdapterVO outboundAdapterVo = null;
if(adapterName != null)
outboundAdapterVo = outboundAdapterGroupVO.getAdapterVO(adapterName);
else
outboundAdapterVo = outboundAdapterGroupVO.nextAdapterVO();
if (outboundAdapterVo != null) {
String errorHandlerClass = AdapterPropManager.getInstance().getProperty(
outboundAdapterVo.getPropGroupName(), "ERR_MSG_HANDLER");
if (StringUtils.isNotBlank(errorHandlerClass)) {
if (logger.isInfo())
logger.info("ExceptionHandler] errorSyncSend ERR_MSG_HANDLER=" + errorHandlerClass);
AdapterErrorMessageHandler handler = AdapterErrorMessageHandlerFactory.createHandler(errorHandlerClass);
this.resObject = handler.generateOutboundErrorResponseMessage(adapterGroupName, adapterGroupName,
callProp, this.reqObject, this.resEaiMsg);
setResObject = true;
}
}
}
}
if (!setResObject) {
this.resObject = "";
}
@@ -25,6 +25,33 @@ public class HexaConverter {
return bytes;
}
public static String binToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02X", b));
}
return sb.toString();
}
public static byte[] hexToBin(String hexStr) {
String lookup = "0123456789ABCDEF";
int len = hexStr.length();
byte[] bArray = new byte[len / 2];
for (int i = 0; i < bArray.length; i++) {
char highChar = Character.toUpperCase(hexStr.charAt(i * 2));
char lowChar = Character.toUpperCase(hexStr.charAt(i * 2 + 1));
int high = lookup.indexOf(highChar);
int low = lookup.indexOf(lowChar);
bArray[i] = (byte) ((high << 4) | low);
}
return bArray;
}
// public static void main(String[] argv) {
// String str = "TEST\nÇѱÛ";
// String hexStr = HexaConverter.bytesToHexa(str.getBytes());
@@ -2,6 +2,7 @@ package com.eactive.eai.util;
import org.json.simple.JSONObject;
import com.eactive.eai.common.util.JacksonUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
@@ -15,7 +16,10 @@ public class JsonPathUtil {
}
private static final ObjectMapper objectMapper = new ObjectMapper();
// readTree() 파싱한 writeValueAsString() 으로 되돌리는 왕복이 잦으므로,
// 숫자 자릿수가 유실되지 않는 mapper 쓴다.
// 기본 ObjectMapper 100000000.00 1.0E8 바꿔버린다.
private static final ObjectMapper objectMapper = JacksonUtil.newNumberSafeMapper();
// ---------------------------------------------------------------
// JsonNode 기반 단일 파싱 API 연속 get/set 파싱 횟수 절감
@@ -145,12 +149,12 @@ public class JsonPathUtil {
* @param mergeJson 병합할 JSON 문자열
* @return 병합된 JSON 문자열, 병합 불가 mergeJson 그대로 반환
*/
public static String mergeAtRoot(JsonNode bodyNode, String removeFromPath, String mergeJson) {
public static JsonNode mergeAtRoot(JsonNode bodyNode, String removeFromPath, String mergeJson) {
try {
JsonNode mergeNode = objectMapper.readTree(mergeJson);
if (!bodyNode.isObject() || !mergeNode.isObject()) {
return mergeJson;
return bodyNode;
}
ObjectNode result = (ObjectNode) bodyNode.deepCopy();
@@ -169,9 +173,10 @@ public class JsonPathUtil {
// mergeJson 필드를 body에 병합 (기존 필드 덮어쓰기)
result.setAll((ObjectNode) mergeNode);
return objectMapper.writeValueAsString(result);
// return objectMapper.writeValueAsString(result);
return result;
} catch (Exception e) {
return mergeJson;
return bodyNode;
}
}
@@ -192,7 +197,7 @@ public class JsonPathUtil {
*/
public static String mergeAtRoot(String body, String removeFromPath, String mergeJson) {
try {
return mergeAtRoot(objectMapper.readTree(body), removeFromPath, mergeJson);
return objectMapper.writeValueAsString(mergeAtRoot(objectMapper.readTree(body), removeFromPath, mergeJson));
} catch (Exception e) {
return mergeJson;
}
@@ -0,0 +1,108 @@
package com.eactive.eai.util;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.json.simple.JSONValue;
public class QueryStringUtils {
private QueryStringUtils() {
throw new IllegalStateException("Utility class");
}
public static Map<String, String[]> parseQueryString(String queryString, String charset) throws UnsupportedEncodingException {
return parseQueryString(queryString, charset, true);
}
public static Map<String, String[]> parseQueryString(String queryString) throws UnsupportedEncodingException {
return parseQueryString(queryString, null, false);
}
public static Map<String, String[]> parseQueryString(String queryString, String charset, boolean urlDecode) throws UnsupportedEncodingException {
Map<String, String[]> paramMap = new HashMap<>();
Map<String, List<String>> tempMap = new HashMap<>();
if(StringUtils.isEmpty(queryString))
return paramMap;
String[] pairs = queryString.split("&");
for(String pair : pairs) {
if(StringUtils.isEmpty(pair))
continue;
int idx = pair.indexOf("=");
String key = null;
String value = null;
if(idx > 0) {
if(urlDecode)
key = URLDecoder.decode(pair.substring(0, idx), charset);
else
if(charset != null)
key = new String((pair.substring(0, idx)).getBytes(charset));
else
key = pair.substring(0, idx);
if(urlDecode)
value = URLDecoder.decode(pair.substring(idx + 1), charset);
else
if(charset != null)
value = new String((pair.substring(idx + 1)).getBytes(charset));
else
value = pair.substring(idx + 1);
} else {
key = urlDecode ? URLDecoder.decode(pair, charset) : new String(pair.getBytes(charset));
value = "";
}
tempMap.computeIfAbsent(key, k -> new ArrayList<>()).add(value);
}
for(Map.Entry<String, List<String>> entry : tempMap.entrySet()) {
paramMap.put(entry.getKey(), entry.getValue().toArray(new String[0]));
}
return paramMap;
}
public static String makeJson(Map<String, String[]> paramMap, boolean bUrlDecode) throws UnsupportedEncodingException {
StringBuilder sb = new StringBuilder();
sb.append("{");
int i = 0;
for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
if (i > 0) {
sb.append(",");
}
sb.append("\"").append(entry.getKey()).append("\":");
String[] values = entry.getValue();
if (values.length > 1) {
// ["111", "222"]
sb.append("[");
for (int j = 0; j < values.length; j++) {
if (j > 0) {
sb.append(",");
}
String value = bUrlDecode ? URLDecoder.decode(values[j], StandardCharsets.UTF_8.name()) : values[j];
sb.append("\"").append(JSONValue.escape(value)).append("\"");
}
sb.append("]");
} else {
String value = bUrlDecode ? URLDecoder.decode(values[0], StandardCharsets.UTF_8.name()) : values[0];
sb.append("\"").append(JSONValue.escape(value)).append("\"");
}
i++;
}
sb.append("}");
return sb.toString();
}
}
@@ -2,6 +2,7 @@ package com.eactive.eai.util.json;
import java.util.Map;
import com.eactive.eai.common.util.JacksonUtil;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.util.json.transformer.ValueTransformer;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -14,7 +15,9 @@ import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider;
public class JsonPathsTransform {
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
private static final ObjectMapper objectMapper = new ObjectMapper();
// 파싱 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 쓴다.
// 기본 ObjectMapper 100000000.00 1.0E8 바꿔버린다.
private static final ObjectMapper objectMapper = JacksonUtil.newNumberSafeMapper();
public static <T, R> String modifyValuesAtPaths(String jsonString,
Map<String, ValueTransformer<T, R>> pathTransformerMap, boolean isPretty) {
@@ -23,9 +26,11 @@ public class JsonPathsTransform {
// DocumentContext documentContext = JsonPath.parse(jsonString);
// 변경헤도 별차이가 없음.
// provider mapper 넘기지 않으면 json-path 자체 기본 ObjectMapper 쓰게 되어
// documentContext.jsonString() 단계에서 이미 숫자 자릿수가 유실된다.
Configuration conf = Configuration.builder()
.jsonProvider(new JacksonJsonNodeJsonProvider())
.mappingProvider(new JacksonMappingProvider())
.jsonProvider(new JacksonJsonNodeJsonProvider(objectMapper))
.mappingProvider(new JacksonMappingProvider(objectMapper))
.build();
DocumentContext documentContext = JsonPath.using(conf).parse(jsonString);
@@ -2,6 +2,7 @@ package com.eactive.eai.util.json;
import java.util.Map;
import com.eactive.eai.common.util.JacksonUtil;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.util.json.transformer.ValueTransformer;
import com.fasterxml.jackson.databind.JsonNode;
@@ -11,7 +12,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
public class JsonSimplePathsTransform {
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
private static final ObjectMapper objectMapper = new ObjectMapper();
// 파싱 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 쓴다.
// 기본 ObjectMapper 100000000.00 1.0E8 바꿔버린다.
private static final ObjectMapper objectMapper = JacksonUtil.newNumberSafeMapper();
public static <T, R> String modifyValuesAtPaths(String jsonString,
Map<String, ValueTransformer<T, R>> pathTransformerMap, boolean isPretty) {
@@ -56,7 +56,10 @@ public class OAuth2AccessTokenVO implements AccessTokenVO, Serializable
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
if(tokenType.equalsIgnoreCase(BEARER_TYPE))
this.tokenType = BEARER_TYPE;
else
this.tokenType = tokenType;
}
public long getExpiresIn() {
@@ -1383,4 +1383,428 @@ class TemplateAdapterErrorMsgHandlerTest {
assertNotNull(result);
}
}
// ================================================================
// default.* 템플릿 폴백
//
// 그룹 전용 키가 미설정/공백이면 "default." 시작하는 공통 키를 재조회한다.
// generateNonStandardErrorResponseMessage : {group}.template default.template
// generateOutboundErrorResponseMessage : {group}.template default.template
// generateNonStandardInternalErrorResponseMessage : {group}.sys.template default.sys.template
// generateNonStandardInboundErrorResponseMessage : {group}.in.{code}.template
// default.in.{code}.template
// {group}.in.template
// default.in.template
// MessageUtil 폴백
// ================================================================
@Nested
@DisplayName("default.* 템플릿 폴백")
class DefaultTemplateFallback {
private static final String G = TemplateAdapterErrorMsgHandler.PROP_GROUP;
private PropManager mockPropManager;
private AdapterManager mockAdapterManager;
private AdapterPropManager mockAdapterPropManager;
@BeforeEach
void setUpDefaults() throws Exception {
mockPropManager = Mockito.mock(PropManager.class);
mockAdapterManager = Mockito.mock(AdapterManager.class);
mockAdapterPropManager = Mockito.mock(AdapterPropManager.class);
// 명시적으로 stub 하지 않은 모든 키는 미설정(null) 간주
Mockito.when(mockPropManager.getProperty(anyString(), anyString())).thenReturn(null);
Mockito.when(mockPropManager.getProperty(anyString(), anyString(), anyString()))
.thenAnswer(invocation -> invocation.getArgument(2));
AdapterGroupVO adapterGroupVO = Mockito.mock(AdapterGroupVO.class);
AdapterVO adapterVO = Mockito.mock(AdapterVO.class);
Mockito.when(adapterGroupVO.getMessageType()).thenReturn("JSON");
Mockito.when(adapterGroupVO.getMessageEncode()).thenReturn("UTF-8");
Mockito.when(adapterVO.getPropGroupName()).thenReturn("TEST_PROP");
Mockito.when(adapterVO.getAdapterGroupVO()).thenReturn(adapterGroupVO);
Mockito.when(mockAdapterManager.getAdapterGroupVO("MY_GROUP")).thenReturn(adapterGroupVO);
Mockito.when(mockAdapterManager.getAdapterVO("MY_GROUP", "MY_ADAPTER")).thenReturn(adapterVO);
Properties httpProp = new Properties();
httpProp.setProperty("ERROR_RESPONSE_FORMAT", "");
Mockito.when(mockAdapterPropManager.getProperties("TEST_PROP")).thenReturn(httpProp);
ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager);
Mockito.when(mockCtx.getBean(AdapterManager.class)).thenReturn(mockAdapterManager);
Mockito.when(mockCtx.getBean(AdapterPropManager.class)).thenReturn(mockAdapterPropManager);
Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
ctxField.setAccessible(true);
ctxField.set(null, mockCtx);
}
private void prop(String key, String value) {
Mockito.when(mockPropManager.getProperty(eq(G), eq(key))).thenReturn(value);
}
// ------------------------------------------------------------
// generateNonStandardErrorResponseMessage : default.template
// ------------------------------------------------------------
@Nested
@DisplayName("generateNonStandardErrorResponseMessage default.template")
class NonStandardError {
@Test
@DisplayName("그룹 템플릿이 있으면 default.template 을 조회하지 않는다")
void 그룹_템플릿_우선() throws Exception {
prop("MY_GROUP.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}");
Object result = handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E001", "오류", "", null)));
assertEquals("{\"code\":\"E001\"}", result);
Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.template");
}
@Test
@DisplayName("그룹 템플릿 미설정 시 default.template 으로 폴백")
void default_폴백() throws Exception {
prop("default.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"src\":\"default\"}");
Object result = handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E777", "오류", "", null)));
assertEquals("{\"code\":\"E777\",\"src\":\"default\"}", result);
}
@Test
@DisplayName("그룹 템플릿이 공백 문자열이어도 default.template 으로 폴백")
void 공백_템플릿도_폴백() throws Exception {
prop("MY_GROUP.template", " ");
prop("default.template", "{\"src\":\"default\"}");
Object result = handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E001", "오류", "", null)));
assertEquals("{\"src\":\"default\"}", result);
}
@Test
@DisplayName("그룹/default 둘 다 없으면 기존대로 null 반환")
void 둘다_없으면_null() throws Exception {
Object result = handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E001", "오류", "", null)));
assertNull(result);
// 폴백 조회가 실제로 시도되었는지 확인
Mockito.verify(mockPropManager).getProperty(G, "MY_GROUP.template");
Mockito.verify(mockPropManager).getProperty(G, "default.template");
}
@Test
@DisplayName("조회 순서: {group}.template → default.template")
void 조회_순서() throws Exception {
handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E001", "오류", "", null)));
InOrder inOrder = Mockito.inOrder(mockPropManager);
inOrder.verify(mockPropManager).getProperty(G, "MY_GROUP.template");
inOrder.verify(mockPropManager).getProperty(G, "default.template");
}
@Test
@DisplayName("default.template 에서도 callProp/foreach 치환이 동일하게 동작")
void default_템플릿_전체_치환() throws Exception {
prop("default.template",
"{\"adapter\":\"${callprop.ADAPTER_NAME}\"," +
"\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"," +
"\"errors\":[{{#foreach MSG.MSG_LIST}}{\"c\":\"${outp_msg_cd}\"}{{/foreach}}]}");
Object result = handler.generateNonStandardErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", props("ADAPTER_NAME", "REAL"), null,
toEaiMessage(buildMsg("E001", "대표오류", "",
new String[][]{{"E001", "오류A", "", ""}, {"E002", "오류B", "", ""}})));
assertEquals(
"{\"adapter\":\"REAL\",\"code\":\"E001\"," +
"\"errors\":[{\"c\":\"E001\"},{\"c\":\"E002\"}]}",
result);
}
}
// ------------------------------------------------------------
// generateNonStandardInternalErrorResponseMessage : default.sys.template
// ------------------------------------------------------------
@Nested
@DisplayName("generateNonStandardInternalErrorResponseMessage default.sys.template")
class InternalError {
@Test
@DisplayName("그룹 sys 템플릿이 있으면 default.sys.template 을 조회하지 않는다")
void 그룹_템플릿_우선() throws Exception {
prop("MY_GROUP.sys.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}");
Object result = handler.generateNonStandardInternalErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E500", "내부오류", "", null)));
assertEquals("{\"code\":\"E500\"}", result);
Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.sys.template");
}
@Test
@DisplayName("그룹 sys 템플릿 미설정 시 default.sys.template 으로 폴백")
void default_폴백() throws Exception {
prop("default.sys.template",
"{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"msg\":\"${MSG.MAIN_MSG.outp_msg_ctnt}\"}");
Object result = handler.generateNonStandardInternalErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E500", "시스템오류", "", null)));
assertEquals("{\"code\":\"E500\",\"msg\":\"시스템오류\"}", result);
}
@Test
@DisplayName("내부오류는 default.template 이 아니라 default.sys.template 을 조회한다")
void sys_전용_키_사용() throws Exception {
// default.template 설정된 상태 내부오류에는 적용되지 않아야 한다
prop("default.template", "{\"src\":\"non-sys\"}");
Object result = handler.generateNonStandardInternalErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E500", "시스템오류", "", null)));
assertNull(result);
Mockito.verify(mockPropManager).getProperty(G, "default.sys.template");
Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.template");
}
@Test
@DisplayName("그룹/default 둘 다 없으면 기존대로 null 반환")
void 둘다_없으면_null() throws Exception {
Object result = handler.generateNonStandardInternalErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E500", "오류", "", null)));
assertNull(result);
}
@Test
@DisplayName("조회 순서: {group}.sys.template → default.sys.template")
void 조회_순서() throws Exception {
handler.generateNonStandardInternalErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null,
toEaiMessage(buildMsg("E500", "오류", "", null)));
InOrder inOrder = Mockito.inOrder(mockPropManager);
inOrder.verify(mockPropManager).getProperty(G, "MY_GROUP.sys.template");
inOrder.verify(mockPropManager).getProperty(G, "default.sys.template");
}
}
// ------------------------------------------------------------
// generateOutboundErrorResponseMessage : default.template
// ------------------------------------------------------------
@Nested
@DisplayName("generateOutboundErrorResponseMessage default.template")
class OutboundError {
@Test
@DisplayName("그룹 템플릿이 있으면 default.template 을 조회하지 않는다")
void 그룹_템플릿_우선() throws Exception {
prop("OUT_GROUP.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}");
Object result = handler.generateOutboundErrorResponseMessage(
"OUT_GROUP", "OUT_ADAPTER", null, null,
toEaiMessage(buildMsg("E001", "오류", "", null)));
assertEquals("{\"code\":\"E001\"}", result);
Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.template");
}
@Test
@DisplayName("그룹 템플릿 미설정 시 default.template 으로 폴백")
void default_폴백() throws Exception {
prop("default.template", "{\"code\":\"${MSG.MAIN_MSG.outp_msg_cd}\",\"src\":\"default\"}");
Object result = handler.generateOutboundErrorResponseMessage(
"OUT_GROUP", "OUT_ADAPTER", null, null,
toEaiMessage(buildMsg("E888", "오류", "", null)));
assertEquals("{\"code\":\"E888\",\"src\":\"default\"}", result);
}
@Test
@DisplayName("아웃바운드/인바운드가 동일한 default.template 을 공유한다")
void inbound_outbound_default_공유() throws Exception {
prop("default.template", "{\"shared\":\"${MSG.MAIN_MSG.outp_msg_cd}\"}");
Object outbound = handler.generateOutboundErrorResponseMessage(
"OUT_GROUP", "OUT_ADAPTER", null, null,
toEaiMessage(buildMsg("E001", "오류", "", null)));
Object inbound = handler.generateNonStandardErrorResponseMessage(
"IN_GROUP", "IN_ADAPTER", null, null,
toEaiMessage(buildMsg("E001", "오류", "", null)));
// 경로 모두 같은 키를 쓰므로 결과가 동일하다.
// 방향별로 다른 기본 응답이 필요하면 그룹 전용 키를 설정해야 한다.
assertEquals("{\"shared\":\"E001\"}", outbound);
assertEquals(outbound, inbound);
}
@Test
@DisplayName("그룹/default 둘 다 없으면 기존대로 null 반환")
void 둘다_없으면_null() throws Exception {
Object result = handler.generateOutboundErrorResponseMessage(
"OUT_GROUP", "OUT_ADAPTER", null, null,
toEaiMessage(buildMsg("E001", "오류", "", null)));
assertNull(result);
}
}
// ------------------------------------------------------------
// generateNonStandardInboundErrorResponseMessage : default.in.*
// ------------------------------------------------------------
@Nested
@DisplayName("generateNonStandardInboundErrorResponseMessage default.in.*")
class InboundError {
/** 500 으로 매핑되는 일반 예외로 호출한다. */
private Object call500(Properties callProp) throws Exception {
return handler.generateNonStandardInboundErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", callProp, null, null,
new RuntimeException("처리 중 오류"));
}
@Test
@DisplayName("1순위: {group}.in.{code}.template 이 있으면 그것을 사용")
void 그룹_코드별_템플릿_우선() throws Exception {
prop("MY_GROUP.in.500.template", "{\"src\":\"group-code\"}");
prop("default.in.500.template", "{\"src\":\"default-code\"}");
prop("MY_GROUP.in.template", "{\"src\":\"group-common\"}");
prop("default.in.template", "{\"src\":\"default-common\"}");
assertEquals("{\"src\":\"group-code\"}", call500(null));
}
@Test
@DisplayName("2순위: {group}.in.{code} 가 없으면 default.in.{code}.template 사용")
void default_코드별_템플릿() throws Exception {
prop("default.in.500.template", "{\"src\":\"default-code\"}");
prop("MY_GROUP.in.template", "{\"src\":\"group-common\"}");
prop("default.in.template", "{\"src\":\"default-common\"}");
// 주의: 현재 구현은 코드별 default 그룹 공통보다 우선한다.
assertEquals("{\"src\":\"default-code\"}", call500(null));
}
@Test
@DisplayName("3순위: 코드별 키가 모두 없으면 {group}.in.template 사용")
void 그룹_공통_템플릿() throws Exception {
prop("MY_GROUP.in.template", "{\"src\":\"group-common\"}");
prop("default.in.template", "{\"src\":\"default-common\"}");
assertEquals("{\"src\":\"group-common\"}", call500(null));
}
@Test
@DisplayName("4순위: 앞의 3개가 모두 없으면 default.in.template 사용")
void default_공통_템플릿() throws Exception {
prop("default.in.template", "{\"src\":\"default-common\"}");
assertEquals("{\"src\":\"default-common\"}", call500(null));
}
@Test
@DisplayName("4개 키가 모두 없으면 MessageUtil 폴백")
void 전부_없으면_MessageUtil_폴백() throws Exception {
Object result = call500(null);
assertNotNull(result);
Mockito.verify(mockPropManager).getProperty(G, "MY_GROUP.in.500.template");
Mockito.verify(mockPropManager).getProperty(G, "default.in.500.template");
Mockito.verify(mockPropManager).getProperty(G, "MY_GROUP.in.template");
Mockito.verify(mockPropManager).getProperty(G, "default.in.template");
}
@Test
@DisplayName("조회 순서: group.code → default.code → group.common → default.common")
void 조회_순서_4단계() throws Exception {
handler.generateNonStandardInboundErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null, null,
new HttpStatusException("Service Unavailable", 503));
InOrder inOrder = Mockito.inOrder(mockPropManager);
inOrder.verify(mockPropManager).getProperty(G, "MY_GROUP.in.503.template");
inOrder.verify(mockPropManager).getProperty(G, "default.in.503.template");
inOrder.verify(mockPropManager).getProperty(G, "MY_GROUP.in.template");
inOrder.verify(mockPropManager).getProperty(G, "default.in.template");
}
@Test
@DisplayName("코드별 default 키 형식은 'default.in.{code}.template' (점 누락 회귀 방지)")
void default_코드별_키_형식_검증() throws Exception {
handler.generateNonStandardInboundErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null, null,
new HttpStatusException("Bad Request", 400));
Mockito.verify(mockPropManager).getProperty(G, "default.in.400.template");
// 점이 빠진 잘못된 키로 조회하지 않아야 한다
Mockito.verify(mockPropManager, Mockito.never()).getProperty(G, "default.in400.template");
}
@Test
@DisplayName("JwtAuthException 은 default.in.401.template 로 폴백")
void jwt_401_default_폴백() throws Exception {
prop("default.in.401.template", "{\"code\":\"${exception.code}\",\"msg\":\"${exception.message}\"}");
Object result = handler.generateNonStandardInboundErrorResponseMessage(
"MY_GROUP", "MY_ADAPTER", null, null, null,
new JwtAuthException("JWT_EXPIRED", "토큰이 만료되었습니다"));
assertEquals("{\"code\":\"JWT_EXPIRED\",\"msg\":\"토큰이 만료되었습니다\"}", result);
}
@Test
@DisplayName("default.in.template 에서 exception/callProp 치환이 동일하게 동작")
void default_공통_템플릿_치환() throws Exception {
prop("default.in.template",
"{\"adapter\":\"${callprop.ADAPTER_NAME}\",\"msg\":\"${exception.message}\"}");
Object result = call500(props("ADAPTER_NAME", "REAL"));
assertEquals("{\"adapter\":\"REAL\",\"msg\":\"처리 중 오류\"}", result);
}
@Test
@DisplayName("default.in.template 에 ${callprop[경로]} 를 써도 msg=null 로 NPE 없이 기본값 반환")
void default_공통_템플릿_간접참조_NPE_회귀방지() throws Exception {
// 인바운드 경로는 render(template, null, ...) 호출되므로
// 간접 참조의 표준전문 경로 조회 대상이 없다 기본값으로 처리되어야 한다.
prop("default.in.template", "{\"key\":\"${callprop[MSG.cp_key]:NO_MSG}\"}");
Object result = call500(props("ADAPTER_NAME", "REAL"));
assertEquals("{\"key\":\"NO_MSG\"}", result);
}
@Test
@DisplayName("그룹 코드별 템플릿이 공백이면 default 로 폴백")
void 공백_템플릿도_폴백() throws Exception {
prop("MY_GROUP.in.500.template", " ");
prop("default.in.500.template", "{\"src\":\"default-code\"}");
assertEquals("{\"src\":\"default-code\"}", call500(null));
}
}
}
}
@@ -1,466 +0,0 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.File;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
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.adapter.http.dynamic.filter.InCryptoFilter;
import com.eactive.eai.adapter.http.filter.AbstractCryptoFilter;
import com.eactive.eai.common.hsm.HsmCryptoService;
import com.eactive.eai.common.hsm.HsmManager;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.security.CryptoModuleConfigVO;
import com.eactive.eai.common.security.CryptoModuleManager;
import com.eactive.eai.common.security.CryptoModuleService;
import com.eactive.eai.common.security.keyderiv.strategy.HsmContextSha256KeyDerivationStrategy;
import com.eactive.eai.common.security.keyderiv.strategy.HsmKeyDerivationStrategy;
import com.eactive.eai.common.security.loader.CryptoModuleConfigLoader;
import com.eactive.eai.common.security.mapper.CryptoModuleConfigMapper;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.data.entity.onl.security.CryptoModuleConfig;
/**
* CryptoFilter HSM 연동 통합 테스트 (SoftHSM2 / SunPKCS11)
*
* SoftHSM2에서 MASTER_KEY를 조회하여 키를 도출한
* InCryptoFilter 암복호화 전체 흐름을 검증한다.
*
* 사전 조건:
* C:\SoftHSM2 SoftHSM2 설치 (미설치 자동 건너뜀)
* MASTER_KEY alias는 없으면 자동 생성됨
*
* 검증 전략:
* HSM 도출 CryptoModuleExtension InCryptoFilter doPostFilter(암호화) doPreFilter(복호화) 원문 일치
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
public class CryptoFilterHsmIntegrationTest {
// -------------------------------------------------------------------------
// 상수
// -------------------------------------------------------------------------
private static final String SOFTHSM2_DLL = "C:/SoftHSM2/lib/softhsm2-x64.dll";
private static final String SOFTHSM2_UTIL = "C:/SoftHSM2/bin/softhsm2-util.exe";
private static final String TOKEN_LABEL = "eapim-test";
private static final String PIN = "1234";
private static final String MASTER_KEY_ALIAS = "MASTER_KEY";
/** HsmKeyDerivationStrategy: HSM 마스터키를 파생 없이 직접 사용 */
private static final String MOD_HSM_GCM = "HSM_GCM";
/** HsmContextSha256KeyDerivationStrategy: HSM 마스터키 + 컨텍스트값 SHA-256 파생 */
private static final String MOD_CTX_SHA_GCM = "CTX_SHA256_GCM";
private static final String CTX_KEY = "X-Api-Group-Seq";
private static final String CTX_VAL_A = "GROUP-A";
private static final String CTX_VAL_B = "GROUP-B";
// -------------------------------------------------------------------------
// 정적 필드
// -------------------------------------------------------------------------
private static GenericApplicationContext springCtx;
private static HsmManager hsmManager;
private static File tempCfgFile;
// -------------------------------------------------------------------------
// 내부 클래스: runtimeContext를 고정값으로 반환하는 필터
// -------------------------------------------------------------------------
static class ContextAwareCryptoFilter extends InCryptoFilter {
private final String key;
private final String value;
ContextAwareCryptoFilter(String key, String value) {
this.key = key;
this.value = value;
}
@Override
protected Map<String, String> buildRuntimeContext(Properties prop, HttpServletRequest request) {
Map<String, String> ctx = new HashMap<>();
ctx.put(key, value);
return ctx;
}
}
// -------------------------------------------------------------------------
// 인스턴스 필드
// -------------------------------------------------------------------------
private HttpServletRequest mockReq;
private HttpServletResponse mockRes;
// -------------------------------------------------------------------------
// 전체 설정 / 해제
// -------------------------------------------------------------------------
@BeforeAll
static void setUpAll() throws Exception {
assumeTrue(new File(SOFTHSM2_DLL).exists(),
"SoftHSM2 미설치 (C:/SoftHSM2) - 테스트 건너뜀");
System.setProperty("SOFTHSM2_CONF", "C:\\SoftHSM2\\etc\\softhsm2.conf");
// 1. 토큰 초기화
ensureTokenInitialized();
// 2. PKCS11 설정 문자열
// attributes(*,...): generate/import 모두 CKA_SENSITIVE=false getEncoded() 사용 가능
String cfgContent = "name = SoftHSM\n"
+ "library = " + SOFTHSM2_DLL + "\n"
+ "slotListIndex = 0\n"
+ "attributes(*, CKO_SECRET_KEY, CKK_AES) = {\n"
+ " CKA_SENSITIVE = false\n"
+ " CKA_EXTRACTABLE = true\n"
+ "}\n";
tempCfgFile = File.createTempFile("pkcs11-filter-it-", ".cfg");
Files.write(tempCfgFile.toPath(), cfgContent.getBytes("UTF-8"));
// 3. Mock PropManager (DB 없이 HSM 설정값 제공)
PropManager mockPropManager = mock(PropManager.class);
when(mockPropManager.getProperty("HSM", "PKCS11_CONFIG")).thenReturn(cfgContent);
when(mockPropManager.getProperty("HSM", "PIN")).thenReturn(PIN);
when(mockPropManager.getProperty(eq("HSM"), eq("CACHE_RELOAD_YN"), anyString())).thenReturn("N");
// 4. HsmManager (private 생성자 리플렉션)
Constructor<HsmManager> hsmCtor = HsmManager.class.getDeclaredConstructor();
hsmCtor.setAccessible(true);
hsmManager = hsmCtor.newInstance();
// 5. CryptoModuleManager (private 생성자 리플렉션)
CryptoModuleConfigLoader mockLoader = mock(CryptoModuleConfigLoader.class);
when(mockLoader.findAll()).thenReturn(buildModuleConfigs());
Constructor<CryptoModuleManager> managerCtor = CryptoModuleManager.class.getDeclaredConstructor();
managerCtor.setAccessible(true);
CryptoModuleManager manager = managerCtor.newInstance();
CryptoModuleService cryptoService = new CryptoModuleService();
HsmCryptoService hsmCryptoService = new HsmCryptoService();
// 6. Spring ApplicationContext 구성
springCtx = new GenericApplicationContext();
springCtx.getBeanFactory().registerSingleton("propManager", mockPropManager);
springCtx.getBeanFactory().registerSingleton("hsmManager", hsmManager);
springCtx.getBeanFactory().registerSingleton("hsmCryptoService", hsmCryptoService);
springCtx.getBeanFactory().registerSingleton("cryptoModuleConfigLoader", mockLoader);
springCtx.getBeanFactory().registerSingleton("cryptoModuleManager", manager);
springCtx.getBeanFactory().registerSingleton("cryptoModuleService", cryptoService);
springCtx.getBeanFactory().registerSingleton("cryptoModuleConfigMapper", testMapper());
springCtx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
springCtx.refresh();
// 7. HsmManager 시작 (SunPKCS11 Provider 초기화 + KeyStore 오픈)
hsmManager.start();
assertTrue(hsmManager.isReady(), "HsmManager 초기화 실패");
System.out.println("[HSM] Provider: " + hsmManager.getPkcs11Provider().getName());
// 8. MASTER_KEY 등록 (없으면 자동 생성)
ensureMasterKey();
// 9. CryptoModuleManager 시작 (모듈 설정 로드)
manager.start();
System.out.println("[Crypto] 모듈 로드 완료");
}
@AfterAll
static void tearDownAll() throws Exception {
if (hsmManager != null && hsmManager.isStarted()) hsmManager.stop();
if (springCtx != null) springCtx.close();
if (tempCfgFile != null) tempCfgFile.delete();
}
@BeforeEach
void setUp() {
mockReq = mock(HttpServletRequest.class);
mockRes = mock(HttpServletResponse.class);
}
// =========================================================================
// 1. HsmKeyDerivationStrategy HSM 마스터키 직접 사용 (InCryptoFilter)
// =========================================================================
@Test
@DisplayName("1-1. FIELD/GCM HsmKey — doPostFilter 암호화 → doPreFilter 복호화 라운드트립")
void testHsmGcm_field_roundTrip() throws Exception {
InCryptoFilter filter = new InCryptoFilter();
String original = "{\"acno\":\"1234567890\",\"amount\":\"100000\"}";
Properties prop = new Properties();
prop.setProperty(AbstractCryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM);
// SCOPE 기본값 FIELD, 경로 기본값 사용
// 암호화: body 전체 /encrypted_data 필드
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
System.out.println("[1-1] encrypted: " + encrypted);
assertTrue(encrypted.contains("encrypted_data"), "/encrypted_data 필드가 있어야 한다");
// 복호화: /encrypted_data 필드 body 전체 교체
String decrypted = (String) filter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes);
System.out.println("[1-1] decrypted: " + decrypted);
assertEquals(original, decrypted);
}
@Test
@DisplayName("1-2. BODY/GCM HsmKey — doPostFilter 암호화 → doPreFilter 복호화 라운드트립")
void testHsmGcm_body_roundTrip() throws Exception {
InCryptoFilter filter = new InCryptoFilter();
String original = "{\"acno\":\"1234567890\",\"amount\":\"100000\"}";
Properties prop = new Properties();
prop.setProperty(AbstractCryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM);
prop.setProperty(AbstractCryptoFilter.PROP_SCOPE, "BODY");
// 암호화: body 전체 Base64 암호문
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
System.out.println("[1-2] encrypted(Base64): " + encrypted);
assertNotEquals(original, encrypted, "암호문은 평문과 달라야 한다");
// 복호화: Base64 암호문 원문
String decrypted = (String) filter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes);
System.out.println("[1-2] decrypted: " + decrypted);
assertEquals(original, decrypted);
}
@Test
@DisplayName("1-3. FIELD/GCM HsmKey + AAD 헤더 — 동일 요청 ID로 암복호화 성공")
void testHsmGcm_field_withAad_success() throws Exception {
InCryptoFilter filter = new InCryptoFilter();
String original = "{\"amount\":\"500000\"}";
String requestId = "REQ-20240101-001";
when(mockReq.getHeader("X-Request-Id")).thenReturn(requestId);
Properties prop = new Properties();
prop.setProperty(AbstractCryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM);
prop.setProperty(AbstractCryptoFilter.PROP_AAD_HEADER, "X-Request-Id");
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
String decrypted = (String) filter.doPreFilter( "G", "A", encrypted, prop, mockReq, mockRes);
System.out.println("[1-3] decrypted: " + decrypted);
assertEquals(original, decrypted);
}
@Test
@DisplayName("1-4. FIELD/GCM HsmKey + AAD 헤더 — 다른 요청 ID로 복호화 시 GCM 인증 실패")
void testHsmGcm_field_withAad_wrongAad_fails() throws Exception {
InCryptoFilter filter = new InCryptoFilter();
String original = "{\"amount\":\"500000\"}";
// 암호화 요청 ID
HttpServletRequest encReq = mock(HttpServletRequest.class);
when(encReq.getHeader("X-Request-Id")).thenReturn("REQ-CORRECT");
// 복호화 다른 요청 ID
HttpServletRequest decReq = mock(HttpServletRequest.class);
when(decReq.getHeader("X-Request-Id")).thenReturn("REQ-WRONG");
Properties prop = new Properties();
prop.setProperty(AbstractCryptoFilter.PROP_MODULE_NAME, MOD_HSM_GCM);
prop.setProperty(AbstractCryptoFilter.PROP_AAD_HEADER, "X-Request-Id");
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, encReq, mockRes);
assertThrows(Exception.class,
() -> filter.doPreFilter("G", "A", encrypted, prop, decReq, mockRes),
"잘못된 AAD로 GCM 복호화 시 예외가 발생해야 한다");
System.out.println("[1-4] 다른 AAD 복호화 예외 확인 완료");
}
// =========================================================================
// 2. HsmContextSha256KeyDerivationStrategy 컨텍스트 기반 SHA-256 파생
// =========================================================================
@Test
@DisplayName("2-1. FIELD/GCM ContextSha256 — 동일 컨텍스트로 암복호화 라운드트립")
void testCtxSha256Gcm_field_roundTrip() throws Exception {
InCryptoFilter filter = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_A);
String original = "{\"accNo\":\"0011234567890\",\"amount\":\"50000\"}";
Properties prop = new Properties();
prop.setProperty(AbstractCryptoFilter.PROP_MODULE_NAME, MOD_CTX_SHA_GCM);
String encrypted = (String) filter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
System.out.println("[2-1] encrypted: " + encrypted);
assertTrue(encrypted.contains("encrypted_data"));
String decrypted = (String) filter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes);
System.out.println("[2-1] decrypted: " + decrypted);
assertEquals(original, decrypted);
}
@Test
@DisplayName("2-2. FIELD/GCM ContextSha256 — 암호화와 다른 컨텍스트로 복호화 시 GCM 인증 실패")
void testCtxSha256Gcm_differentContext_throwsException() throws Exception {
InCryptoFilter encFilter = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_A);
InCryptoFilter decFilter = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_B);
String original = "{\"data\":\"sensitive\"}";
Properties prop = new Properties();
prop.setProperty(AbstractCryptoFilter.PROP_MODULE_NAME, MOD_CTX_SHA_GCM);
String encrypted = (String) encFilter.doPostFilter("G", "A", original, prop, mockReq, mockRes);
assertThrows(Exception.class,
() -> decFilter.doPreFilter("G", "A", encrypted, prop, mockReq, mockRes),
"다른 컨텍스트 키로 파생된 키는 복호화에 실패해야 한다");
System.out.println("[2-2] 다른 컨텍스트 복호화 예외 확인 완료");
}
@Test
@DisplayName("2-3. FIELD/GCM ContextSha256 — 컨텍스트별 독립 암복호화 (GROUP-A, GROUP-B 각각 성공)")
void testCtxSha256Gcm_perGroupRoundTrip() throws Exception {
InCryptoFilter filterA = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_A);
InCryptoFilter filterB = new ContextAwareCryptoFilter(CTX_KEY, CTX_VAL_B);
String plainA = "{\"group\":\"A\",\"amount\":\"1000\"}";
String plainB = "{\"group\":\"B\",\"amount\":\"2000\"}";
Properties prop = new Properties();
prop.setProperty(AbstractCryptoFilter.PROP_MODULE_NAME, MOD_CTX_SHA_GCM);
String encA = (String) filterA.doPostFilter("G", "A", plainA, prop, mockReq, mockRes);
String encB = (String) filterB.doPostFilter("G", "B", plainB, prop, mockReq, mockRes);
assertEquals(plainA, (String) filterA.doPreFilter("G", "A", encA, prop, mockReq, mockRes));
assertEquals(plainB, (String) filterB.doPreFilter("G", "B", encB, prop, mockReq, mockRes));
System.out.println("[2-3] GROUP-A, GROUP-B 각각 독립 암복호화 성공");
}
// =========================================================================
// 헬퍼
// =========================================================================
private static List<CryptoModuleConfig> buildModuleConfigs() {
return Arrays.asList(
buildDynamic(MOD_HSM_GCM, "AES", "GCM", "NoPadding",
HsmKeyDerivationStrategy.class.getName(),
"{\"hsmKeyAlias\":\"" + MASTER_KEY_ALIAS + "\"}"),
buildDynamic(MOD_CTX_SHA_GCM, "AES", "GCM", "NoPadding",
HsmContextSha256KeyDerivationStrategy.class.getName(),
"{\"hsmKeyAlias\":\"" + MASTER_KEY_ALIAS + "\",\"contextKey\":\"" + CTX_KEY + "\"}")
);
}
private static CryptoModuleConfig buildDynamic(String name, String alg, String mode, String padding,
String strategy, String params) {
CryptoModuleConfig c = new CryptoModuleConfig();
c.setCryptoId(UUID.randomUUID().toString());
c.setCryptoName(name);
c.setAlgType(alg);
c.setCipherMode(mode);
c.setPadding(padding);
c.setKeySourceType("DYNAMIC");
c.setKeyDerivStrategy(strategy);
c.setKeyDerivParams(params);
c.setCacheYn("Y");
c.setCacheTtlSec(60);
c.setUseYn("Y");
// ivHex = null: GCM에서 IV를 AAD 대체값으로 사용하지 않도록 의도적으로 미설정
return c;
}
private static void ensureMasterKey() throws Exception {
java.security.KeyStore ks = hsmManager.getKeyStore();
// 이전 실행 삭제 (sensitive 여부 무관하게 항상 고정키로 재등록)
if (ks.containsAlias(MASTER_KEY_ALIAS)) {
ks.deleteEntry(MASTER_KEY_ALIAS);
System.out.println("[HSM] 기존 MASTER_KEY 삭제");
}
// CryptoModuleServiceTest.KEY_128 동일한 고정 테스트 암호문 비교 가능
// attributes(*, CKO_SECRET_KEY, CKK_AES) 지시어로 임포트 CKA_SENSITIVE=false 적용
byte[] keyBytes = "0123456789abcdef".getBytes(StandardCharsets.UTF_8);
SecretKey fixedKey = new SecretKeySpec(keyBytes, "AES");
ks.setKeyEntry(MASTER_KEY_ALIAS, fixedKey, null, null);
// 임포트 getEncoded() 검증
SecretKey stored = (SecretKey) ks.getKey(MASTER_KEY_ALIAS, null);
byte[] encoded = stored.getEncoded();
System.out.println("[HSM] MASTER_KEY 등록 완료: alias=" + MASTER_KEY_ALIAS
+ " / getEncoded()=" + (encoded != null ? encoded.length + "bytes, " + new String(encoded) : "null (추출 불가!)"));
}
private static void ensureTokenInitialized() throws Exception {
ProcessBuilder pb = new ProcessBuilder(
SOFTHSM2_UTIL, "--init-token", "--free",
"--label", TOKEN_LABEL, "--pin", PIN, "--so-pin", PIN);
pb.redirectErrorStream(true);
Process p = pb.start();
String out = readOutput(p);
p.waitFor(10, TimeUnit.SECONDS);
System.out.println("[SoftHSM2] init-token: " + out.trim());
}
private static CryptoModuleConfigMapper testMapper() {
return new CryptoModuleConfigMapper() {
@Override
public CryptoModuleConfigVO toVo(CryptoModuleConfig e) {
CryptoModuleConfigVO vo = new CryptoModuleConfigVO();
vo.setCryptoId(e.getCryptoId());
vo.setCryptoName(e.getCryptoName());
vo.setCryptoDesc(e.getCryptoDesc());
vo.setAlgType(e.getAlgType());
vo.setCipherMode(e.getCipherMode());
vo.setPadding(e.getPadding());
vo.setIvHex(e.getIvHex());
vo.setKeySourceType(e.getKeySourceType());
vo.setEncKeyHex(e.getEncKeyHex());
vo.setDecKeyHex(e.getDecKeyHex());
vo.setKeyDerivStrategy(e.getKeyDerivStrategy());
vo.setKeyDerivParams(e.getKeyDerivParams());
vo.setCacheYn(e.getCacheYn());
vo.setCacheTtlSec(e.getCacheTtlSec());
vo.setUseYn(e.getUseYn());
return vo;
}
@Override
public CryptoModuleConfig toEntity(CryptoModuleConfigVO vo) {
return null;
}
};
}
private static String readOutput(Process p) throws Exception {
InputStream is = p.getInputStream();
byte[] buf = new byte[4096];
StringBuilder sb = new StringBuilder();
int len;
while ((len = is.read(buf)) != -1) {
sb.append(new String(buf, 0, len, "UTF-8"));
}
return sb.toString();
}
}
@@ -0,0 +1,319 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.charset.StandardCharsets;
import java.util.Properties;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
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.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* JsonToSetStatusFilter 단위 테스트.
*
* 상태코드 필드명은 PropManager(그룹: JsonToSetStatusFilter, : 어댑터그룹명)에서 조회하므로
* PropManager Mock 으로 등록하고 필터 로직만 검증한다.
*
* 4번 그룹은 "현재 구현의 동작을 그대로 고정(characterization)" 테스트로,
* 개선 여부 판단용이다. 구현을 보완하면 해당 테스트도 함께 수정해야 한다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class JsonToSetStatusFilterTest {
private static final String GRP = "TEST_GRP";
private static final String ADPT = "TEST_ADPT";
private static final String FIELD = "apiRsltCd";
private static final String PROP_GROUP = "JsonToSetStatusFilter";
private static GenericApplicationContext ctx;
private static PropManager mockPropManager;
private static JsonToSetStatusFilter filter;
private HttpServletRequest mockRequest;
private HttpServletResponse mockResponse;
private Properties prop;
@BeforeAll
static void setUpClass() {
mockPropManager = mock(PropManager.class);
ctx = new GenericApplicationContext();
ctx.getBeanFactory().registerSingleton("propManager", mockPropManager);
ctx.registerBeanDefinition("applicationContextProvider",
BeanDefinitionBuilder.genericBeanDefinition(ApplicationContextProvider.class)
.getBeanDefinition());
ctx.refresh();
filter = new JsonToSetStatusFilter();
}
@AfterAll
static void tearDownClass() {
if (ctx != null) ctx.close();
}
@BeforeEach
void setUp() {
reset(mockPropManager);
mockRequest = mock(HttpServletRequest.class);
mockResponse = mock(HttpServletResponse.class);
prop = new Properties();
when(mockPropManager.getProperty(PROP_GROUP, GRP)).thenReturn(FIELD);
}
private String body(String rsltCd) {
return "{\"" + FIELD + "\":\"" + rsltCd + "\",\"msg\":\"OK\"}";
}
// =========================================================================
// 1. 상태코드 정상 반영
// =========================================================================
@Test
@DisplayName("1-1. 문자열 body의 상태코드 필드값을 HTTP 상태코드로 설정한다")
void testPostFilter_string_setsStatus() throws Exception {
Object result = filter.doPostFilter(GRP, ADPT, body("404"), prop, mockRequest, mockResponse);
verify(mockResponse).setStatus(404);
assertEquals(body("404"), result, "원 메시지를 그대로 반환해야 한다");
}
@Test
@DisplayName("1-2. 숫자 타입 필드값도 상태코드로 설정한다")
void testPostFilter_numericNode_setsStatus() throws Exception {
filter.doPostFilter(GRP, ADPT, "{\"" + FIELD + "\":503}", prop, mockRequest, mockResponse);
verify(mockResponse).setStatus(503);
}
@Test
@DisplayName("1-3. 업무팀 주 사용 케이스 - 2xx 상태코드를 설정한다")
void testPostFilter_successStatusCodes() throws Exception {
for (int status : new int[] { 200, 201, 202, 204 }) {
reset(mockResponse);
filter.doPostFilter(GRP, ADPT, body(String.valueOf(status)), prop, mockRequest, mockResponse);
verify(mockResponse).setStatus(status);
}
}
@Test
@DisplayName("1-4. 어댑터 그룹마다 다른 필드명을 사용할 수 있다")
void testPostFilter_perGroupFieldName() throws Exception {
when(mockPropManager.getProperty(PROP_GROUP, GRP)).thenReturn("rspCd");
filter.doPostFilter(GRP, ADPT, "{\"rspCd\":\"403\",\"" + FIELD + "\":\"500\"}", prop,
mockRequest, mockResponse);
verify(mockResponse).setStatus(403);
verify(mockResponse, never()).setStatus(500);
}
@Test
@DisplayName("1-5. JSONObject 타입 응답도 처리한다")
void testPostFilter_jsonObjectMessage_setsStatus() throws Exception {
JSONObject json = new JSONObject();
json.put(FIELD, "404");
Object result = filter.doPostFilter(GRP, ADPT, json, prop, mockRequest, mockResponse);
verify(mockResponse).setStatus(404);
assertSame(json, result);
}
@Test
@DisplayName("1-6. JsonNode 타입 응답도 처리한다")
void testPostFilter_jsonNodeMessage_setsStatus() throws Exception {
JsonNode node = new ObjectMapper().readTree(body("401"));
Object result = filter.doPostFilter(GRP, ADPT, node, prop, mockRequest, mockResponse);
verify(mockResponse).setStatus(401);
assertSame(node, result);
}
@Test
@DisplayName("1-7. 경계값 100 / 599는 설정한다")
void testPostFilter_boundaryValues() throws Exception {
filter.doPostFilter(GRP, ADPT, body("100"), prop, mockRequest, mockResponse);
verify(mockResponse).setStatus(100);
reset(mockResponse);
filter.doPostFilter(GRP, ADPT, body("599"), prop, mockRequest, mockResponse);
verify(mockResponse).setStatus(599);
}
// =========================================================================
// 2. 상태코드를 변경하지 않는 경우
// =========================================================================
@Test
@DisplayName("2-1. 상태코드 필드가 없으면 setStatus를 호출하지 않는다")
void testPostFilter_fieldAbsent_noStatusChange() throws Exception {
filter.doPostFilter(GRP, ADPT, "{\"msg\":\"OK\"}", prop, mockRequest, mockResponse);
verify(mockResponse, never()).setStatus(anyInt());
}
@Test
@DisplayName("2-2. 상태코드 필드값이 빈 문자열이면 setStatus를 호출하지 않는다")
void testPostFilter_emptyValue_noStatusChange() throws Exception {
filter.doPostFilter(GRP, ADPT, body(""), prop, mockRequest, mockResponse);
verify(mockResponse, never()).setStatus(anyInt());
}
@Test
@DisplayName("2-3. 프로퍼티에 필드명 설정이 없으면(null) 예외 없이 통과한다")
void testPostFilter_noConfiguredField_noStatusChange() throws Exception {
when(mockPropManager.getProperty(PROP_GROUP, GRP)).thenReturn(null);
Object result = filter.doPostFilter(GRP, ADPT, body("404"), prop, mockRequest, mockResponse);
verify(mockResponse, never()).setStatus(anyInt());
assertEquals(body("404"), result);
}
@Test
@DisplayName("2-4. 상태코드 필드값이 숫자가 아니면 로그만 남기고 상태코드를 변경하지 않는다")
void testPostFilter_nonNumericValue_noStatusChange() throws Exception {
Object result = filter.doPostFilter(GRP, ADPT, body("E0001"), prop, mockRequest, mockResponse);
verify(mockResponse, never()).setStatus(anyInt());
assertEquals(body("E0001"), result, "파싱 실패해도 원 메시지는 그대로 반환되어야 한다");
}
@Test
@DisplayName("2-5. 업무결과코드 0000은 유효한 상태코드가 아니므로 설정하지 않는다")
void testPostFilter_businessCode0000_noStatusChange() throws Exception {
filter.doPostFilter(GRP, ADPT, body("0000"), prop, mockRequest, mockResponse);
verify(mockResponse, never()).setStatus(anyInt());
}
@Test
@DisplayName("2-6. HTTP 상태코드 범위(100~599)를 벗어난 값은 설정하지 않는다")
void testPostFilter_outOfRangeStatus_noStatusChange() throws Exception {
for (String value : new String[] { "0", "99", "600", "9999", "-200" }) {
reset(mockResponse);
filter.doPostFilter(GRP, ADPT, body(value), prop, mockRequest, mockResponse);
verify(mockResponse, never()).setStatus(anyInt());
}
}
@Test
@DisplayName("2-7. JSON 배열 응답이면 상태코드를 변경하지 않는다")
void testPostFilter_jsonArray_noStatusChange() throws Exception {
filter.doPostFilter(GRP, ADPT, "[{\"" + FIELD + "\":\"404\"}]", prop, mockRequest, mockResponse);
verify(mockResponse, never()).setStatus(anyInt());
}
@Test
@DisplayName("2-8. 필드값이 JSON null이면 상태코드를 변경하지 않는다")
void testPostFilter_jsonNullValue_noStatusChange() throws Exception {
filter.doPostFilter(GRP, ADPT, "{\"" + FIELD + "\":null}", prop, mockRequest, mockResponse);
verify(mockResponse, never()).setStatus(anyInt());
}
// =========================================================================
// 3. 비정상 입력에서도 예외를 던지지 않는다 (거래 실패 방지)
// =========================================================================
@Test
@DisplayName("3-1. JSON 형식이 아닌 응답이어도 예외 없이 통과한다")
void testPostFilter_nonJsonBody_noException() throws Exception {
String xml = "<xml><rslt>0000</rslt></xml>";
Object result = assertDoesNotThrow(
() -> filter.doPostFilter(GRP, ADPT, xml, prop, mockRequest, mockResponse));
assertSame(xml, result);
verify(mockResponse, never()).setStatus(anyInt());
}
@Test
@DisplayName("3-2. 깨진 JSON 응답이어도 예외 없이 통과한다")
void testPostFilter_malformedJson_noException() {
String broken = "{\"" + FIELD + "\":\"404\"";
assertDoesNotThrow(() -> filter.doPostFilter(GRP, ADPT, broken, prop, mockRequest, mockResponse));
verify(mockResponse, never()).setStatus(anyInt());
}
@Test
@DisplayName("3-3. null 응답이어도 예외 없이 null을 그대로 반환한다")
void testPostFilter_nullMessage_noException() {
Object result = assertDoesNotThrow(
() -> filter.doPostFilter(GRP, ADPT, null, prop, mockRequest, mockResponse));
assertNull(result);
verify(mockResponse, never()).setStatus(anyInt());
}
@Test
@DisplayName("3-4. 빈 문자열 응답이어도 예외 없이 통과한다")
void testPostFilter_emptyBody_noException() {
assertDoesNotThrow(() -> filter.doPostFilter(GRP, ADPT, "", prop, mockRequest, mockResponse));
verify(mockResponse, never()).setStatus(anyInt());
}
@Test
@DisplayName("3-5. 프로퍼티 그룹 미등록(PropManager가 null 반환)이어도 예외 없이 통과한다")
void testPostFilter_propGroupMissing_noException() {
when(mockPropManager.getProperty(anyString(), anyString())).thenReturn(null);
assertDoesNotThrow(() -> filter.doPostFilter(GRP, ADPT, body("404"), prop, mockRequest, mockResponse));
verify(mockResponse, never()).setStatus(anyInt());
}
// =========================================================================
// 4. doPreFilter / 현재 동작 고정 (개선 검토 대상)
// =========================================================================
@Test
@DisplayName("4-1. doPreFilter는 아무 것도 하지 않고 요청 메시지를 그대로 반환한다")
void testPreFilter_doesNothing() throws Exception {
String message = body("404");
Object result = filter.doPreFilter(GRP, ADPT, message, prop, mockRequest, mockResponse);
assertSame(message, result);
verify(mockResponse, never()).setStatus(anyInt());
verifyNoInteractions(mockPropManager);
}
@Test
@DisplayName("4-2. [확인필요] byte[] 응답은 JSON으로 파싱되지 않아 상태코드가 설정되지 않는다")
void testPostFilter_byteArray_notSupported() throws Exception {
byte[] msg = body("404").getBytes(StandardCharsets.UTF_8);
Object result = filter.doPostFilter(GRP, ADPT, msg, prop, mockRequest, mockResponse);
// JsonPathUtil.toTree byte[] toString() 처리하므로 "[B@..." 되어 파싱에 실패한다.
verify(mockResponse, never()).setStatus(anyInt());
assertSame(msg, result);
}
}

Some files were not shown because too many files have changed in this diff Show More