Compare commits

..

51 Commits

Author SHA1 Message Date
curry772 1c48dd834a 토큰 현황 목록 조회에 최근 발급 이력 1건 포함
- 목록에서 issueHistory 가 null 로만 나와 "값이 없는 것"과 "안 실린 것"이 구분되지 않던 문제
- 목록은 최근 1건, 단건 조회는 보관 중인 전체(최대 10건)를 담는다
- 이력이 없으면 null 대신 빈 목록을 반환하고, serverName 도 함께 담는다
- 단위테스트 2건 추가
2026-09-11 17:35:36 +09:00
curry772 11b439aaf1 클러스터에서 토큰이 중복 발급되던 문제 수정
- near 캐시 사본 때문에 다른 노드가 방금 교체한 토큰을 못 보고 같은 주기에 두 번 발급되던 문제
  (분산락을 잡은 뒤의 조회에서도 옛 값을 읽어 락만으로는 막지 못했음)
- 캐시 조회를 짧은 비관적 트랜잭션으로 감싸 primary 값을 읽도록 함
  발급 HTTP 호출은 트랜잭션 밖에 두어 장기 트랜잭션이 파티션 맵 교환을 막지 않게 함
- 재발급 판정에 validUntilTime 추가 : 요구 시각까지 유효한 토큰이 있으면 발급하지 않음
  거래 중 재발급은 0 을 넘겨 만료 기준 검사를 건너뛴다 (만료 전이어도 기관이 거부한 상황)
- 2노드 시험에서 주기당 발급 1회, 만료 창 0건 확인
- 단위테스트 3건 추가
2026-09-11 15:05:18 +09:00
curry772 e46678ed76 토큰 만료 전 선제 갱신
- getOutboundAccessToken 은 토큰이 없거나 이미 만료됐을 때만 발급 함수를 호출해서,
  그 안에 있던 "다음 스케줄 전에 만료되면 재발급" 판정에 도달하지 못했음
  (토큰이 항상 만료된 뒤에야 갱신되어 최대 intervalSec 동안 만료 토큰을 사용)
- 스케줄러가 peekOutboundAccessToken 으로 먼저 확인하고, 다음 틱 전에 만료되면
  reissueOutboundAccessToken 으로 미리 갱신하도록 판정 위치를 옮김
- 여러 노드가 동시에 들어와도 분산락 + oldToken 비교로 발급은 한 번만 일어남
- 단위테스트 5건 추가
2026-09-11 14:08:18 +09:00
curry772 b92518eb5a 거래 중 토큰 재발급을 분산락 경로로 통합
- 재발급 결과를 캐시에 반영하지 않아 거래마다 발급되고, synchronized 가 JVM 단위라
  클러스터에서 노드 수만큼 중복 발급되던 문제
- SessionManager.reissueOutboundAccessToken 추가 : 분산락 안에서 oldToken 을 비교해
  이미 갱신됐으면 그 토큰을 쓰고, 아니면 한 번만 발급 후 캐시에 반영
- 발급 구현체는 호출자가 넘긴 어댑터 속성 기준으로 선택 (기존 의미 유지)
- HttpClient5AdapterServiceRest : 응답 기반 재발급도 DB 기반 매니저를 쓰도록 정리
- 아웃바운드 토큰 분산락 키를 전역 상수에서 어댑터그룹별로 변경
- 단위테스트 12건 추가 (경합 상황 포함)
2026-09-11 14:08:04 +09:00
curry772 324d8f8f8a OAuth 토큰 발급 이력 조회 기능 추가
- 어댑터그룹별 최근 10건을 노드 메모리에 보관. 성공뿐 아니라 실패도 남긴다
  (발급 실패는 캐시에 아무것도 남지 않아 사후 추적이 어려웠음)
- 기록 항목 : 시각, SCHEDULE/RETRY, 구현체, 성공여부, 소요시간, 만료시각, 실패사유
- accessToken 은 기록 시점에 앞 8자만 남겨 보관
- GET /manage/oauth-token/status/{어댑터그룹명} 에만 포함. 목록 조회에는 미포함
- 이력이 노드 로컬이므로 응답에 serverName 을 함께 담는다
- 단위테스트 6건 추가
2026-09-11 14:07:50 +09:00
curry772 6d3cbe1d8b 거래 중 토큰 재발급 시 새 토큰을 헤더에 반영
- 재발급받은 newaccessToken 을 쓰지 않고 방금 실패한 토큰을 그대로 헤더에 넣어
  재시도가 같은 사유로 실패하던 문제
- newaccessToken 이 null 인 경우(구현체 미확인)에는 기존 동작 유지
- Bypass 계열과 HttpClientAdapterServiceRest 는 이미 새 토큰을 사용 중
2026-09-11 12:39:24 +09:00
curry772 822ca52ef0 OAuth 토큰 현황 조회 API 진단 정보 추가
- 토큰이 발급되지 않는 사유를 message 에 모아서 표시
  (useYn=N, 어댑터그룹 없음, 이 서버에 배정된 어댑터 없음, 캐시 없음 등)
- tokenUrlResolved 추가 : 상대 경로 토큰 URL 을 어댑터 URL 과 조합한 실제 호출 주소
- adapterTokenServiceClasses 는 어댑터그룹을 찾지 못하면 null, 어댑터가 없으면 빈 값으로 구분
- 단위테스트 4건 추가
2026-09-11 11:13:30 +09:00
curry772 c6053055a4 토큰 발급 시 커넥션 풀 고갈 방지
- 풀 대기 시간 미지정 시 기본 3분을 기다려, 스케줄러 태스크 타임아웃(30초)에 먼저 인터럽트되고
  커넥션이 반납되지 않아 캐시한 풀이 마르는 문제
- setConnectionRequestTimeout 지정으로 풀 대기도 CONNECTION_TIMEOUT 을 따르도록 함
- evictExpiredConnections / evictIdleConnections 추가로 캐시한 풀의 죽은 커넥션 정리
2026-09-11 11:13:29 +09:00
curry772 0f9d38918a OAuth 토큰 현황 조회 API 진단 항목 보강
- 캐시에 빈 토큰이 있거나 만료시각이 없으면 재발급되지 않음을 message 로 알림
- 그룹 내 어댑터별 ADAPTER_TOKEN_ISSUING_CLIENT_TYPE 을 adapterTokenServiceClasses 로 노출
- 스케줄러와 거래 중 재발급이 서로 다른 어댑터 속성을 쓰는 경우를 확인하기 위함
- 단위테스트 2건 추가
2026-09-11 10:14:19 +09:00
curry772 76a74cd3a1 빈 아웃바운드 OAuth 토큰이 캐시에 고착되는 문제 수정
- 발급 실패를 삼키고 빈 토큰을 반환하는 구현체가 있어 그 토큰이 그대로 캐시되던 문제
- 빈 토큰은 expiration 이 없어 isExpired() 가 항상 false 라 재기동 전까지 재발급되지 않음
- SessionManagerForIgnite : 빈 토큰은 캐시하지 않고 warn 후 다음 주기에 재시도
- AccessTokenManagerByDB : 발급 결과가 비어 있으면 실패로 처리하고 null 반환
2026-09-11 10:14:12 +09:00
curry772 7723e830cf 아웃바운드 OAuth 토큰 현황 조회 API 추가
- GET /manage/oauth-token/status[/{어댑터그룹명}] 조회 전용
- SessionManager.peekOutboundAccessToken 추가 : 캐시만 조회해 상태 조회가 토큰 발급을 유발하지 않도록 함
- AccessTokenManagerByDB 에 조회 전용 getter 추가
- accessToken 은 앞 8자만 노출하고 clientSecret 은 응답에 미포함
- Ehcache 백엔드는 아웃바운드 토큰 캐시 미지원이므로 사유를 담아 응답
- 단위테스트 10건 추가
2026-09-10 16:35:45 +09:00
curry772 af5695f6bf 설정 기반 범용 OAuth 토큰 발급 구현체 추가
- HttpClientAccessTokenServiceWithConfig 신규
- 프로퍼티 그룹 OAuthTokenClient 에서 어댑터그룹별 요청/응답 형태를 읽어 조립
- 값(client_id/secret 등)은 DB, 형태(필드명/전달위치/응답구조)만 프로퍼티로 분리
- 설정이 없으면 WithDefault 와 동일 동작
- 기존 구현체 6종을 설정으로 대체 가능 (WithParamAddBody 는 DB 데이터 정리 선행 필요)
- 단위테스트 40건 추가
- build.gradle: libs 의 json-simple 을 테스트 클래스패스에 추가 (테스트 컴파일 불가 상태 해소)
2026-09-10 16:35:20 +09:00
curry772 9eea585b52 HttpClientAccessTokenServiceByDB 구현체 내에 tls 연결에 대한 인증서 검증 옵션 적용 2026-09-09 17:09:52 +09:00
curry772 66e51d1755 보안성심의 오픈소스 조치 2026-09-07 16:08:30 +09:00
curry772 5e07100c3d EZDATA 없는데, Ref필드에는 'ERP'로 되어 있는 경우, flat 전문 파싱 오류 처리 2026-09-07 14:39:31 +09:00
curry772 1bf2c78f03 NON_DELIVERY_GROUP 기능 추가 2026-09-07 14:27:20 +09:00
curry772 1941292079 Merge branch 'feature/hsm-config-failover'
- HSM 설정 이중화(PRIMARY/SECONDARY) 및 상태 조회 API 추가
- isNotBlank -> StringUtils.isNotBlank 로 변경

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnybKuxcuPafGhVGh4wqkZ
2026-09-03 09:03:33 +09:00
curry772 abf0de7757 Merge branch 'feature/msg-block-parse-skip'
- JsonReader: 수신 전문에 존재하는 블록을 ref 조건으로 버리지 않도록 수정
- 표준 오류응답 메시지 형식을 PropManager 에서 조회하도록 변경

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnybKuxcuPafGhVGh4wqkZ
2026-09-03 08:58:24 +09:00
curry772 a7fde3f81e 표준 오류응답 메시지 형식을 PropManager 에서 조회하도록 변경
DefaultProcess.setResMsg() 두 곳(표준/준표준 오류응답)에 하드코딩돼 있던
String.format("[%s] %s (%s)", ...) 를 프로퍼티 기반으로 바꾼다.

- 그룹/키: DefaultProcess / std.error.msg.format
- 인자 순서: 1=오류코드, 2=오류메시지, 3=오류상세
- 기본형식: "[%s] %s"

프로퍼티는 에이전트의 ReloadPropertyCommand 로 무중단 갱신될 수 있으므로
캐시하지 않고 호출할 때마다 조회한다.

방어 두 가지를 함께 넣는다.
 - 그룹/키가 없거나 값이 공백이면 기본형식을 쓴다. 설정을 넣지 않아도 동작한다.
 - 형식이 잘못되면(%s 개수 불일치 등) String.format 이 IllegalFormatException 을
   던지는데, 하필 오류응답 경로라 여기서 예외가 나면 오류 자체를 못 내려보낸다.
   조립 실패 시 경고만 남기고 기본형식으로 되돌린다.

특정 사이트 전용이 아닌 공통 기능이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnybKuxcuPafGhVGh4wqkZ
2026-09-03 08:57:17 +09:00
curry772 bfec1edea2 JsonReader: 수신 전문에 존재하는 블록을 ref 조건으로 버리지 않도록 수정
상대가 오류응답을 procs_rslt_dvcd=F 로 보내면서 요청응답구분 dman_rspn_dvcd 는
요청값 S 를 그대로 에코백했다(표준은 응답 시 R). 레이아웃의 MSG 블록 조건이 !S 라
MSG 이하가 통째로 버려졌고, 그 결과

 - MAIN_MSG 가 레이아웃 기본값으로 남아 오류가 "정상처리되었습니다." 로 보고되고
 - 거래로그는 StandardMessage 재직렬화본(EAILogDAO)이라 MSG 부 이후가 통째로 누락됐다

refPath/refValue 는 "이 블록이 전문에 들어있는가" 를 판단하는 조건인데, 고정길이와
달리 JSON 은 키의 존재 자체가 답이다. OBJECT 분기에 도달했다는 것은 수신 전문에
해당 블록이 실제로 들어있다는 뜻이므로 조건으로 다시 거르지 않고, 불일치는 상대
헤더의 오류로 보고 경고만 남긴 뒤 파싱을 계속한다.

기존에 한쪽 분기의 스킵 경고가 isDebugEnabled 가드에 가려 운영 로그에 남지 않던
문제도 함께 해소된다. FlatReader 는 조건이 블록 존재 판단에 반드시 필요하므로
그대로 둔다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UnybKuxcuPafGhVGh4wqkZ
2026-09-02 17:24:59 +09:00
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
77 changed files with 7793 additions and 409 deletions
+47 -10
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'
}
@@ -66,7 +75,11 @@ dependencies {
api 'io.micrometer:micrometer-core:1.5.17'
api 'io.micrometer:micrometer-registry-prometheus:1.5.17'
implementation "com.google.code.gson:gson:2.3.1"
// gson 2.3.1 -> 2.8.9 (CVE-2022-25647: 악의적 데이터 역직렬화 시 메모리 고갈/DoS)
// 사용처는 Jsons(new Gson), AlarmService(fromJson), TemplateAdapterErrorMsgHandler(JsonParser),
// alarm/ums/payload/*(@SerializedName) 4곳뿐이고 사용 API 는 그대로 유지된다.
// (new JsonParser().parse() 는 2.8.9 에서 deprecated 이지만 제거되지 않아 동작에 영향 없음)
implementation "com.google.code.gson:gson:2.8.9"
//api "com.eactive:mina-core-1.0.10-custom:1.0:custom@jar"
api ("org.apache.mina:mina-filter-ssl:1.0.10") {
@@ -82,10 +95,23 @@ dependencies {
api 'com.nimbusds:nimbus-jose-jwt:9.24.3'
api 'org.bouncycastle:bcprov-jdk15on:1.70'
// BouncyCastle: bcprov-jdk15on 라인은 1.70에서 종료되어 후속 패치가 없다.
// jdk18on 라인으로 전환한다(패키지명·프로바이더명("BC") 동일 → 소스 변경 없음,
// 클래스파일 major 52 / Bundle-RequiredExecutionEnvironment: JavaSE-1.8 이라 JDK 8 유지 가능).
// 사용처: AESCryptoModuleExtension, ARIACryptoModuleExtension
// (BouncyCastleProvider 등록, ARIA 알고리즘, FPE 모드의 FPEParameterSpec)
// 1.70 대비 CVE-2023-33201 / CVE-2024-29857 / CVE-2024-30171 / CVE-2024-30172 해소.
// bcpkix 는 spring-security-jwt 가 전이로 끌고 오던 1.64 를 대체한다(아래 exclude 참조).
api 'org.bouncycastle:bcprov-jdk18on:1.78.1'
api 'org.bouncycastle:bcpkix-jdk18on:1.78.1'
api "io.undertow:undertow-servlet:${undertowVersion}"
api 'org.java-websocket:Java-WebSocket:1.3.9'
// Java-WebSocket 1.3.9 -> 1.5.7 (CVE-2020-11050: 구형 암호화 통신/인증서 검증 결함에 의한 MitM)
// 사용 API 는 WebSocketServer 상속(onOpen/onClose/onMessage/onError/onStart)과
// WebSocket 의 send/close/getRemoteSocketAddress/isOpen, start()/stop(timeout) 뿐이라
// 1.4.0 의 파괴적 변경(WebSocketImpl.DEBUG 제거, Draft_10/17 제거, connections() -> getConnections())
// 에 해당하는 사용처가 없다. 1.5.x 는 로깅을 SLF4J 로 하는데 이미 클래스패스에 있다.
api 'org.java-websocket:Java-WebSocket:1.5.7'
api 'javax.cache:cache-api:1.1.1'
api 'org.apache.ignite:ignite-slf4j:2.14.0'
@@ -132,17 +158,28 @@ 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'
api "org.springframework.security:spring-security-jwt:1.1.1.RELEASE"
// jackson-dataformat-xml 제거 (2026-08-27)
// XmlMapper/JacksonXml* 사용처가 전 소스에 0건이고, 선언 버전(2.13.1)이
// 실제 해석되는 jackson-core/databind(2.12.7)와 마이너 불일치라 승격 시 위험했다.
// bcpkix-jdk15on:1.64 -> bcprov-jdk15on:1.64 를 전이로 끌고 온다.
// 위에서 jdk18on 으로 전환했으므로 함께 두면 org.bouncycastle.* 클래스가 중복되고
// 로딩 순서에 따라 구버전이 선택될 수 있다. 전이를 끊고 jdk18on 만 사용한다.
// (이 exclude 는 발행 POM 에도 기록되어 이 모듈을 참조하는 타 사이트에도 동일 적용된다)
api ("org.springframework.security:spring-security-jwt:1.1.1.RELEASE") {
exclude group: 'org.bouncycastle'
}
testRuntimeOnly 'com.h2database:h2:2.1.214'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
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/damo-manager.jar', 'libs/kjb-safedb.jar')
testImplementation files('libs/kjb-safedb.jar')
// libs 는 main 에서 compileOnly 라 테스트 클래스패스에 오르지 않는다.
// JsonToSetStatusFilterTest 가 org.json.simple.JSONObject 를 쓰므로 테스트에만 추가한다.
testImplementation files('libs/json-simple-1.1.1-custom-1.2.jar')
}
test {
Binary file not shown.
@@ -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;
@@ -171,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));
}
@@ -400,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));
}
@@ -411,9 +416,16 @@ 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");
@@ -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(",")) {
@@ -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;
@@ -60,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 ) {
@@ -71,12 +71,13 @@ import com.eactive.eai.common.util.JacksonUtil;
import com.eactive.eai.common.util.RestSendBodyLogUtils;
import com.eactive.eai.common.util.TxFileLogger;
import com.eactive.eai.common.util.XMLUtils;
import com.eactive.eai.message.StandardItem;
import com.eactive.eai.message.StandardMessage;
import com.eactive.eai.util.JsonPathUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import com.openbanking.eai.common.token.AccessTokenManager;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
@@ -106,10 +107,15 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
public static final String AUTH_TOKEN = "AUTH_TOKEN";
public static final String HTTP_HEADER_SETTING = "HTTP_HEADER_SETTING";
public static final String NON_DELIVERY_GROUP = "NON_DELIVERY_GROUP";
public static final String PAYLOAD_PARAM_NAME_CLIENT_ID = "client_id"; // jwhong
static final String DJB_ROOTLESS_ARRAY = "{ \"DJB_ROOTLESS_ARRAY\" : ";
// URL Path Variable 이 표준전문(요청) 항목을 참조할 때 사용하는 접두어. 접두어 뒤는 표준전문 루트 기준 전체 경로.
static final String STD_MSG_VARIABLE_PREFIX = "stdHeaderGroup.";
private boolean useAdapterToken;
/**
@@ -130,6 +136,12 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
String bizCode = "";
String headerGroupName = prop.getProperty(HEADER_GROUP);
String relayResponseHeaderKeys = prop.getProperty(HEADER_KEYS, "");
// 전송 대상에서 제외할 그룹명(콤마로 다건 지정 가능)
String[] nonDeliveryGroups = StringUtils.split(
StringUtils.trimToEmpty(prop.getProperty(NON_DELIVERY_GROUP)), ",");
if (nonDeliveryGroups == null) nonDeliveryGroups = new String[0];
useAdapterToken = StringUtils.equalsIgnoreCase(prop.getProperty("ADAPTER_TOKEN_USE_YN", "N"), "Y");
String authorizationHeaderName = prop.getProperty("ADAPTER_TOKEN_HEADER_NAME", "Authorization");
@@ -242,29 +254,44 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
uri = vo.getUrl();
} else {
if (dataObject == null) {
uri = changeUrl(messageType, vo.getUrl(), restOptionData, sendData, inboundPathVariables);
uri = changeUrl(messageType, vo.getUrl(), restOptionData, sendData, inboundPathVariables, tempProp);
} else {
uri = changeUrl(messageType, vo.getUrl(), restOptionData, dataObject, inboundPathVariables);
uri = changeUrl(messageType, vo.getUrl(), restOptionData, dataObject, inboundPathVariables, tempProp);
}
}
if(dataObject instanceof ObjectNode) {
if (dataObject != null && StringUtils.isNotBlank(headerGroupName)) {
httpHeader = ((ObjectNode)dataObject).get(headerGroupName);
((ObjectNode)dataObject).remove(headerGroupName);
if (dataObject instanceof ObjectNode) {
ObjectNode node = (ObjectNode) dataObject;
if (StringUtils.isNotBlank(headerGroupName)) {
httpHeader = node.get(headerGroupName);
node.remove(headerGroupName);
}
} else if(dataObject instanceof JSONObject) {
if (dataObject != null && StringUtils.isNotBlank(headerGroupName)) {
httpHeader = ((JSONObject)dataObject).get(headerGroupName);
((JSONObject)dataObject).remove(headerGroupName);
for (String nonDeliveryGroup : nonDeliveryGroups) {
if (StringUtils.isNotBlank(nonDeliveryGroup)) node.remove(nonDeliveryGroup.trim());
}
} else if (dataObject instanceof JSONObject) {
JSONObject json = (JSONObject) dataObject;
if (StringUtils.isNotBlank(headerGroupName)) {
httpHeader = json.get(headerGroupName);
json.remove(headerGroupName);
}
for (String nonDeliveryGroup : nonDeliveryGroups) {
if (StringUtils.isNotBlank(nonDeliveryGroup)) json.remove(nonDeliveryGroup.trim());
}
} else if (dataObject instanceof Document) {
Document doc = (Document)dataObject;
if (doc != null && StringUtils.isNotBlank(headerGroupName)) {
Element root = doc.getRootElement();
Element httpHeaderElement = root.element(headerGroupName);
root.remove(httpHeaderElement);
httpHeader = httpHeaderElement;
Element root = ((Document) dataObject).getRootElement();
if (root != null) {
if (StringUtils.isNotBlank(headerGroupName)) {
Element httpHeaderElement = root.element(headerGroupName);
if (httpHeaderElement != null) root.remove(httpHeaderElement);
httpHeader = httpHeaderElement;
}
// element(null) 은 dom4j 내부에서 NPE 가 발생하므로 반드시 blank 검사 후 조회한다
for (String nonDeliveryGroup : nonDeliveryGroups) {
if (StringUtils.isBlank(nonDeliveryGroup)) continue;
Element nonDeliveryGroupElement = root.element(nonDeliveryGroup.trim());
if (nonDeliveryGroupElement != null) root.remove(nonDeliveryGroupElement);
}
}
}
@@ -527,8 +554,8 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
// 이경우 정상으로 처리함
} else if (status >= 200 && status <= 207) {
if (useAdapterToken && StringUtils.equals(tokenErrorHttpStatusCode, "200")) {
needReissue = checkTokenRetry(responseMessage, tokenErrorCodeKey, tokenErrorCodeValues,
vo.getEncode());
needReissue = checkTokenRetry(status, responseMessage, tokenErrorHttpStatusCode, tokenErrorCodeKey,
tokenErrorCodeValues, vo.getEncode());
}
} else if (status == 302) {
if (!StringUtils.contains(relayResponseHeaderKeys, "Location")) {
@@ -542,15 +569,14 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
if (status >= 400 && status < 500) {
if (useAdapterToken) {
needReissue = checkTokenRetry(responseMessage, tokenErrorCodeKey, tokenErrorCodeValues,
vo.getEncode());
needReissue = checkTokenRetry(status, responseMessage, tokenErrorHttpStatusCode,
tokenErrorCodeKey, tokenErrorCodeValues, vo.getEncode());
}
if (!needReissue) {
throw new Exception(errMsg);
}
} else {
throw new Exception(errMsg);
}
}
@@ -577,12 +603,17 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
+ "]");
}
// 토큰 재발급
AccessTokenManager tokenManager = AccessTokenManager.getInstance();
// 토큰 재발급. 전송 전 조회(:353)와 같은 DB 기반 매니저를 써야 Ignite 캐시가 갱신된다.
AccessTokenManagerByDB tokenManager = AccessTokenManagerByDB.getInstance();
String oldToken = accessToken == null ? null : accessToken.getAccessToken();
OAuth2AccessTokenVO newaccessToken = (OAuth2AccessTokenVO) tokenManager
.retryAccessTokenVO(vo.getAdapterGroupName(), prop, oldToken);
// 재발급받은 토큰으로 헤더를 다시 세팅한다. 기존에는 방금 실패한 토큰을 그대로 넣어
// 재시도가 같은 사유로 실패했다.
if (newaccessToken != null) {
accessToken = newaccessToken;
}
setAuthHeaders(method, accessToken, authorizationHeaderName);
if (logger.isDebug() && "N".equals(vo.getTestCallYn())) {
@@ -882,8 +913,8 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
String.format("%s %s", AccessTokenVO.BEARER_TYPE, accessToken.getAccessToken()));
}
private boolean checkTokenRetry(byte[] responseMessage, String tokenErrorCodeKey, String tokenErrorCodeValues,
String encode) {
protected boolean checkTokenRetry(int status, byte[] responseMessage, String tokenErrorHttpStatusCode,
String tokenErrorCodeKey, String tokenErrorCodeValues, String encode) {
if (responseMessage == null) {
return false;
}
@@ -1156,7 +1187,8 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
return document;
}
private String changeUrl(String messageType, String url, String restOption, Object sendData, Map<String, String> inboundPathVariables) {
private String changeUrl(String messageType, String url, String restOption, Object sendData,
Map<String, String> inboundPathVariables, Properties tempProp) {
if ((MessageType.JSON.equals(messageType) || MessageType.XML.equals(messageType))) {
try {
if (StringUtils.isBlank(restOption)) {
@@ -1189,19 +1221,30 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
// jsonObject = (JSONObject) JSONValue.parse((String) sendData);
// }
for (String urlVaribleId : uriVariables) {
String uriVariable = JsonPathUtil.getAt(jsonNode, urlVaribleId, "\\.");
String uriVariable;
if (urlVaribleId.startsWith(STD_MSG_VARIABLE_PREFIX)) {
uriVariable = getStandardMessageValue(tempProp, urlVaribleId);
} else {
uriVariable = JsonPathUtil.getAt(jsonNode, urlVaribleId, "\\.");
}
// String uriVariable = null;
// if( jsonObject != null ) {
// uriVariable = (String) jsonObject.get(urlVaribleId);
// jsonObject.remove(urlVaribleId);
// }
if (StringUtils.isBlank(uriVariable) && inboundPathVariables.containsKey(urlVaribleId)) {
if (StringUtils.isBlank(uriVariable) && inboundPathVariables != null
&& inboundPathVariables.containsKey(urlVaribleId)) {
uriVariable = inboundPathVariables.get(urlVaribleId);
}
// expand()는 이름이 아닌 순서로 매핑하므로 값이 없어도 자리를 유지해야 한다.
if (uriVariable == null) {
logger.warn("HttpClientAdapterServiceRest] uri variable not found=[" + urlVaribleId + "] ");
uriVariable = "";
}
urlVariableList.add(uriVariable);
}
} else if (MessageType.XML.equals(messageType)) {
Document doc;
@@ -1212,7 +1255,17 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
}
for (Object tempObject : uriVariables) {
String urlVaribleId = (String) tempObject;
if (urlVaribleId.startsWith(STD_MSG_VARIABLE_PREFIX)) {
String stdValue = getStandardMessageValue(tempProp, urlVaribleId);
urlVariableList.add(stdValue == null ? "" : stdValue);
continue;
}
Element element = (Element) doc.selectSingleNode("//" + urlVaribleId);
if (element == null) {
logger.warn("HttpClientAdapterServiceRest] uri variable not found=[" + urlVaribleId + "] ");
urlVariableList.add("");
continue;
}
urlVariableList.add(element.getText());
if (doc.getRootElement() == element) {
doc = null;
@@ -1235,6 +1288,29 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
return url;
}
/**
* URL Path Variable 이 {@code stdHeaderGroup.} 접두어를 가지면 요청 표준전문에서 값을 찾는다.
*
* @param tempProp 어댑터 임시 속성 (요청 표준전문 보관)
* @param urlVaribleId 접두어를 포함한 Path Variable 이름
* @return 표준전문 항목 값. 표준전문이 없거나 항목을 찾지 못하면 null
*/
private String getStandardMessageValue(Properties tempProp, String urlVaribleId) {
Object stdMessageObject = tempProp.get(HttpAdapterServiceKey.OUT_REQ_STD_MSG);
if (!(stdMessageObject instanceof StandardMessage)) {
logger.warn("HttpClientAdapterServiceRest] request standard message not found=[" + urlVaribleId + "] ");
return null;
}
// 접두어는 표기용 이름이므로 제거한 나머지가 표준전문 루트 기준 전체 경로가 된다.
String itemPath = urlVaribleId.substring(STD_MSG_VARIABLE_PREFIX.length());
StandardItem item = ((StandardMessage) stdMessageObject).findItem(itemPath);
if (item == null) {
logger.warn("HttpClientAdapterServiceRest] standard message item not found=[" + itemPath + "] ");
return null;
}
return item.getValue();
}
private String getUrl(String baseUrl, String extraPath) {
if (StringUtils.isBlank(extraPath)) {
return baseUrl;
@@ -9,6 +9,7 @@ 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;
@@ -37,7 +38,8 @@ public class HttpClient5AdapterServiceRestAddFilter extends HttpClient5AdapterSe
// // Adapter에서 Exception이 발생한 경우, 처리할 필터
// static final String EXCEPTION_FILTER = "EXCEPTION_FILTER";
private ObjectMapper mapper = new ObjectMapper();
// 기본 ObjectMapper 는 JsonNode 직렬화 시 작은 소수를 1.2E-7 로 바꿔버린다.
private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
/**
* 1. 기능 : HttpClient 호출 전후 Filter 적용용 2. 처리 개요 : <br>
@@ -85,7 +85,10 @@ 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";
@@ -18,6 +18,8 @@ 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;
@@ -30,14 +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);
private ObjectMapper mapper = new ObjectMapper();
// 기본 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);
}
@@ -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);
}
}
@@ -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
@@ -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);
// }
}
@@ -173,7 +173,12 @@ public class HttpClientAccessTokenServiceWithBase64Header implements HttpClientA
cmBuilder.setSSLSocketFactory(sslSocketFactory);
} else {
sslContext = SSLContextBuilder.create().loadTrustMaterial(new TrustAllStrategy()).build();
SSLConnectionSocketFactory csf = new SSLConnectionSocketFactory(sslContext);
SSLConnectionSocketFactory csf = null;
if (testMode) {
csf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
} else {
csf = new SSLConnectionSocketFactory(sslContext);
}
cmBuilder.setSSLSocketFactory(csf);
}
} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException | CertificateException
@@ -181,7 +181,12 @@ public class HttpClientAccessTokenServiceWithBase64NiceOn implements HttpClientA
cmBuilder.setSSLSocketFactory(sslSocketFactory);
} else {
sslContext = SSLContextBuilder.create().loadTrustMaterial(new TrustAllStrategy()).build();
SSLConnectionSocketFactory csf = new SSLConnectionSocketFactory(sslContext);
SSLConnectionSocketFactory csf = null;
if (testMode) {
csf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
} else {
csf = new SSLConnectionSocketFactory(sslContext);
}
cmBuilder.setSSLSocketFactory(csf);
}
} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException | CertificateException
@@ -0,0 +1,911 @@
package com.eactive.eai.authoutbound.client.impl;
import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
import com.eactive.eai.adapter.http.secure.HttpClient5SSLContextFactory;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.authoutbound.client.HttpClientAccessTokenServiceByDB;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.JacksonUtil;
import com.eactive.eai.common.util.Logger;
import com.eactive.eai.httpouttlsinfo.HttpOutTlsInfoManager;
import com.eactive.eai.httpouttlsinfo.HttpOutTlsInfoVO;
import com.eactive.eai.util.TestModeChecker;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
import org.apache.commons.lang3.StringUtils;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.classic.methods.HttpPost;
import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
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.HttpEntity;
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.message.BasicNameValuePair;
import org.apache.hc.core5.net.URIBuilder;
import org.apache.hc.core5.ssl.SSLContextBuilder;
import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
import org.springframework.security.web.util.UrlUtils;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.KeyManagementException;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* 1. 기능 : 설정 기반 범용 OAuth AccessToken 발급 구현체.
*
* 2. 처리 개요 :
* - 토큰 발급에 필요한 <b>값</b>(client_id/secret/scope/grant_type/URL)은 기존과 동일하게 DB(OutboundOAuthCredentialVo)에서 읽고,
* 사이트마다 달라지는 <b>형태</b>(파라미터명, 자격증명 전달 위치, 응답 구조)는 PropManager 의
* "{@value #PROP_GROUP}" 프로퍼티 그룹에서 어댑터그룹 단위로 읽어 조립한다.
* - 프로퍼티를 하나도 설정하지 않으면 HttpClientAccessTokenServiceWithDefault 와 동일하게 동작한다.
*
* <pre>
* {어댑터그룹명}.content-type = application/x-www-form-urlencoded (기본값. VO 의 contentType 보다 우선)
* {어댑터그룹명}.content-type.charset = Y (N 이면 Content-Type 에 charset 을 붙이지 않음)
* {어댑터그룹명}.method = POST (POST | GET)
*
* # 자격증명 전달 방식
* {어댑터그룹명}.credential.location = body | basic-header | custom-header (기본값 body)
* {어댑터그룹명}.credential.header.name = Authorization (basic-header 기본값. custom-header 는 필수)
* {어댑터그룹명}.credential.header.prefix = Basic (basic-header 기본값)
* {어댑터그룹명}.credential.header.value = basic | client-id | client-secret (헤더에 담을 값. basic-header 는 basic, custom-header 는 client-secret 기본)
* {어댑터그룹명}.credential.header.client-id.name = X-CLIENT-ID (client_id 도 별도 헤더로 보낼 때만)
*
* # 요청 필드명 매핑 (form 이면 파라미터명, json 이면 필드명, 헤더로 보내면 헤더명. json 은 dot-path 로 중첩 가능)
* # 값을 none 으로 두면 그 필드는 전송하지 않는다. 전송할 파라미터가 하나도 없으면 바디 자체를 붙이지 않는다.
* # in-header 에 나열한 필드는 바디가 아니라 헤더로 보낸다. (4개를 모두 나열하면 바디 없이 헤더로만 인증)
* {어댑터그룹명}.request.fields.in-header = client-id,client-secret,scope,grant-type
* {어댑터그룹명}.request.field.client-id = client_id
* {어댑터그룹명}.request.field.client-secret = client_secret
* {어댑터그룹명}.request.field.grant-type = grant_type
* {어댑터그룹명}.request.field.scope = scope
*
* # 고정 추가 헤더 / 바디 (prefix 스캔으로 N 건)
* {어댑터그룹명}.header.X-API-KEY = abcd
* {어댑터그룹명}.body.institution_code = 0088
*
* # 응답 필드명 매핑 (dot-path 로 중첩 조회)
* {어댑터그룹명}.response.field.access-token = dataBody.access_token
* {어댑터그룹명}.response.field.token-type = token_type
* {어댑터그룹명}.response.field.expires-in = expires_in
* {어댑터그룹명}.response.field.scope = scope
* {어댑터그룹명}.response.expires-in.default = 3600 (응답에 expires_in 이 없을 때. 미설정이면 VO 의 intervalSec, 그것도 없으면 3600)
*
* # HttpClient 옵션
* {어댑터그룹명}.http.content-compression = Y (N 이면 Accept-Encoding: gzip 을 붙이지 않음)
* {어댑터그룹명}.http.default-user-agent = Y (N 이면 기본 User-Agent 를 보내지 않음)
*
* # 응답 성공여부 판정 (설정한 경우에만 검사)
* {어댑터그룹명}.response.success.field = dataHeader.GW_RSLT_CD
* {어댑터그룹명}.response.success.value = 0000
* </pre>
*
* 3. 주의사항
* - client_secret 등 비밀정보는 절대 PropManager 에 설정하지 않는다. 관리포털에서 평문 조회되는 영역이다.
* 값은 DB(VO), 형태만 프로퍼티라는 경계를 지킨다.
* - HttpClientAccessTokenServiceFactoryByDB 가 className 기준으로 인스턴스를 캐싱하므로 이 클래스는
* 상태를 가지면 안 된다. 프로퍼티는 반드시 execute() 안에서 매번 읽는다.
*
* @author :
* @version : v 1.0.0
* @see : HttpClientAccessTokenServiceWithDefault.java
* @since :
*/
public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessTokenServiceByDB {
public static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static boolean testMode = TestModeChecker.isTestMode();
/** 어댑터그룹별 토큰 발급 형태를 조회할 프로퍼티 그룹 이름 */
static final String PROP_GROUP = "OAuthTokenClient";
private static final String CONTENT_TYPE_FORM = "application/x-www-form-urlencoded";
private static final String LOCATION_BODY = "body";
private static final String LOCATION_BASIC_HEADER = "basic-header";
private static final String LOCATION_CUSTOM_HEADER = "custom-header";
private static final String SOURCE_BASIC = "basic";
private static final String SOURCE_CLIENT_ID = "client-id";
private static final String SOURCE_CLIENT_SECRET = "client-secret";
/** request.field.* / response.field.* 에 이 값을 주면 해당 필드를 사용하지 않는다. */
private static final String FIELD_NONE = "none";
/** request.fields.in-header 에 나열할 수 있는 필드 이름 */
private static final String FIELD_CLIENT_ID = "client-id";
private static final String FIELD_CLIENT_SECRET = "client-secret";
private static final String FIELD_GRANT_TYPE = "grant-type";
private static final String FIELD_SCOPE = "scope";
private static final long DEFAULT_EXPIRES_IN = 3600L;
/** 캐시한 HttpClient 의 유휴 커넥션 정리 주기(초) */
private static final int IDLE_CONNECTION_EVICT_SECONDS = 30;
/**
* mTLS 여부/clientId 조합별로 CloseableHttpClient(및 내부 PoolingHttpClientConnectionManager)를
* 1회만 생성해 재사용한다. 이 서비스 인스턴스는 HttpClientAccessTokenServiceFactoryByDB 에
* className 기준으로 캐시되어 재사용되므로, 이 필드도 인스턴스 생명주기 동안 안전하게 재사용된다.
* (어댑터그룹별 설정을 들고 있는 것이 아니므로 stateless 원칙에 어긋나지 않는다)
*/
private final ConcurrentHashMap<String, CloseableHttpClient> httpClientCache = new ConcurrentHashMap<String, CloseableHttpClient>();
/**
* 1. 기능 : 토큰 발급에 사용 2. 처리 개요 : - 속성 정보를 설정 하고 토큰 발급 URL 호출 한다. 3. 주의사항
*
* @param adapterProp Http Adapter 속성 정보
* @return 반환 된 AccessTokenVO
* @exception Exception 수동 시스템 간 통신 중 발생
**/
public AccessTokenVO execute(String name, Properties adapterProp, OutboundOAuthCredentialVo oAuthCredentialVo)
throws Exception {
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(name);
String adapterUrl = adapterProp.getProperty("URL");
String encode = gvo.getMessageEncode();
String timeoutTemp = adapterProp.getProperty("HTTP_TIME_OUT");
if (StringUtils.isBlank(timeoutTemp)) {
timeoutTemp = "30000";
}
String connectionTimeoutTemp = adapterProp.getProperty("CONNECTION_TIMEOUT");
if (StringUtils.isBlank(connectionTimeoutTemp)) {
connectionTimeoutTemp = "30000";
}
int timeout = Integer.parseInt(timeoutTemp);
int connectionTimeout = Integer.parseInt(connectionTimeoutTemp);
long currentTime = System.currentTimeMillis();
String uri = oAuthCredentialVo.getUrl();
if (!UrlUtils.isAbsoluteUrl(uri)) {
uri = appendPath(adapterUrl, uri);
}
// content-type 은 프로퍼티 > VO > 기본값 순으로 결정한다.
String contentType = getProp(name, "content-type", null);
if (StringUtils.isBlank(contentType)) {
contentType = oAuthCredentialVo.getContentType();
}
if (StringUtils.isBlank(contentType)) {
contentType = CONTENT_TYPE_FORM;
if (logger.isDebug()) {
logger.debug("Content-Type not specified. Using default: " + CONTENT_TYPE_FORM);
}
}
String method = getProp(name, "method", "POST");
Charset charset;
if (StringUtils.isNotBlank(encode)) {
charset = Charset.forName(encode);
} else {
charset = Charset.defaultCharset();
encode = charset.toString();
}
boolean useForwardProxy = StringUtils.equalsIgnoreCase(adapterProp.getProperty("FORWARD_PROXY_USE_YN"), "Y");
String forwardProxyUrl = adapterProp.getProperty("FORWARD_PROXY_URL");
if (logger.isDebug()) {
logger.debug(
"contentType:{}, method:{}, uri:{}, charset:{}, transactionTimeout:{}, connectionTimeout:{}, useForwardProxy:{}, forwardProxyUrl:{}",
contentType, method, uri, encode, timeout, connectionTimeout, useForwardProxy, forwardProxyUrl);
}
// mTLS config with default connection parameters
boolean useMtls = StringUtils.equalsIgnoreCase(adapterProp.getProperty(HttpClientAdapterServiceKey.USE_MTLS),
"Y");
AdapterGroupVO adapterGroup = AdapterManager.getInstance().getAdapterGroup(name);
String clientId = adapterGroup.getClientId();
if (logger.isInfo()) {
logger.info("MTLS adapterGroupName. : {}, useMtls. : {}, clientId : {}", name, useMtls, clientId);
}
// mTLS 여부(및 clientId)별로 CloseableHttpClient 를 재사용한다. 매 호출마다 새로 만들면
// PoolingHttpClientConnectionManager 를 쓰는 의미가 없어지고 SSL 핸드셰이크 비용만 반복된다.
boolean contentCompression = !"N".equalsIgnoreCase(getProp(name, "http.content-compression", "Y"));
boolean defaultUserAgent = !"N".equalsIgnoreCase(getProp(name, "http.default-user-agent", "Y"));
// HttpClient 옵션도 캐시 키에 포함해야 어댑터그룹별 설정이 서로 섞이지 않는다.
String httpClientCacheKey = (useMtls ? "mtls:" + clientId : "default") + "|zip=" + contentCompression + "|ua="
+ defaultUserAgent;
CloseableHttpClient httpClient = httpClientCache.computeIfAbsent(httpClientCacheKey,
key -> buildHttpClient(useMtls, clientId, name, contentCompression, defaultUserAgent));
// 자격증명 전달 방식과 필드명 매핑 (모두 지역변수 — 이 클래스는 캐싱되는 싱글턴이므로 설정 상태를 갖지 않는다)
String location = getProp(name, "credential.location", LOCATION_BODY);
if (!LOCATION_BODY.equalsIgnoreCase(location) && !LOCATION_BASIC_HEADER.equalsIgnoreCase(location)
&& !LOCATION_CUSTOM_HEADER.equalsIgnoreCase(location)) {
throw new Exception("Unsupported credential.location. property [" + name + ".credential.location] = "
+ location);
}
String fieldClientId = getFieldName(name, "request.field.client-id", "client_id");
String fieldClientSecret = getFieldName(name, "request.field.client-secret", "client_secret");
String fieldGrantType = getFieldName(name, "request.field.grant-type", "grant_type");
String fieldScope = getFieldName(name, "request.field.scope", "scope");
// request.fields.in-header 에 나열한 필드는 바디가 아니라 헤더로 보낸다.
Set<String> inHeaderFields = parseInHeaderFields(name);
warnIgnoredCredentialFields(name, location, inHeaderFields);
// 요청 바디(또는 GET 쿼리) 파라미터와 요청 헤더 구성
Map<String, String> params = new LinkedHashMap<String, String>();
Map<String, String> headers = new LinkedHashMap<String, String>();
if (LOCATION_BODY.equalsIgnoreCase(location)) {
assignField(params, headers, inHeaderFields, FIELD_CLIENT_ID, fieldClientId,
oAuthCredentialVo.getClientId());
assignField(params, headers, inHeaderFields, FIELD_CLIENT_SECRET, fieldClientSecret,
oAuthCredentialVo.getClientSecret());
}
assignField(params, headers, inHeaderFields, FIELD_SCOPE, fieldScope, oAuthCredentialVo.getScope());
assignField(params, headers, inHeaderFields, FIELD_GRANT_TYPE, fieldGrantType,
oAuthCredentialVo.getGrantType());
// 관리화면에서 입력한 bodyJson (기존 구현체와의 호환)
String addBodyJson = oAuthCredentialVo.getBodyJson();
if (StringUtils.isNotBlank(addBodyJson)) {
ObjectMapper bodyJsonMapper = JacksonUtil.newNumberSafeMapper();
Map<String, Object> addBodyMap = bodyJsonMapper.readValue(addBodyJson, Map.class);
for (Map.Entry<String, Object> entry : addBodyMap.entrySet()) {
if (entry.getValue() != null) {
params.put(entry.getKey(), entry.getValue().toString());
}
}
}
// {어댑터그룹명}.body.* 로 설정한 고정 파라미터
params.putAll(getPropsByPrefix(name, "body."));
// 관리화면에서 입력한 headerJson (기존 구현체와의 호환)
String addHeaderJson = oAuthCredentialVo.getHeaderJson();
if (StringUtils.isNotBlank(addHeaderJson)) {
ObjectMapper headerJsonMapper = JacksonUtil.newNumberSafeMapper();
Map<String, Object> addHeaderMap = headerJsonMapper.readValue(addHeaderJson, Map.class);
for (Map.Entry<String, Object> entry : addHeaderMap.entrySet()) {
if (entry.getValue() != null) {
headers.put(entry.getKey(), entry.getValue().toString());
}
}
}
// {어댑터그룹명}.header.* 로 설정한 고정 헤더
headers.putAll(getPropsByPrefix(name, "header."));
if (!LOCATION_BODY.equalsIgnoreCase(location)) {
boolean basic = LOCATION_BASIC_HEADER.equalsIgnoreCase(location);
// basic-header 는 Authorization: Basic base64(id:secret) 가 기본,
// custom-header 는 헤더명/접두어/값 출처를 모두 설정으로 정한다.
String headerName = getProp(name, "credential.header.name", basic ? "Authorization" : null);
if (StringUtils.isBlank(headerName)) {
throw new Exception("credential.location=" + location + " requires " + PROP_GROUP + " property ["
+ name + ".credential.header.name]");
}
String prefix = getProp(name, "credential.header.prefix", basic ? "Basic" : null);
String source = getProp(name, "credential.header.value", basic ? SOURCE_BASIC : SOURCE_CLIENT_SECRET);
headers.put(headerName, join(prefix, resolveCredentialValue(name, source, oAuthCredentialVo)));
String idHeaderName = getProp(name, "credential.header.client-id.name", null);
if (StringUtils.isNotBlank(idHeaderName)) {
headers.put(idHeaderName, oAuthCredentialVo.getClientId());
}
}
// httpClient 는 캐시해서 재사용하므로 close 하지 않는다. (닫으면 커넥션 풀이 함께 종료된다)
{
HttpUriRequestBase request;
if ("GET".equalsIgnoreCase(method)) {
URIBuilder uriBuilder = new URIBuilder(uri, charset);
for (Map.Entry<String, String> entry : params.entrySet()) {
uriBuilder.addParameter(entry.getKey(), entry.getValue());
}
request = new HttpGet(uriBuilder.build());
} else {
request = new HttpPost(uri);
request.setHeader("Content-Type", buildContentTypeHeader(name, contentType, encode));
if (params.isEmpty()) {
// 전송할 파라미터가 없으면 바디를 붙이지 않는다. (헤더로만 인증하는 기관 대응)
if (logger.isDebug()) {
logger.debug("No request parameter. Sending no body. adapterGroupName : " + name);
}
} else if (isJsonContentType(contentType)) {
ObjectMapper objMapper = JacksonUtil.newNumberSafeMapper();
ObjectNode bodyNode = objMapper.createObjectNode();
for (Map.Entry<String, String> entry : params.entrySet()) {
putJsonPath(bodyNode, entry.getKey(), entry.getValue());
}
request.setEntity(new StringEntity(objMapper.writeValueAsString(bodyNode), charset));
} else if (isFormContentType(contentType)) {
List<NameValuePair> formParams = new ArrayList<NameValuePair>();
for (Map.Entry<String, String> entry : params.entrySet()) {
formParams.add(new BasicNameValuePair(entry.getKey(), entry.getValue()));
}
request.setEntity(new UrlEncodedFormEntity(formParams, charset));
} else {
// 조용히 form 으로 내보내면 상대 서버가 400 을 줬을 때 원인을 찾기 어렵다.
throw new Exception("Unsupported content-type for token request. property [" + name
+ ".content-type] = " + contentType);
}
}
for (Map.Entry<String, String> entry : headers.entrySet()) {
request.setHeader(entry.getKey(), entry.getValue());
}
RequestConfig.Builder requestConfigBuilder = RequestConfig.custom();
if (useForwardProxy) {
java.net.URL url = new java.net.URL(forwardProxyUrl);
// 프로토콜, 호스트, 포트 추출
String protocol = url.getProtocol();
String host = url.getHost();
int port = url.getPort();
HttpHost proxy = new HttpHost(protocol, host, port);
requestConfigBuilder.setProxy(proxy);
}
// 커넥션 풀 대기 시간을 지정하지 않으면 기본 3분을 기다린다. 스케줄러의 태스크 타임아웃(30초)이
// 먼저 걸려 요청이 인터럽트되고, 그 과정에서 커넥션이 반납되지 않아 풀이 마르는 악순환이 생긴다.
RequestConfig requestConfig = requestConfigBuilder
.setConnectTimeout(Timeout.ofMilliseconds(connectionTimeout))
.setConnectionRequestTimeout(Timeout.ofMilliseconds(connectionTimeout))
.setResponseTimeout(Timeout.ofMilliseconds(timeout)).build();
request.setConfig(requestConfig);
if (logger.isDebug()) {
// client_secret 은 로그에 남기지 않는다.
logger.debug("uri = [" + uri + "]");
logger.debug("method = [" + method + "]");
logger.debug("credentialLoc = [" + location + "]");
logger.debug("oauthClientId = [" + oAuthCredentialVo.getClientId() + "]");
logger.debug("oauthScope = [" + oAuthCredentialVo.getScope() + "]");
logger.debug("oauthGrantType = [" + oAuthCredentialVo.getGrantType() + "]");
logger.debug("contentType = [" + contentType + "]");
logger.debug("encode = [" + encode + "]");
logger.debug("requestParamNames = " + params.keySet());
logger.debug("requestHeaderNames= " + headers.keySet());
}
try (CloseableHttpResponse response = httpClient.execute(request)) {
if (response.getCode() / 100 != 2) {
throw new Exception("OAuth token receive status fail value= " + response.getCode());
}
HttpEntity entity = response.getEntity();
String responseString = EntityUtils.toString(entity, encode);
logger.debug("oauthToken RECV = [" + responseString + "]");
if (StringUtils.isBlank(responseString)) {
throw new Exception("oauth token return null");
}
return parseToken(name, responseString, currentTime, oAuthCredentialVo);
}
}
}
/**
* 응답 전문을 프로퍼티에 설정된 필드명 매핑에 따라 AccessTokenVO 로 변환한다.
*
* @param name 어댑터그룹명
* @param responseString 응답 전문
* @param currentTime 요청 시각 (만료시각 계산 기준)
* @param vo oauth 인증 정보. 응답에 expires_in 이 없을 때 intervalSec 을 사용한다.
* @return AccessTokenVO
* @throws Exception 성공코드 불일치, access_token 미존재 시
*/
AccessTokenVO parseToken(String name, String responseString, long currentTime,
OutboundOAuthCredentialVo vo) throws Exception {
int intervalSec = vo.getIntervalSec();
ObjectMapper objectMapper = JacksonUtil.newNumberSafeMapper();
JsonNode root = objectMapper.readTree(responseString);
// 응답 성공여부 판정 (설정한 경우에만)
String successField = getProp(name, "response.success.field", null);
if (StringUtils.isNotBlank(successField)) {
String expected = getProp(name, "response.success.value", null);
JsonNode successNode = findByPath(root, successField);
String actual = (successNode == null) ? null : successNode.asText();
if (successNode == null || (StringUtils.isNotBlank(expected) && !StringUtils.equals(expected, actual))) {
throw new Exception("OAuth token response result code mismatch. field=" + successField + ", expected="
+ expected + ", actual=" + actual);
}
}
OAuth2AccessTokenVO accessToken = new OAuth2AccessTokenVO();
// access_token 만 필수. 나머지는 없으면 없는 대로 진행한다.
String tokenPath = getFieldName(name, "response.field.access-token", "access_token");
JsonNode tokenNode = findByPath(root, tokenPath);
if (tokenNode == null || StringUtils.isBlank(tokenNode.asText())) {
throw new Exception("OAuth token not found in response. field=" + tokenPath);
}
accessToken.setAccessToken(tokenNode.asText());
accessToken.setClientId(vo.getClientId());
JsonNode tokenTypeNode = findByPath(root, getFieldName(name, "response.field.token-type", "token_type"));
if (tokenTypeNode != null && StringUtils.isNotBlank(tokenTypeNode.asText())) {
accessToken.setTokenType(tokenTypeNode.asText());
}
String expiresInPath = getFieldName(name, "response.field.expires-in", "expires_in");
JsonNode expiresInNode = findByPath(root, expiresInPath);
long expiresIn;
if (expiresInNode != null && StringUtils.isNotBlank(expiresInNode.asText())) {
expiresIn = expiresInNode.asLong();
} else {
// 응답에 만료시간이 없는 기관이 있다. 설정값 > 토큰 재발급 주기(intervalSec) > 1시간 순으로 사용한다.
String configured = getProp(name, "response.expires-in.default", null);
if (StringUtils.isNotBlank(configured)) {
expiresIn = Long.parseLong(configured);
} else if (intervalSec > 0) {
expiresIn = intervalSec;
} else {
expiresIn = DEFAULT_EXPIRES_IN;
}
if (logger.isWarn()) {
logger.warn("OAuth token response has no [{}]. Using expires-in {} sec. adapterGroupName : {}",
expiresInPath, expiresIn, name);
}
}
accessToken.setExpiration(new Date(currentTime + expiresIn * 1000L));
JsonNode scopeNode = findByPath(root, getFieldName(name, "response.field.scope", "scope"));
if (scopeNode != null && StringUtils.isNotBlank(scopeNode.asText())) {
accessToken.setScope(scopeNode.asText());
}
JsonNode clientUseCodeNode = findByPath(root,
getFieldName(name, "response.field.client-use-code", "client_use_code"));
if (clientUseCodeNode != null && StringUtils.isNotBlank(clientUseCodeNode.asText())) {
accessToken.setClientUseCode(clientUseCodeNode.asText());
}
logger.debug("oauthToken =" + accessToken.toString());
return accessToken;
}
/**
* credential.location 에 따라 무시되는 프로퍼티가 설정돼 있으면 경고를 남긴다.
* (execute() 마다 호출되므로 토큰 갱신 주기마다 반복 출력된다 — 오설정을 놓치지 않기 위한 의도)
*
* @param name 어댑터그룹명
* @param location credential.location 설정값
* @param inHeaderFields request.fields.in-header 목록
*/
private void warnIgnoredCredentialFields(String name, String location, Set<String> inHeaderFields) {
if (LOCATION_BODY.equalsIgnoreCase(location)) {
return;
}
if (!logger.isWarn()) {
return;
}
if (inHeaderFields.contains(FIELD_CLIENT_ID) || inHeaderFields.contains(FIELD_CLIENT_SECRET)) {
logger.warn(
"[{}] credential.location={} : client-id/client-secret in request.fields.in-header are ignored. adapterGroupName : {}",
PROP_GROUP, location, name);
}
// 기본값이 아니라 실제 설정 여부를 봐야 하므로 default 없는 조회를 쓴다. (none 은 끈 것이므로 제외)
if (isExplicitFieldName(getProp(name, "request.field.client-id", null))
|| isExplicitFieldName(getProp(name, "request.field.client-secret", null))) {
logger.warn(
"[{}] credential.location={} : request.field.client-id/client-secret are ignored. adapterGroupName : {}",
PROP_GROUP, location, name);
}
}
/**
* Content-Type 헤더값을 만든다.
* charset 이 이미 들어있거나 "{어댑터그룹명}.content-type.charset" 이 N 이면 charset 을 붙이지 않는다.
* (특히 application/x-www-form-urlencoded 에 charset 이 붙으면 거부하는 기관이 있다)
*
* @param name 어댑터그룹명
* @param contentType 결정된 content-type
* @param encode 전문 인코딩
* @return Content-Type 헤더값
*/
String buildContentTypeHeader(String name, String contentType, String encode) {
String value = StringUtils.trim(contentType);
if (StringUtils.containsIgnoreCase(value, "charset")) {
return value;
}
if (!"Y".equalsIgnoreCase(getProp(name, "content-type.charset", "Y"))) {
return StringUtils.removeEnd(value, ";");
}
// VO 에 "application/json;" 처럼 세미콜론까지 들어있는 경우가 있어 구분자 중복을 막는다.
if (StringUtils.endsWith(value, ";")) {
return value + " charset=" + encode;
}
return value + "; charset=" + encode;
}
/**
* mTLS 여부/clientId 조합에 맞는 SSLContext 로 PoolingHttpClientConnectionManager 와
* CloseableHttpClient 를 생성한다. httpClientCache 에 의해 조합당 1회만 호출되며, 반환된
* CloseableHttpClient 는 재사용을 위해 닫지 않는다(닫으면 커넥션 풀이 함께 종료된다).
*
* @param useMtls mTLS 사용 여부
* @param clientId mTLS 인증서 조회용 clientId
* @param adapterGroupName 어댑터그룹명 (로그용)
* @param contentCompression false 이면 Accept-Encoding: gzip 을 붙이지 않는다.
* @param defaultUserAgent false 이면 기본 User-Agent 를 보내지 않는다.
* @return 재사용할 CloseableHttpClient
*/
private CloseableHttpClient buildHttpClient(boolean useMtls, String clientId, String adapterGroupName,
boolean contentCompression, boolean defaultUserAgent) {
int maxTotalConnections = HttpClientAdapterServiceKey.DEFAULT_MAX_TOTAL_CONNECTIONS;
int maxHostConnections = HttpClientAdapterServiceKey.DEFAULT_MAX_CONNECTION_PER_HOST;
HttpOutTlsInfoVO mtlsInfo = null;
SSLContext sslContext = null;
PoolingHttpClientConnectionManagerBuilder cmBuilder = PoolingHttpClientConnectionManagerBuilder.create();
try {
if (useMtls) {
HttpOutTlsInfoManager tlsManager = HttpOutTlsInfoManager.getInstance();
if (StringUtils.isNotEmpty(clientId)) {
mtlsInfo = tlsManager.getHttpOutTlsInfo(clientId);
}
}
if (useMtls && mtlsInfo != null) {
String storeType = mtlsInfo.getStoreType();
String keyStoreInfo = mtlsInfo.getKeystoreInfo();
String keyStorePassword = mtlsInfo.getKeystorePassword();
String trustStoreInfo = mtlsInfo.getTruststoreInfo();
String trustStorePassword = mtlsInfo.getTruststorePassword();
String[] tlsVersions = null;
String[] cipherSuites = null;
if (StringUtils.isAnyEmpty(keyStoreInfo, keyStorePassword)) {
throw new Exception("mTLS keyStore config error");
}
boolean skipTrust = false;
if (StringUtils.isAnyEmpty(trustStoreInfo, trustStorePassword)) {
if (logger.isWarn())
logger.warn("Skip trustStore validation adapterGroupName : " + adapterGroupName);
skipTrust = true;
}
sslContext = HttpClient5SSLContextFactory.createMTLSContextFromContent(storeType, keyStoreInfo,
keyStorePassword, trustStoreInfo, trustStorePassword, skipTrust, tlsVersions, cipherSuites);
SSLConnectionSocketFactory sslSocketFactory = null;
if (testMode) {
sslSocketFactory = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
} else {
sslSocketFactory = new SSLConnectionSocketFactory(sslContext);
}
cmBuilder.setSSLSocketFactory(sslSocketFactory);
} else {
sslContext = SSLContextBuilder.create().loadTrustMaterial(new TrustAllStrategy()).build();
SSLConnectionSocketFactory csf = null;
if (testMode) {
csf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
} else {
csf = new SSLConnectionSocketFactory(sslContext);
}
cmBuilder.setSSLSocketFactory(csf);
}
} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException | CertificateException
| IOException | UnrecoverableKeyException e) {
throw new RuntimeException(e);
} catch (Exception e) {
throw new RuntimeException(e);
}
PoolingHttpClientConnectionManager connectionManager = cmBuilder.build();
connectionManager.setMaxTotal(maxTotalConnections);
connectionManager.setDefaultMaxPerRoute(maxHostConnections);
if (logger.isInfo()) {
logger.info(
"HttpClientAccessTokenServiceWithConfig] HttpClient(재사용) 생성. adapterGroupName={}, useMtls={}, clientId={}, contentCompression={}, defaultUserAgent={}",
adapterGroupName, useMtls, clientId, contentCompression, defaultUserAgent);
}
// HttpClient 를 캐시해 재사용하므로 죽은 커넥션이 풀에 남지 않도록 정리 설정을 건다.
HttpClientBuilder builder = HttpClients.custom().setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(TimeValue.ofSeconds(IDLE_CONNECTION_EVICT_SECONDS));
if (!contentCompression) {
builder.disableContentCompression();
}
if (!defaultUserAgent) {
builder.disableDefaultUserAgent();
}
return builder.build();
}
/**
* credential.header.value 설정에 따라 헤더에 담을 값을 만든다.
*
* @param name 어댑터그룹명
* @param source basic | client-id | client-secret
* @param vo oauth 인증 정보
* @return 헤더에 담을 값
* @throws Exception 지원하지 않는 설정값인 경우
*/
String resolveCredentialValue(String name, String source, OutboundOAuthCredentialVo vo) throws Exception {
if (SOURCE_BASIC.equalsIgnoreCase(source)) {
String authValue = vo.getClientId() + ":" + vo.getClientSecret();
return Base64.getEncoder().encodeToString(authValue.getBytes(StandardCharsets.UTF_8));
}
if (SOURCE_CLIENT_ID.equalsIgnoreCase(source)) {
// 이미 인코딩된 값을 clientId 에 저장해 두고 그대로 보내는 기관이 있다.
return vo.getClientId();
}
if (SOURCE_CLIENT_SECRET.equalsIgnoreCase(source)) {
return vo.getClientSecret();
}
throw new Exception("Unsupported credential.header.value. property [" + name + ".credential.header.value] = "
+ source);
}
/**
* "{어댑터그룹명}.request.fields.in-header" 설정을 파싱한다.
* 여기 나열한 필드는 바디가 아니라 헤더로 전송한다. (ex. "client-id,client-secret,scope,grant-type")
*
* @param name 어댑터그룹명
* @return 헤더로 보낼 필드 이름 집합. 미설정이면 빈 집합.
*/
Set<String> parseInHeaderFields(String name) {
Set<String> fields = new LinkedHashSet<String>();
String configured = getProp(name, "request.fields.in-header", null);
if (StringUtils.isBlank(configured)) {
return fields;
}
for (String token : StringUtils.split(configured, ',')) {
String field = StringUtils.lowerCase(StringUtils.trim(token));
if (StringUtils.isNotBlank(field)) {
fields.add(field);
}
}
return fields;
}
/**
* 필드 하나를 in-header 설정에 따라 헤더 또는 바디(GET 이면 쿼리)에 담는다.
* 어느 쪽이든 이름은 "{어댑터그룹명}.request.field.*" 로 매핑한 값을 쓴다.
*
* @param params 바디/쿼리 파라미터
* @param headers 요청 헤더
* @param inHeaderFields 헤더로 보낼 필드 목록
* @param field 필드 이름 (client-id / client-secret / grant-type / scope)
* @param mappedName 매핑된 파라미터명 또는 헤더명. none 으로 끈 경우 null.
* @param value 전송할 값
*/
void assignField(Map<String, String> params, Map<String, String> headers, Set<String> inHeaderFields, String field,
String mappedName, String value) {
if (inHeaderFields.contains(field)) {
putIfNotBlank(headers, mappedName, value);
} else {
putIfNotBlank(params, mappedName, value);
}
}
/**
* 실제로 필드명이 설정돼 있는지 확인한다. 미설정이거나 none(끔) 이면 false.
*
* @param value 프로퍼티 설정값
* @return 필드명이 설정돼 있으면 true
*/
private boolean isExplicitFieldName(String value) {
return StringUtils.isNotBlank(value) && !FIELD_NONE.equalsIgnoreCase(StringUtils.trim(value));
}
/**
* 필드명 매핑을 조회한다. 설정값이 none 이면 그 필드를 사용하지 않겠다는 의미이므로 null 을 반환한다.
*
* @param name 어댑터그룹명
* @param key 프로퍼티 키
* @param def 미설정 시 기본 필드명
* @return 필드명. 사용하지 않으면 null.
*/
String getFieldName(String name, String key, String def) {
String value = StringUtils.trim(getProp(name, key, def));
if (FIELD_NONE.equalsIgnoreCase(value)) {
return null;
}
return value;
}
/**
* content-type 을 파라미터 부분을 뗀 소문자로 정규화한다. (ex. "Application/JSON; charset=UTF-8" → "application/json")
*
* @param contentType content-type 설정값
* @return 정규화된 content-type
*/
private String normalizeContentType(String contentType) {
return StringUtils.lowerCase(StringUtils.trim(StringUtils.substringBefore(contentType, ";")));
}
/**
* JSON 계열 content-type 인지 확인한다. (application/json, application/vnd.xxx+json 등)
*
* @param contentType content-type 설정값
* @return JSON 이면 true
*/
boolean isJsonContentType(String contentType) {
return StringUtils.contains(normalizeContentType(contentType), "json");
}
/**
* form-urlencoded content-type 인지 확인한다.
*
* @param contentType content-type 설정값
* @return form-urlencoded 이면 true
*/
boolean isFormContentType(String contentType) {
return StringUtils.contains(normalizeContentType(contentType), "x-www-form-urlencoded");
}
/**
* PropManager 에서 "{어댑터그룹명}.{key}" 프로퍼티를 조회한다.
*
* @param name 어댑터그룹명
* @param key 프로퍼티 키 (어댑터그룹명 이후 부분)
* @param def 미설정 시 반환할 기본값
* @return 프로퍼티 값
*/
private String getProp(String name, String key, String def) {
return PropManager.getInstance().getProperty(PROP_GROUP, name + "." + key, def);
}
/**
* "{어댑터그룹명}.{prefix}" 로 시작하는 프로퍼티를 모두 조회한다. (header./body. 다건 설정용)
*
* @param name 어댑터그룹명
* @param prefix 조회할 접두어. 마침표까지 포함한다. (ex. "header.")
* @return 접두어를 제거한 키와 값의 Map. 설정이 없으면 빈 Map.
*/
Map<String, String> getPropsByPrefix(String name, String prefix) {
Map<String, String> result = new LinkedHashMap<String, String>();
PropManager propManager = PropManager.getInstance();
// getProperties() 는 그룹이 없으면 RuntimeException 이므로 먼저 확인한다.
if (!propManager.isContainProperties(PROP_GROUP)) {
return result;
}
String full = name + "." + prefix;
Properties properties = propManager.getProperties(PROP_GROUP);
for (String key : properties.stringPropertyNames()) {
if (key.startsWith(full) && key.length() > full.length()) {
result.put(key.substring(full.length()), properties.getProperty(key));
}
}
return result;
}
/**
* dot-path 로 JsonNode 를 탐색한다. (ex. "dataBody.access_token")
*
* @param root 응답 JSON 루트
* @param path 조회 경로
* @return 찾은 노드. 경로가 없거나 null 노드면 null.
*/
JsonNode findByPath(JsonNode root, String path) {
if (root == null || StringUtils.isBlank(path)) {
return null;
}
JsonNode node = root;
for (String token : StringUtils.split(path, '.')) {
if (node == null) {
return null;
}
node = node.get(token);
}
if (node == null || node.isNull()) {
return null;
}
return node;
}
/**
* dot-path 로 JSON 요청 바디에 값을 넣는다. 경로 중간 노드는 없으면 생성한다. (ex. "auth.clientId")
*
* @param root 요청 바디 루트
* @param path 필드 경로
* @param value 설정할 값
*/
void putJsonPath(ObjectNode root, String path, String value) {
String[] tokens = StringUtils.split(path, '.');
ObjectNode node = root;
for (int i = 0; i < tokens.length - 1; i++) {
JsonNode child = node.get(tokens[i]);
if (child instanceof ObjectNode) {
node = (ObjectNode) child;
} else {
node = node.putObject(tokens[i]);
}
}
node.put(tokens[tokens.length - 1], value);
}
/**
* 헤더값 접두어와 값을 공백으로 잇는다. 접두어가 없으면 값만 반환한다.
*
* @param prefix 접두어 (ex. "Basic")
* @param value 값
* @return 완성된 헤더값
*/
private String join(String prefix, String value) {
if (StringUtils.isBlank(prefix)) {
return value;
}
return prefix + " " + value;
}
/**
* 값이 비어있지 않을 때만 Map 에 담는다.
*
* @param params 대상 Map
* @param key 파라미터명
* @param value 파라미터값
*/
private void putIfNotBlank(Map<String, String> params, String key, String value) {
if (StringUtils.isNotBlank(key) && StringUtils.isNotBlank(value)) {
params.put(key, value);
}
}
/**
* URL에 경로를 추가합니다. 중복되는 슬래시를 방지합니다.
*
* @param baseUrl 기본 URL
* @param pathToAdd 추가할 경로
* @return 완성된 URL 문자열
*/
public static String appendPath(String baseUrl, String pathToAdd) {
if (!baseUrl.endsWith("/") && !pathToAdd.startsWith("/")) {
return baseUrl + "/" + pathToAdd;
} else if (baseUrl.endsWith("/") && pathToAdd.startsWith("/")) {
return baseUrl + pathToAdd.substring(1);
} else {
return baseUrl + pathToAdd;
}
}
}
@@ -180,7 +180,12 @@ public class HttpClientAccessTokenServiceWithDefault implements HttpClientAccess
cmBuilder.setSSLSocketFactory(sslSocketFactory);
} else {
sslContext = SSLContextBuilder.create().loadTrustMaterial(new TrustAllStrategy()).build();
SSLConnectionSocketFactory csf = new SSLConnectionSocketFactory(sslContext);
SSLConnectionSocketFactory csf = null;
if (testMode) {
csf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
} else {
csf = new SSLConnectionSocketFactory(sslContext);
}
cmBuilder.setSSLSocketFactory(csf);
}
} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException | CertificateException
@@ -179,7 +179,12 @@ public class HttpClientAccessTokenServiceWithParam implements HttpClientAccessTo
cmBuilder.setSSLSocketFactory(sslSocketFactory);
} else {
sslContext = SSLContextBuilder.create().loadTrustMaterial(new TrustAllStrategy()).build();
SSLConnectionSocketFactory csf = new SSLConnectionSocketFactory(sslContext);
SSLConnectionSocketFactory csf = null;
if (testMode) {
csf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
} else {
csf = new SSLConnectionSocketFactory(sslContext);
}
cmBuilder.setSSLSocketFactory(csf);
}
} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException | CertificateException
@@ -196,7 +196,12 @@ public class HttpClientAccessTokenServiceWithParamAddBody implements HttpClientA
cmBuilder.setSSLSocketFactory(sslSocketFactory);
} else {
sslContext = SSLContextBuilder.create().loadTrustMaterial(new TrustAllStrategy()).build();
SSLConnectionSocketFactory csf = new SSLConnectionSocketFactory(sslContext);
SSLConnectionSocketFactory csf = null;
if (testMode) {
csf = new SSLConnectionSocketFactory(sslContext, NoopHostnameVerifier.INSTANCE);
} else {
csf = new SSLConnectionSocketFactory(sslContext);
}
cmBuilder.setSSLSocketFactory(csf);
}
} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException | CertificateException
@@ -68,6 +68,15 @@ public class AccessTokenManagerByDB implements Lifecycle {
*/
private Map<String, OutboundOAuthCredentialVo> outboundOAuthCredentialVos;
/** 어댑터그룹별로 보관할 토큰 발급 이력 건수 */
private static final int ISSUE_HISTORY_SIZE = 10;
/** 이력에 남길 accessToken 앞자리 수 */
private static final int UNMASKED_TOKEN_LENGTH = 8;
/** 어댑터그룹별 토큰 발급 이력. 이 노드 메모리에만 존재하며 재기동 시 사라진다. */
private final Map<String, Deque<TokenIssueHistory>> issueHistories = new ConcurrentHashMap<>();
@Autowired
OutboundOAuthCredentialDao outboundOAuthCredentialDao;
@@ -238,33 +247,38 @@ public class AccessTokenManagerByDB implements Lifecycle {
try {
logger.debug("Executing token issuance for adapter group: {}", adapterGroupName);
SessionManager.getInstance().getOutboundAccessToken(adapterGroupName, new Function<AccessTokenVO, AccessTokenVO>() {
SessionManager sessionManager = SessionManager.getInstance();
@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 exists but expiration time is null for adapter group: {}", adapterGroupName);
return accessToken;
}
}
});
// 발급을 유발하지 않는 조회로 먼저 상태를 본다.
// getOutboundAccessToken 은 "없거나 이미 만료" 일 때만 발급 함수를 부르기 때문에,
// 만료 임박 판정을 그 안에 두면 도달하지 못한다.
AccessTokenVO cached = sessionManager.peekOutboundAccessToken(adapterGroupName);
long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000L);
if (cached == null) {
logger.debug("Token not exists for adapter group: {}", adapterGroupName);
sessionManager.getOutboundAccessToken(adapterGroupName,
token -> issueToken(credential));
} else if (cached.getExpiration() == null) {
// 만료시각이 없는 토큰은 갱신 시점을 판단할 수 없다.
logger.debug("Token exists but expiration time is null for adapter group: {}",
adapterGroupName);
} else if (cached.getExpiration().before(new Date(intervalTime))) {
// 다음 스케줄 전에 만료되므로 미리 갱신한다.
// 여러 노드가 동시에 들어와도 분산락 안에서 다시 확인해 한 번만 발급된다.
// 다른 노드가 이미 넣어둔 토큰이 다음 틱까지 유효하면 발급하지 않는다.
logger.debug("Token expires before next schedule : {}, expiration date: {}",
adapterGroupName, cached.getExpiration());
sessionManager.reissueOutboundAccessToken(adapterGroupName, cached.getAccessToken(),
intervalTime, token -> issueToken(credential));
} else {
logger.debug(
"Token still valid until next schedule for adapter group: {}, expiration date: {}",
adapterGroupName, cached.getExpiration());
}
logger.debug("Token issuance completed for adapter group: {}", adapterGroupName);
} catch (Exception e) {
@@ -315,14 +329,38 @@ public class AccessTokenManagerByDB implements Lifecycle {
HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
if(service != null) {
logger.debug("Executing token service of type: {} for adapter group: {}", type, adapterGroupName);
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties, outboundOAuthCredentialVo);
long startTime = System.currentTimeMillis();
try {
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
} catch (Exception e) {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, type, startTime, null,
toFailReason(e));
throw e;
}
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, type, startTime,
accessToken, null);
// 발급에 실패했는데도 빈 토큰을 반환하는 구현체가 있다. 그대로 캐시되면
// 만료시각이 없어 재발급 대상이 되지 않으므로 실패로 처리한다.
if (accessToken == null || StringUtils.isBlank(accessToken.getAccessToken())) {
logger.error("Issued token is empty. type: {}, adapter group: {}", type, adapterGroupName);
return null;
}
logger.info("New token issued successfully for adapter group: {}", adapterGroupName);
return accessToken;
} else {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, type,
System.currentTimeMillis(), null, "토큰 발급 구현체를 찾을 수 없음");
logger.warn("Token service not found for type: {} and adapter group: {}", type, adapterGroupName);
}
} else {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, null,
System.currentTimeMillis(), null, "어댑터 설정을 찾을 수 없음");
logger.warn("No valid adapter configuration found for adapter group: {}", adapterGroupName);
}
@@ -384,6 +422,111 @@ public class AccessTokenManagerByDB implements Lifecycle {
lifecycle.removeLifecycleListener(listener);
}
/**
* 1. 기능 : 토큰 발급 이력을 남긴다.
* 2. 처리 개요 : 어댑터그룹별로 최근 ISSUE_HISTORY_SIZE 건만 노드 메모리에 보관한다.
* 성공뿐 아니라 실패도 남긴다. 실패는 캐시에 아무것도 남지 않아 사후 추적이 어렵기 때문이다.
* 3. 주의사항 : accessToken 은 앞 UNMASKED_TOKEN_LENGTH 자만 남겨 보관한다.
*
* @param adapterGroupName 어댑터그룹명
* @param trigger SCHEDULE / RETRY
* @param serviceClass 사용한 발급 구현체 클래스명
* @param startTime 발급 시도 시각
* @param accessToken 발급된 토큰. 실패 시 null.
* @param failReason 실패 사유. 성공 시 null.
**/
private void recordIssueHistory(String adapterGroupName, String trigger, String serviceClass, long startTime,
AccessTokenVO accessToken, String failReason) {
String reason = failReason;
String maskedToken = null;
Date expiration = null;
if (reason == null) {
if (accessToken == null || StringUtils.isBlank(accessToken.getAccessToken())) {
reason = "발급된 토큰이 비어 있음";
} else {
maskedToken = maskToken(accessToken.getAccessToken());
expiration = accessToken.getExpiration();
}
}
TokenIssueHistory history = new TokenIssueHistory(startTime, trigger, serviceClass, maskedToken, expiration,
reason);
Deque<TokenIssueHistory> histories = issueHistories.computeIfAbsent(adapterGroupName,
key -> new ArrayDeque<TokenIssueHistory>());
synchronized (histories) {
histories.addFirst(history);
while (histories.size() > ISSUE_HISTORY_SIZE) {
histories.removeLast();
}
}
}
/** 예외 메시지가 비어 있는 경우를 대비해 클래스명이라도 이력에 남긴다. */
private String toFailReason(Exception e) {
return StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getName());
}
/** accessToken 앞자리만 남기고 마스킹한다. */
private String maskToken(String accessToken) {
if (accessToken.length() <= UNMASKED_TOKEN_LENGTH) {
return StringUtils.repeat('*', accessToken.length());
}
return StringUtils.substring(accessToken, 0, UNMASKED_TOKEN_LENGTH) + "***";
}
/**
* 1. 기능 : 어댑터그룹의 토큰 발급 이력을 최근 순으로 반환한다.
* 2. 처리 개요 : 상태 조회 API 에서 사용한다. 이 노드에서 일어난 발급만 담긴다.
*
* @param adapterGroupName 어댑터그룹명
* @return 최근 순 이력 목록. 없으면 빈 목록.
**/
public List<TokenIssueHistory> getIssueHistories(String adapterGroupName) {
Deque<TokenIssueHistory> histories = issueHistories.get(adapterGroupName);
if (histories == null) {
return Collections.emptyList();
}
synchronized (histories) {
return new ArrayList<>(histories);
}
}
/**
* 1. 기능 : 등록된 OAuth 인증 정보의 어댑터그룹명 목록을 반환한다.
* 2. 처리 개요 : 상태 조회 API 에서 사용한다.
*
* @return 어댑터그룹명 목록
**/
public Set<String> getRegisteredAdapterGroupNames() {
return new TreeSet<>(outboundOAuthCredentialVos.keySet());
}
/**
* 1. 기능 : 어댑터그룹의 OAuth 인증 정보를 반환한다.
* 2. 처리 개요 : 상태 조회 API 에서 사용한다.
* 3. 주의사항 : clientSecret 이 들어있으므로 응답 전문에 그대로 담지 말 것.
*
* @return OAuth 인증 정보. 등록돼 있지 않으면 null.
**/
public OutboundOAuthCredentialVo getOutboundOAuthCredentialVo(String adapterGroupName) {
return outboundOAuthCredentialVos.get(adapterGroupName);
}
/**
* 1. 기능 : 캐시에 있는 토큰만 조회한다.
* 2. 처리 개요 : getAccessTokenVO() 와 달리 캐시에 없거나 만료됐어도 새로 발급하지 않는다.
* 상태 조회가 토큰 발급을 유발하면 안 되므로 조회 API 는 이 메서드를 쓴다.
*
* @return 캐시에 있는 토큰. 없으면 null.
* @exception UnsupportedOperationException 아웃바운드 토큰 캐시를 지원하지 않는 SessionManager 백엔드
**/
public AccessTokenVO peekAccessTokenVO(String adapterGroupName) {
return SessionManager.getInstance().peekOutboundAccessToken(adapterGroupName);
}
public boolean isOAuthCredentialRegistered(String adapterGroupName){
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
return outboundOAuthCredentialVo != null && "Y".equals(outboundOAuthCredentialVo.getUseYn());
@@ -464,29 +607,50 @@ public class AccessTokenManagerByDB implements Lifecycle {
* 3. 주의사항
*
**/
public synchronized AccessTokenVO retryAccessTokenVO(String adapterGroupName, Properties properties,
String oldToken) throws Exception {
public AccessTokenVO retryAccessTokenVO(String adapterGroupName, Properties properties, String oldToken)
throws Exception {
AccessTokenVO accessToken = getAccessTokenVO(adapterGroupName);
// 동시 요청이 발생할 경우 synchronized 처리를 했기때문에 토큰을 다시 한번 체크한다.
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'.");
}
return accessToken;
String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE");
final HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
if (service == null) {
logger.warn("Token service not found for type: {} and adapter group: {}", type, adapterGroupName);
return getAccessTokenVO(adapterGroupName);
}
final OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
// 클러스터 전역에서 한 번만 발급되도록 분산락 안에서 처리한다. 발급 결과는 캐시에 반영되므로
// 뒤따르는 거래는 재발급하지 않는다. 구현체는 호출자가 넘긴 어댑터 속성 기준으로 고른다.
// 거부된 토큰을 바꾸는 것이 목적이므로 만료 기준 검사는 하지 않고 oldToken 비교만 한다.
// (만료 전이어도 상대 기관이 거부한 상황이다)
return SessionManager.getInstance().reissueOutboundAccessToken(adapterGroupName, oldToken, 0L,
new Function<AccessTokenVO, AccessTokenVO>() {
@Override
public AccessTokenVO apply(AccessTokenVO currentToken) {
long startTime = System.currentTimeMillis();
try {
logger.info("Reissuing token for adapter group: {}, type: {}", adapterGroupName, type);
AccessTokenVO issued = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_RETRY, type, startTime,
issued, null);
return issued;
} catch (Exception e) {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_RETRY, type, startTime,
null, toFailReason(e));
logger.error("Token reissue failed for adapter group: {}", adapterGroupName, e);
return null;
}
}
});
}
}
@@ -0,0 +1,61 @@
package com.eactive.eai.common.authoutbound;
import java.text.SimpleDateFormat;
import java.util.Date;
import lombok.Getter;
/**
* 아웃바운드 OAuth 토큰 발급 이력 한 건.
*
* 진단 목적이므로 성공뿐 아니라 실패도 남긴다. accessToken 은 마스킹된 값만 담는다.
* 이 인스턴스는 노드 메모리에만 존재하며 재기동 시 사라진다.
*/
@Getter
public class TokenIssueHistory {
/** 스케줄러의 주기 발급 */
public static final String TRIGGER_SCHEDULE = "SCHEDULE";
/** 거래 중 재발급 */
public static final String TRIGGER_RETRY = "RETRY";
private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
/** 발급을 시도한 시각 */
private final String issuedAt;
/** SCHEDULE | RETRY */
private final String trigger;
/** 사용한 발급 구현체 클래스명 */
private final String serviceClass;
private final boolean success;
/** 발급에 걸린 시간(ms) */
private final long elapsedMs;
/** 앞 8자만 남긴 accessToken. 실패 시 null. */
private final String accessTokenMasked;
/** 발급된 토큰의 만료 시각. 실패 시 null. */
private final String expiration;
/** 실패 사유. 성공 시 null. */
private final String failReason;
TokenIssueHistory(long startTime, String trigger, String serviceClass, String accessTokenMasked, Date expiration,
String failReason) {
SimpleDateFormat formatter = new SimpleDateFormat(DATE_FORMAT);
this.issuedAt = formatter.format(new Date(startTime));
this.trigger = trigger;
this.serviceClass = serviceClass;
this.elapsedMs = System.currentTimeMillis() - startTime;
this.failReason = failReason;
this.success = failReason == null;
this.accessTokenMasked = accessTokenMasked;
this.expiration = expiration == null ? null : formatter.format(expiration);
}
}
@@ -791,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,6 +7,10 @@ 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;
@@ -199,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
// -------------------------------------------------------------------------
@@ -1,5 +1,7 @@
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;
@@ -12,6 +14,10 @@ import java.security.NoSuchAlgorithmException;
import java.security.Provider;
import java.security.Security;
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;
@@ -19,6 +25,7 @@ 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;
@@ -27,6 +34,7 @@ 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;
@@ -35,40 +43,62 @@ 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
*
* ---------------------------------------------------------------------
* [세션 누적 방지 / 회로차단 로직 추가]
* 기존 구현은 재로드마다 KeyStore.getInstance(...).load(null, pin) 을 새로
* 호출하여 PKCS11 세션(C_OpenSession)이 계속 누적되고, 결국 HSM 파티션의
* 최대 세션 수를 초과하면서 reload 가 영구적으로 실패하는 문제가 있었다.
* 이를 방지하기 위해:
* 1) 정상 상황에서는 "기존 keyStore 인스턴스"에 다시 load() 하여 세션 재사용
* [설정 이중화 / 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) 연속 실패가 임계치를 넘으면 Provider 자체를 logout 후 완전히 재생성
* 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 volatile KeyStore keyStore;
private boolean started;
@@ -77,10 +107,23 @@ public class HsmManager implements Lifecycle {
private volatile char[] pin;
// 재로드 주기 (분 단위). 필요시 PropManager로 외부화 가능.
private static long RELOAD_INTERVAL_MINUTES = 1;
/** 현재 연결에 사용 중인 설정. 미연결이면 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() {
}
@@ -89,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) {
@@ -99,6 +248,7 @@ public class HsmManager implements Lifecycle {
try {
init();
startReloadScheduler();
registerPropertyChangeListener();
} catch (Exception e) {
throw new LifecycleException(ExceptionUtil.getErrorCode(e, "RECEAIHSM002"));
}
@@ -109,38 +259,133 @@ public class HsmManager implements Lifecycle {
private void init() throws Exception {
String reloadIntervalStr = PropManager.getInstance().getProperty(GROUP_NAME, PROP_RELOAD_INTERVAL_MINUTES, "10");
RELOAD_INTERVAL_MINUTES = Long.parseLong(reloadIntervalStr);
EncryptionManager encManager = EncryptionManager.getInstance();
applyReloadInterval();
String configContent = encManager.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG));
String pinStr = encManager.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_PIN));
if (configContent == null || configContent.trim().isEmpty()) {
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());
}
Security.addProvider(pkcs11Provider);
keyStore = KeyStore.getInstance("PKCS11", pkcs11Provider);
this.pin = (pinStr != null) ? pinStr.toCharArray() : null;
keyStore.load(null, pin);
logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName());
logKeyStore(this.keyStore);
logger.warn("HsmManager] 초기화 완료. config=" + connection.config.name
+ ", Provider=" + connection.provider.getName());
}
private void logKeyStore(KeyStore keyStore) throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
java.util.Enumeration<String> aliases = keyStore.aliases();
/**
* 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;
}
if (newInterval <= 0) {
newInterval = DEFAULT_RELOAD_INTERVAL_MINUTES;
}
if (newInterval == reloadIntervalMinutes) {
return false;
}
reloadIntervalMinutes = newInterval;
return true;
}
// -------------------------------------------------------------------------
// 연결
// -------------------------------------------------------------------------
/**
* 후보 설정을 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(", ");
@@ -157,7 +402,11 @@ public class HsmManager implements Lifecycle {
}
}
logger.warn("HsmManager] HSM 키 목록: [" + aliasList + "]");
}
}
// -------------------------------------------------------------------------
// 주기적 재로드
// -------------------------------------------------------------------------
/**
* 별도 스레드에서 주기적으로 KeyStore 를 다시 로드하여
@@ -172,12 +421,29 @@ public class HsmManager implements Lifecycle {
reloadFuture = scheduler.scheduleWithFixedDelay(
this::reloadKeyStoreSafely,
RELOAD_INTERVAL_MINUTES,
RELOAD_INTERVAL_MINUTES,
reloadIntervalMinutes,
reloadIntervalMinutes,
TimeUnit.MINUTES
);
logger.warn("HsmManager] KeyStore 주기적 재로드 스케줄러 시작. interval=" + RELOAD_INTERVAL_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 + "");
}
/**
@@ -196,79 +462,181 @@ public class HsmManager implements Lifecycle {
* KeyStore 를 다시 로드한다. 외부(getSecretKey 등)에서 키 미스 발생 시
* 즉시 재시도용으로 직접 호출할 수도 있다.
*
* 세션 누적 방지를 위해 새 KeyStore 인스턴스를 만들지 않고,
* 기존 keyStore 객체에 다시 load() 하여 기존 PKCS11 세션을 재사용한다.
* 연속 실패가 임계치를 넘으면 Provider 자체를 재생성한다.
* 처리 순서:
* 1) PropManager 에서 설정을 매번 새로 읽는다 (변경값 즉시 반영)
* 2) 설정이 변경되었거나 아직 연결이 없으면 primary -> secondary 순으로 전체 재연결
* 3) 현재 secondary 로 동작 중이면 primary 복귀를 먼저 시도
* 4) 그 외에는 기존 keyStore 인스턴스에 다시 load() (PKCS11 세션 재사용).
* 실패하면 primary -> secondary 순으로 전체 재연결
*
* 어느 경로든 신규 연결이 완전히 성공한 경우에만 keyStore 멤버변수를 교체한다.
*/
public synchronized void reloadKeyStoreIfNeeded() throws Exception {
if (pkcs11Provider == null) {
return; // HSM 비활성화 상태
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);
logger.warn("HsmManager] KeyStore 재로드 완료 (기존 세션 재사용).");
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 신규 생성 완료.");
logger.warn("HsmManager] KeyStore 신규 생성 완료 (config=" + active.name + ").");
}
recordReloadSuccess(active.name + " 설정 재로드 성공");
logKeyStore(keyStore);
} catch (Exception e) {
} catch (Exception e) {
logger.warn("HsmManager] KeyStore 재로드 실패:" + e.getMessage(), e);
logger.warn("HsmManager] Provider 전체 재초기화를 시도합니다.");
fullReinitialize();
logger.warn("HsmManager] 후보 설정(PRIMARY→SECONDARY) 전체 재연결을 시도합니다.");
reconnect(candidates);
}
}
/**
* 세션 누적, 네트워크 단절 등으로 일반 재로드가 더 이상 복구되지 않을 때
* 기존 세션을 정리하고 Provider 를 완전히 새로 생성한다.
* 세션 누적, 네트워크 단절, 설정 변경 등으로 일반 재로드가 더 이상 복구되지 않을 때
* primary -> secondary 순서로 Provider 를 완전히 새로 생성한다.
*
* 신규 Provider/KeyStore 준비가 완전히 성공한 후에만 기존 Provider 를 제거하고 교체한다.
* 재초기화 중 예외가 발생하면 기존 Provider 와 keyStore 를 그대로 유지한다
* 모든 후보가 실패하면 기존 Provider 와 keyStore 를 그대로 유지한다
* (서비스 중단보다 마지막 정상 상태 보존을 우선).
*/
private void fullReinitialize() throws Exception {
Provider oldProvider = this.pkcs11Provider;
private void reconnect(List<HsmConfig> candidates) throws Exception {
try {
// 1. 신규 Provider 인스턴스 생성 (Security 미등록 상태)
String configContent = EncryptionManager.getInstance()
.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG));
Provider newProvider = createProvider(configContent.trim().replace("\\n", "\n"));
// 2. 신규 Provider 로 KeyStore 로드 테스트
// KeyStore.getInstance(type, providerInstance) 는 Security 등록 없이도 동작하므로
// 여기서 실패해도 oldProvider/keyStore 는 변경되지 않은 상태를 유지함
KeyStore ks = KeyStore.getInstance("PKCS11", newProvider);
ks.load(null, pin);
// 3. 신규 연결 성공 → 기존 Provider 정리 후 교체
if (oldProvider instanceof AuthProvider) {
try {
((AuthProvider) oldProvider).logout();
} catch (Exception logoutEx) {
logger.warn("HsmManager] 기존 세션 logout 실패(무시하고 진행): " + logoutEx.getMessage());
}
}
Security.removeProvider(oldProvider.getName());
Security.addProvider(newProvider);
this.pkcs11Provider = newProvider;
this.keyStore = ks;
logger.warn("HsmManager] Provider 전체 재초기화 성공.");
HsmConnection connection = connectFirstAvailable(candidates);
applyConnection(connection);
recordReloadSuccess(connection.config.name + " 설정 재연결 성공");
logger.warn("HsmManager] HSM 재연결 성공. config=" + connection.config.name);
} catch (Exception e) {
logger.warn("HsmManager] Provider 전체 재초기화 실패. 이전 keyStore 를 그대로 유지합니다: " + e.getMessage(), 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 를 생성한다.
*
@@ -304,6 +672,8 @@ public class HsmManager implements Lifecycle {
}
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
unregisterPropertyChangeListener();
if (reloadFuture != null) {
reloadFuture.cancel(false);
}
@@ -323,6 +693,8 @@ public class HsmManager implements Lifecycle {
}
pkcs11Provider = null;
keyStore = null;
activeConfig = null;
activeSince = 0;
started = false;
lifecycle.fireLifecycleEvent(STOPPED_EVENT, this);
@@ -375,4 +747,98 @@ public class HsmManager implements Lifecycle {
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;
}
}
@@ -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,11 +1,14 @@
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
@@ -22,6 +25,20 @@ 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()) {
@@ -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;
}
// 업무에러코드에 없을 경우 통신(시스템)에러로 처리함
@@ -214,6 +214,30 @@ public abstract class SessionManager implements Lifecycle {
//Outbound Access Token Cache
public abstract AccessTokenVO getOutboundAccessToken(String key, Function<AccessTokenVO, AccessTokenVO> getNewToken);
/**
* 캐시에 있는 토큰만 조회한다. 없거나 만료됐어도 새로 발급하지 않는다. (상태 조회용)
*
* @param key 어댑터그룹명
* @return 캐시에 있는 토큰. 없으면 null.
*/
public abstract AccessTokenVO peekOutboundAccessToken(String key);
/**
* 사용 중이던 토큰이 거부된 경우 재발급한다.
*
* 분산락 안에서 캐시의 토큰이 oldToken 과 같은지 확인한 뒤에만 발급하므로, 여러 노드가 동시에
* 재발급을 시도해도 실제 발급은 한 번만 일어난다. 발급에 성공하면 캐시에 반영한다.
*
* @param key 어댑터그룹명
* @param oldToken 거부된(또는 갱신 대상인) accessToken 값
* @param validUntilTime 이 시각까지 유효한 토큰이 캐시에 있으면 발급하지 않는다.
* 0 이하면 만료 기준 검사를 하지 않고 oldToken 비교만 한다.
* @param tokenSupplier 실제 발급 처리
* @return 재발급된 토큰. 다른 노드가 이미 갱신했다면 그 토큰.
*/
public abstract AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, long validUntilTime,
Function<AccessTokenVO, AccessTokenVO> tokenSupplier);
public abstract void removeOutboundAccessToken(String key);
public abstract void clearOutboundAccessToken();
@@ -951,6 +951,17 @@ public class SessionManagerForEhcache extends SessionManager {
throw new UnsupportedOperationException();
}
@Override
public AccessTokenVO peekOutboundAccessToken(String key) {
throw new UnsupportedOperationException();
}
@Override
public AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, long validUntilTime,
Function<AccessTokenVO, AccessTokenVO> tokenSupplier) {
throw new UnsupportedOperationException();
}
@Override
public void removeOutboundAccessToken(String key) {
throw new UnsupportedOperationException();
@@ -1,6 +1,7 @@
package com.eactive.eai.common.session;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
@@ -44,6 +45,9 @@ import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi;
import org.apache.ignite.spi.discovery.tcp.ipfinder.kubernetes.TcpDiscoveryKubernetesIpFinder;
import org.apache.ignite.spi.discovery.tcp.ipfinder.multicast.TcpDiscoveryMulticastIpFinder;
import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder;
import org.apache.ignite.transactions.Transaction;
import org.apache.ignite.transactions.TransactionConcurrency;
import org.apache.ignite.transactions.TransactionIsolation;
import com.eactive.eai.adapter.socket2.common.Env;
import com.eactive.eai.authserver.service.BearerTokenInfo;
@@ -72,7 +76,12 @@ import ch.qos.logback.classic.Level;
public class SessionManagerForIgnite extends SessionManager {
private static final String DISTRIBUTED_TOKEN_LOCK = "DISTRIBUTED_TOKEN_LOCK";
/** 아웃바운드 토큰 분산락 대기 시간(초). 스케줄러 경로에서 사용한다. */
private static final int TOKEN_LOCK_WAIT_SECONDS = 60;
/** 거래 중 재발급은 거래 스레드를 잡으므로 스케줄러 경로보다 짧게 기다린다. */
private static final int REISSUE_LOCK_WAIT_SECONDS = 10;
// evictMaster Instance - single socket 관련
private static IgniteCache<String, String> evictMasterCache = null;
// Socket Session cache 정보
@@ -823,6 +832,120 @@ public class SessionManagerForIgnite extends SessionManager {
public CacheInfoVO getOutboundAccessTokenCache() {
return convert(cacheOutBoundAccessToken);
}
@Override
public AccessTokenVO peekOutboundAccessToken(String key) {
if (cacheOutBoundAccessToken == null) {
return null;
}
return cacheOutBoundAccessToken.get(key);
}
/**
* 아웃바운드 토큰을 primary 노드 기준으로 읽는다.
*
* 일반 get() 은 near 캐시 사본을 돌려줄 수 있어, 다른 노드가 방금 교체한 토큰을 놓칠 수 있다.
* 비관적 트랜잭션 안에서 읽으면 primary 의 값을 보장받는다. 트랜잭션은 읽기 직후 바로 닫는다.
* (발급 HTTP 호출 구간까지 열어두면 장기 트랜잭션이 되어 파티션 맵 교환을 막는다)
*
* @param key 어댑터그룹명
* @return 캐시에 있는 토큰. 없으면 null.
*/
private AccessTokenVO readFromPrimary(String key) {
try (Transaction tx = manager.transactions().txStart(TransactionConcurrency.PESSIMISTIC,
TransactionIsolation.REPEATABLE_READ)) {
AccessTokenVO token = cacheOutBoundAccessToken.get(key);
tx.commit();
return token;
} catch (Throwable e) {
logger.error("occuring exception in readFromPrimary. key=" + key, e);
return cacheOutBoundAccessToken.get(key);
}
}
/** 토큰으로 쓸 수 있는 값인지 (빈 토큰은 발급 실패로 본다) */
private boolean isUsableToken(AccessTokenVO token) {
return token != null && StringUtils.isNotBlank(token.getAccessToken());
}
/**
* 캐시의 토큰이 이미 갱신된 것이라 다시 발급할 필요가 없는지 판단한다.
*
* 값이 바뀌었는지(oldToken 비교)와, 요구하는 시각까지 유효한지를 함께 본다.
* 값 비교만 하면 서로 다른 시점에 조회한 노드들이 각자 발급할 수 있다.
*
* @param token 캐시에 있는 토큰
* @param oldToken 갱신 대상으로 보고 들어온 accessToken 값
* @param validUntilTime 이 시각까지 유효하면 갱신 불필요. 0 이하면 만료 기준 검사 생략.
* @return 갱신이 필요 없으면 true
*/
private boolean isAlreadyReissued(AccessTokenVO token, String oldToken, long validUntilTime) {
if (!isUsableToken(token)) {
return false;
}
if (validUntilTime > 0 && token.getExpiration() != null
&& !token.getExpiration().before(new Date(validUntilTime))) {
// 다른 노드가 넣어둔 토큰이 요구 시각까지 유효하다.
return true;
}
return !StringUtils.equals(token.getAccessToken(), oldToken);
}
@Override
public AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, long validUntilTime,
Function<AccessTokenVO, AccessTokenVO> tokenSupplier) {
AccessTokenVO token = readFromPrimary(key);
// 락을 잡기 전에 먼저 확인한다. 다른 노드가 이미 갱신했으면 그 토큰을 쓴다.
if (isAlreadyReissued(token, oldToken, validUntilTime)) {
return token;
}
// 어댑터그룹별로 락을 잡는다. 거래 스레드에서 호출되므로 대기 시간을 짧게 둔다.
Lock lock = cacheOutBoundAccessToken.lock(key);
boolean acquired = false;
try {
acquired = lock.tryLock(REISSUE_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
if (!acquired) {
logger.warn("reissueOutboundAccessToken lock timeout. key=" + key);
return token;
}
// 락 획득 사이에 다른 노드가 갱신했을 수 있으므로 다시 확인한다.
// near 캐시 사본은 무효화가 늦을 수 있어 그냥 get() 하면 이미 교체된 옛 토큰을 읽는다.
// 짧은 비관적 트랜잭션으로 읽어 primary 의 값을 보장받는다. (발급 구간까지 끌고 가지 않는다)
token = readFromPrimary(key);
if (isAlreadyReissued(token, oldToken, validUntilTime)) {
logger.debug("reissueOutboundAccessToken already reissued by another node. key=" + key);
return token;
}
AccessTokenVO newToken = tokenSupplier.apply(token);
if (isUsableToken(newToken)) {
cacheOutBoundAccessToken.put(key, newToken);
return newToken;
}
logger.warn("reissueOutboundAccessToken got empty token. not cached. key=" + key);
return newToken;
} catch (Throwable e) {
logger.error("occuring exception in reissueOutboundAccessToken. key=" + key, e);
return token;
} finally {
try {
if (acquired) {
lock.unlock();
}
} catch (Throwable e) {
logger.error("occuring exception in reissueOutboundAccessToken unlock fail.", e);
}
}
}
@Override
public void putWebSocketTimeout(String key, SessionVO value) {
@@ -909,13 +1032,14 @@ public class SessionManagerForIgnite extends SessionManager {
// 2. 만료되었거나 없다면 락 획득 시도
if (token == null || token.isExpired()) {
Lock lock = cacheOutBoundAccessToken.lock(DISTRIBUTED_TOKEN_LOCK); // Ignite 분산 락
// 어댑터그룹별로 락을 잡는다. 전역 락 하나를 쓰면 모든 그룹의 토큰 처리가 직렬화된다.
Lock lock = cacheOutBoundAccessToken.lock(key); // Ignite 분산 락
boolean acquired = false;
try {
logger.debug("calling func getOutboundAccessToken = distributed ignite trylock");
acquired = lock.tryLock(60, TimeUnit.SECONDS);
acquired = lock.tryLock(TOKEN_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
if ( acquired ) {
logger.debug("calling func getOutboundAccessToken = distributed ignite trylock success=>"+acquired);
@@ -930,7 +1054,13 @@ public class SessionManagerForIgnite extends SessionManager {
logger.debug(String.format("getting token is null or expired borrow new token =%s", token));
cacheOutBoundAccessToken.put(key, token);
// 빈 토큰은 캐시하지 않는다. 발급 구현체가 실패를 삼키고 빈 토큰을 반환하는 경우가 있는데,
// 그게 캐시되면 만료시각이 없어 isExpired() 가 계속 false 라 재발급되지 않는다.
if (token != null && token.getAccessToken() != null && token.getAccessToken().trim().length() > 0) {
cacheOutBoundAccessToken.put(key, token);
} else {
logger.warn("Empty access token is not cached. key=" + key);
}
}
}
else
@@ -958,13 +1088,13 @@ public class SessionManagerForIgnite extends SessionManager {
@Override
public void removeOutboundAccessToken(String key) {
Lock lock = cacheOutBoundAccessToken.lock(DISTRIBUTED_TOKEN_LOCK); // Ignite 분산 락
Lock lock = cacheOutBoundAccessToken.lock(key); // Ignite 분산 락 (어댑터그룹별)
boolean acquired = false;
try {
logger.debug("calling func removeOutboundAccessToken = distributed ignite trylock");
acquired = lock.tryLock(60, TimeUnit.SECONDS);
acquired = lock.tryLock(TOKEN_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
if( acquired ) {
if (logger.isDebug()) {
@@ -2,10 +2,8 @@ package com.eactive.eai.common.util;
import java.util.ArrayList;
import java.util.Arrays;
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;
@@ -63,13 +61,17 @@ public class HttpAdapterExtraLogUtil {
httpAdapterExtraLogVo.setHttpMethod(httpMethod);
for (HttpAdapterExtraHeaderVo httpAdapterExtraHeaderVo : headerVoList) {
// String name = httpAdapterExtraHeaderVo.getName();
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(StringUtils.isNotBlank(value) && value.length() > MAX_HEADER_VALUE_SIZE) {
value = value.substring(0, 400) + "...";
value = value.substring(0, MAX_HEADER_VALUE_SIZE) + "...";
httpAdapterExtraHeaderVo.setValue(value);
}else if(value == null){
httpAdapterExtraHeaderVo.setValue(" ");
@@ -116,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()))
@@ -5,12 +5,17 @@ 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;
@@ -35,10 +40,134 @@ public final class JacksonUtil {
private static final Pattern TOKEN_PATTERN = Pattern.compile("([^\\[\\]]*)((?:\\[\\d+\\])*)");
private static final Pattern INDEX_PATTERN = Pattern.compile("\\[(\\d+)\\]");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
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() {
// 인스턴스화 방지
}
@@ -77,7 +206,9 @@ public final class JacksonUtil {
return null;
}
return objectMapper.readTree(jsonStr);
// 상대 시스템이 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다.
// 이미 표준을 지킨 JSON 이면 원본을 그대로 반환하므로 사실상 무해하다.
return objectMapper.readTree(escapeControlChars(jsonStr));
}
public static JsonNode readTree(Object jsonData) throws JsonMappingException, JsonProcessingException {
@@ -240,7 +371,9 @@ public final class JacksonUtil {
if (target == null || !target.isArray() || lastIdx < 0 || lastIdx >= target.size()) {
return false;
}
((ArrayNode) target).set(lastIdx, value);
// 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 기준 재설정 필요)
@@ -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) {
+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 = "";
@@ -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,62 @@
package com.eactive.eai.manage.oauthtoken;
import java.nio.charset.StandardCharsets;
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.MediaType;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 아웃바운드 OAuth 토큰 현황 조회 API. (HsmStatusController 와 동일한 응답 형태)
*
* 조회 전용이며 토큰을 발급하거나 캐시를 건드리지 않는다.
* accessToken 은 앞 8자만 남기고 마스킹하며 clientSecret 은 응답에 담지 않는다.
*
* GET /manage/oauth-token/status → 등록된 전체 어댑터그룹의 토큰 현황
* GET /manage/oauth-token/status/{adapterGroupName} → 어댑터그룹 하나의 토큰 현황
*/
@RestController
@RequestMapping("/manage/oauth-token")
public class OAuthTokenStatusController {
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
@Autowired
private OAuthTokenStatusService oAuthTokenStatusService;
@GetMapping("/status")
public ResponseEntity<?> status() {
return respond(() -> {
List<OAuthTokenStatusDTO> list = oAuthTokenStatusService.getStatusList();
Map<String, Object> data = new HashMap<>();
data.put("count", list.size());
data.put("tokens", list);
return data;
});
}
@GetMapping("/status/{adapterGroupName}")
public ResponseEntity<?> status(@PathVariable("adapterGroupName") String adapterGroupName) {
return respond(() -> oAuthTokenStatusService.getStatus(adapterGroupName));
}
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,99 @@
package com.eactive.eai.manage.oauthtoken;
import java.util.List;
import java.util.Map;
import com.eactive.eai.common.authoutbound.TokenIssueHistory;
import lombok.Data;
/**
* 어댑터그룹별 아웃바운드 OAuth 토큰 현황.
*
* accessToken 값은 마스킹해서 담는다. clientSecret 은 담지 않는다.
*/
@Data
public class OAuthTokenStatusDTO {
// ---- 등록 정보 ----
/** 어댑터그룹명 */
String adapterGroupName;
/** OAuth 인증 정보 사용 여부 (Y/N) */
String useYn;
/**
* 스케줄러가 토큰을 발급할 때 사용하는 구현체 클래스명.
*
* AccessTokenManagerByDB.issueToken() 이 그룹의 어댑터 중 getAdapters().next() 하나만 골라
* 그 어댑터의 ADAPTER_TOKEN_ISSUING_CLIENT_TYPE 을 쓰므로 여기서도 같은 방식으로 조회한다.
*/
String tokenServiceClass;
/**
* 그룹에 속한 어댑터별 ADAPTER_TOKEN_ISSUING_CLIENT_TYPE. (어댑터명 → 클래스명)
*
* 어댑터마다 값이 다르면 스케줄러가 쓰는 구현체와 거래 중 재발급(retryAccessTokenVO) 때
* 쓰이는 구현체가 달라진다. 그 불일치를 확인하기 위한 항목이다.
*
* 어댑터그룹 자체를 찾지 못하면 null, 그룹은 있으나 이 서버에 배정된 어댑터가 없으면 빈 값이다.
*/
Map<String, String> adapterTokenServiceClasses;
/** 토큰 발급 URL. DB(OutboundOAuthCredentialVo)에 설정된 원본이라 상대 경로일 수 있다. */
String tokenUrl;
/**
* 실제로 호출되는 토큰 발급 URL.
*
* tokenUrl 이 절대 URL 이 아니면 어댑터 속성 URL 뒤에 붙여 호출하므로(구현체들의 appendPath 처리),
* 같은 방식으로 조합한 값이다. 어댑터 속성을 찾지 못하면 null.
*/
String tokenUrlResolved;
/** 토큰 재발급 주기(초) */
int intervalSec;
// ---- 캐시 상태 ----
/** 캐시에 토큰이 있는지 */
boolean cached;
/** 캐시된 토큰이 만료됐는지 */
boolean expired;
/** 만료 시각 */
String expiration;
/** 만료까지 남은 초. 이미 만료됐으면 음수. */
Long remainSec;
// ---- 토큰 정보 ----
/** 앞 8자만 남기고 마스킹한 accessToken */
String accessTokenMasked;
/** accessToken 전체 길이 */
Integer accessTokenLength;
String tokenType;
String scope;
String clientId;
/** 조회 불가 사유 등 부가 메시지 */
String message;
/** 이 응답을 만든 서버. 발급 이력이 노드 로컬이라 어느 노드인지 함께 알려준다. */
String serverName;
/**
* 토큰 발급 이력 (최근 순).
*
* 이 노드에서 일어난 발급만 담기며 재기동 시 사라진다.
* 단건 조회는 보관 중인 전체(최대 10건), 목록 조회는 최근 1건만 담는다.
*/
List<TokenIssueHistory> issueHistory;
}
@@ -0,0 +1,320 @@
package com.eactive.eai.manage.oauthtoken;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.stereotype.Service;
import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
import com.eactive.eai.adapter.AdapterPropManager;
import com.eactive.eai.adapter.AdapterVO;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB;
import com.eactive.eai.common.authoutbound.TokenIssueHistory;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.Logger;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* 아웃바운드 OAuth 토큰 현황 조회 서비스.
*
* 캐시에 있는 토큰만 읽는다(peek). 조회 때문에 토큰이 새로 발급되지 않도록
* AccessTokenManagerByDB.getAccessTokenVO() 가 아니라 peekAccessTokenVO() 를 쓴다.
*
* accessToken 은 앞 8자만 남기고 마스킹하며 clientSecret 은 담지 않는다.
*/
@Service
public class OAuthTokenStatusService {
private static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
/** 토큰 발급 구현체 클래스명을 담고 있는 어댑터 속성 키 */
private static final String PROP_TOKEN_SERVICE_CLASS = "ADAPTER_TOKEN_ISSUING_CLIENT_TYPE";
/** accessToken 중 노출할 앞자리 수 */
private static final int UNMASKED_LENGTH = 8;
/** 목록 조회에 담을 발급 이력 건수 */
private static final int LIST_HISTORY_SIZE = 1;
/**
* 등록된 모든 어댑터그룹의 토큰 현황을 반환한다.
*
* @return 어댑터그룹명 순으로 정렬된 현황 목록
*/
public List<OAuthTokenStatusDTO> getStatusList() {
List<OAuthTokenStatusDTO> result = new ArrayList<OAuthTokenStatusDTO>();
for (String adapterGroupName : AccessTokenManagerByDB.getInstance().getRegisteredAdapterGroupNames()) {
// 목록에는 최근 1건만 담는다. 전체를 담으면 그룹 수만큼 응답이 커진다.
result.add(getStatus(adapterGroupName, LIST_HISTORY_SIZE));
}
return result;
}
/**
* 어댑터그룹 하나의 토큰 현황을 발급 이력 전체와 함께 반환한다.
*
* @param adapterGroupName 어댑터그룹명
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
*/
public OAuthTokenStatusDTO getStatus(String adapterGroupName) {
return getStatus(adapterGroupName, Integer.MAX_VALUE);
}
/**
* 어댑터그룹 하나의 토큰 현황을 반환한다.
*
* @param adapterGroupName 어댑터그룹명
* @param historyLimit 담을 발급 이력 건수 (최근 순)
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
*/
private OAuthTokenStatusDTO getStatus(String adapterGroupName, int historyLimit) {
OAuthTokenStatusDTO dto = new OAuthTokenStatusDTO();
dto.setAdapterGroupName(adapterGroupName);
AccessTokenManagerByDB manager = AccessTokenManagerByDB.getInstance();
// 이력은 노드 로컬이므로 어느 노드가 응답했는지 함께 담는다.
dto.setServerName(findLocalServerName());
dto.setIssueHistory(latest(manager.getIssueHistories(adapterGroupName), historyLimit));
OutboundOAuthCredentialVo credential = manager.getOutboundOAuthCredentialVo(adapterGroupName);
if (credential == null) {
dto.setMessage("등록된 OAuth 인증 정보가 없습니다.");
return dto;
}
dto.setUseYn(credential.getUseYn());
dto.setTokenUrl(credential.getUrl());
dto.setIntervalSec(credential.getIntervalSec());
// 스케줄러가 고르는 어댑터의 속성에서 발급 구현체와 기준 URL 을 함께 읽는다.
Properties schedulerAdapterProp = findSchedulerAdapterProperties(adapterGroupName);
if (schedulerAdapterProp != null) {
dto.setTokenServiceClass(schedulerAdapterProp.getProperty(PROP_TOKEN_SERVICE_CLASS));
dto.setTokenUrlResolved(resolveTokenUrl(schedulerAdapterProp.getProperty("URL"), credential.getUrl()));
}
Map<String, String> adapterClasses = findAdapterTokenServiceClasses(adapterGroupName);
dto.setAdapterTokenServiceClasses(adapterClasses);
// 확인이 필요한 상태는 모아서 message 하나로 돌려준다.
List<String> notes = new ArrayList<String>();
// init() 은 useYn 과 무관하게 인증 정보를 모두 등록하지만 startToken() 은 Y 일 때만 스케줄을 건다.
if (!"Y".equalsIgnoreCase(credential.getUseYn())) {
notes.add("OAuth 인증 정보가 사용 안 함(useYn=" + credential.getUseYn()
+ ") 상태라 스케줄러가 토큰을 발급하지 않습니다.");
}
if (adapterClasses == null) {
notes.add("어댑터그룹을 찾을 수 없습니다. 그룹명이 정확한지, 그룹 사용여부가 '사용' 인지 확인하세요.");
} else if (adapterClasses.isEmpty()) {
notes.add("어댑터그룹에 이 서버로 배정된 어댑터가 없어 토큰을 발급할 수 없습니다.");
}
AccessTokenVO accessToken;
try {
accessToken = manager.peekAccessTokenVO(adapterGroupName);
} catch (UnsupportedOperationException e) {
notes.add("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다.");
dto.setMessage(joinNotes(notes));
return dto;
} catch (Exception e) {
if (logger.isWarn()) {
logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e);
}
notes.add("토큰 캐시 조회 실패 : " + e.getMessage());
dto.setMessage(joinNotes(notes));
return dto;
}
if (accessToken == null) {
notes.add("캐시에 토큰이 없습니다.");
dto.setMessage(joinNotes(notes));
return dto;
}
dto.setCached(true);
dto.setExpired(accessToken.isExpired());
dto.setAccessTokenMasked(mask(accessToken.getAccessToken()));
dto.setAccessTokenLength(accessToken.getAccessToken() == null ? 0 : accessToken.getAccessToken().length());
Date expiration = accessToken.getExpiration();
if (expiration != null) {
dto.setExpiration(new SimpleDateFormat(DATE_FORMAT).format(expiration));
dto.setRemainSec((expiration.getTime() - System.currentTimeMillis()) / 1000L);
}
if (accessToken instanceof OAuth2AccessTokenVO) {
OAuth2AccessTokenVO oauth2Token = (OAuth2AccessTokenVO) accessToken;
dto.setTokenType(oauth2Token.getTokenType());
dto.setScope(oauth2Token.getScope());
dto.setClientId(oauth2Token.getClientId());
}
// 만료시각이 없는 토큰은 isExpired() 가 false 라 재발급 대상이 되지 않는다.
// 빈 토큰이 캐시에 들어가면 기동 후 계속 그대로 사용되므로 조회 시 눈에 띄게 알린다.
if (StringUtils.isBlank(accessToken.getAccessToken())) {
notes.add("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다."
+ " 만료시각이 없어 자동 재발급되지 않습니다.");
} else if (expiration == null) {
notes.add("만료시각이 없어 자동 재발급되지 않습니다.");
}
dto.setMessage(joinNotes(notes));
return dto;
}
/**
* 발급 이력에서 최근 건만 잘라낸다. 이력은 이미 최근 순으로 정렬돼 있다.
*
* @param histories 발급 이력
* @param limit 담을 건수
* @return 최근 limit 건
*/
private List<TokenIssueHistory> latest(List<TokenIssueHistory> histories, int limit) {
if (histories.size() <= limit) {
return histories;
}
return new ArrayList<TokenIssueHistory>(histories.subList(0, limit));
}
/**
* 이 응답을 만든 서버명을 찾는다. 발급 이력이 노드 로컬이라 함께 알려준다.
*
* @return 서버명. 조회하지 못하면 null.
*/
private String findLocalServerName() {
try {
return EAIServerManager.getInstance().getLocalServerName();
} catch (Exception e) {
return null;
}
}
/**
* 확인이 필요한 상태 메시지를 하나로 합친다.
*
* @param notes 메시지 목록
* @return 합친 메시지. 없으면 null.
*/
private String joinNotes(List<String> notes) {
if (notes.isEmpty()) {
return null;
}
return StringUtils.join(notes, " ");
}
/**
* 스케줄러가 토큰 발급에 사용하는 어댑터의 속성을 찾는다.
*
* AccessTokenManagerByDB.issueToken() 과 동일하게 getAdapters().next() 로 어댑터 하나를 고른다.
*
* @param adapterGroupName 어댑터그룹명
* @return 어댑터 속성. 찾지 못하면 null.
*/
private Properties findSchedulerAdapterProperties(String adapterGroupName) {
try {
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (gvo == null || !gvo.getAdapters().hasNext()) {
return null;
}
AdapterVO avo = gvo.getAdapters().next();
return AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
} catch (Exception e) {
if (logger.isDebug()) {
logger.debug("어댑터 속성 조회 실패. adapterGroupName : " + adapterGroupName);
}
return null;
}
}
/**
* 실제로 호출되는 토큰 발급 URL 을 만든다.
*
* 구현체들과 동일하게, 절대 URL 이 아니면 어댑터 속성 URL 뒤에 이어 붙인다.
*
* @param adapterUrl 어댑터 속성의 URL
* @param tokenUrl DB 에 설정된 토큰 발급 URL
* @return 조합된 URL. 만들 수 없으면 null.
*/
String resolveTokenUrl(String adapterUrl, String tokenUrl) {
if (StringUtils.isBlank(tokenUrl)) {
return null;
}
if (UrlUtils.isAbsoluteUrl(tokenUrl)) {
return tokenUrl;
}
if (StringUtils.isBlank(adapterUrl)) {
return null;
}
if (!adapterUrl.endsWith("/") && !tokenUrl.startsWith("/")) {
return adapterUrl + "/" + tokenUrl;
}
if (adapterUrl.endsWith("/") && tokenUrl.startsWith("/")) {
return adapterUrl + tokenUrl.substring(1);
}
return adapterUrl + tokenUrl;
}
/**
* 그룹에 속한 어댑터별로 토큰 발급 구현체 클래스명을 모은다.
*
* 스케줄러(issueToken)는 getAdapters().next() 로 어댑터 하나만 골라 쓰고, 거래 중 재발급
* (retryAccessTokenVO)은 호출한 어댑터 자신의 속성을 쓴다. 어댑터마다 설정이 다르면
* 서로 다른 구현체가 동작하므로 전체를 보여준다.
*
* @param adapterGroupName 어댑터그룹명
* @return 어댑터명 → 구현체 클래스명. 어댑터그룹 자체를 찾지 못하면 null, 어댑터가 없으면 빈 Map.
*/
private Map<String, String> findAdapterTokenServiceClasses(String adapterGroupName) {
Map<String, String> result = new LinkedHashMap<String, String>();
try {
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (gvo == null) {
return null;
}
Iterator<AdapterVO> adapters = gvo.getAdapters();
while (adapters.hasNext()) {
AdapterVO avo = adapters.next();
Properties properties = AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
result.put(avo.getName(), properties.getProperty(PROP_TOKEN_SERVICE_CLASS));
}
} catch (Exception e) {
if (logger.isDebug()) {
logger.debug("어댑터별 토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName);
}
return null;
}
return result;
}
/**
* accessToken 앞 8자만 남기고 마스킹한다.
*
* @param accessToken 토큰 값
* @return 마스킹된 토큰 값
*/
private String mask(String accessToken) {
if (StringUtils.isBlank(accessToken)) {
return null;
}
if (accessToken.length() <= UNMASKED_LENGTH) {
return StringUtils.repeat('*', accessToken.length());
}
return StringUtils.substring(accessToken, 0, UNMASKED_LENGTH) + "***";
}
}
@@ -294,7 +294,59 @@ public class StandardItem implements Serializable, Cloneable {
public void setHidden(boolean isHidden) {
this.isHidden = isHidden;
}
/**
* 조건부 블록의 refValue 와 실제 필드값을 비교한다.
* FlatReader / StandardMessageCoordinator 와 동일한 기준을 쓰기 위한 공용 판정 로직이다.
* - '!' 접두사 : NOT 조건 (예: !S → S가 아닌 경우)
* - '|' 구분자 : OR 조건 (예: NM|EM → NM 또는 EM인 경우)
* - '!' 와 '|' 조합 가능 (예: !NM|EM → NM도 EM도 아닌 경우)
*/
public static boolean matchRefCondition(String refValue, String actualValue) {
if(refValue == null) return false;
String expect = refValue;
boolean negate = expect.startsWith("!");
if(negate) {
expect = expect.substring(1);
}
String actual = (actualValue == null) ? null : actualValue.trim();
boolean matched = false;
String[] expectValues = expect.split("\\|");
for(int i=0; i<expectValues.length; i++) {
if(expectValues[i].equals(actual)) {
matched = true;
break;
}
}
return negate ? !matched : matched;
}
/**
* FLAT 직렬화 시 이 GROUP 블록의 바이트를 출력할지 판단한다.
* FlatReader.traverse() 의 GROUP 처리와 같은 기준을 쓰기 위한 것으로,
* ref 조건이 성립하면 size==0(전문에 블록이 없어 미활성) 이어도 레이아웃 기본값으로 출력한다.
* 조건이 성립하는데 출력하지 않으면, 파서는 블록이 있다고 보고 읽어 오프셋이 어긋난다.
*
* @param root 조건 평가 기준이 되는 최상위 메시지. null 이면 조건 평가 없이 기존 규칙만 적용
* @param parentActivated 조건 성립으로 활성화된 상위 GROUP 하위인지 여부
*/
protected boolean isFlatGroupActive(StandardMessage root, boolean parentActivated) {
if(isHidden) return false;
if(getSize() > 0) return true;
if(root != null
&& StringUtils.isNotBlank(getRefPath())
&& StringUtils.isNotBlank(getRefValue())
&& matchRefCondition(getRefValue(), root.findItemValue(getRefPath())) ) {
if(logger.isDebugEnabled()) {
logger.debug("@GROUP name={} ACTIVATED by ref condition({}=[{}]). size=0 이지만 FLAT 출력 대상"
, getName(), getRefPath(), getRefValue());
}
return true;
}
// 조건 성립으로 활성화된 상위 GROUP 하위의 무조건 블록은 함께 출력한다.
return parentActivated;
}
protected String toTypeValue(String svalue) {
if(svalue == null) return svalue;
@@ -1007,6 +1059,15 @@ public class StandardItem implements Serializable, Cloneable {
}
public int getBytesDataLength(String charset) {
return getBytesDataLength(charset, null, false);
}
/**
* @param root 조건부 블록(refPath/refValue) 평가 기준이 되는 최상위 메시지
* @param parentActivated 조건 성립으로 활성화된 상위 GROUP 하위인지 여부
* @see #isFlatGroupActive(StandardMessage, boolean)
*/
public int getBytesDataLength(String charset, StandardMessage root, boolean parentActivated) {
int totalSize = 0;
Iterator<String> keyIter = null;
try {
@@ -1016,24 +1077,26 @@ public class StandardItem implements Serializable, Cloneable {
while(keyIter.hasNext()) {
String key = keyIter.next();
StandardItem item = childs.get(key);
totalSize +=item.getBytesDataLength(charset);
totalSize +=item.getBytesDataLength(charset, root, false);
}
break;
case StandardType.FIELD :
totalSize += getLength();
break;
case StandardType.GROUP :
// skip inactive/conditional group (size==0 means not activated)
if( getSize() == 0 ) {
// ref 조건이 성립하면 size==0 이어도 출력한다(FlatReader 와 동일 기준)
if( !isFlatGroupActive(root, parentActivated) ) {
break;
}
if (isHidden) break;
// 출력하는 GROUP 의 하위 무조건 블록은 함께 출력한다.
// FlatReader 는 이 블록을 조건 없이 읽으므로 빼면 오프셋이 어긋난다.
boolean childActivated = true;
keyIter = childs.keySet().iterator();
while(keyIter.hasNext()) {
String key = keyIter.next();
StandardItem item = childs.get(key);
totalSize +=item.getBytesDataLength(charset);
}
totalSize +=item.getBytesDataLength(charset, root, childActivated);
}
break;
case StandardType.GRID :
if( getSize() == 0
@@ -1044,14 +1107,14 @@ public class StandardItem implements Serializable, Cloneable {
break;
}
if (isHidden) break;
for(int p=0; p<list.size(); p++) {
for(int p=0; p<list.size(); p++) {
LinkedHashMap<String , StandardItem> group = list.get(p);
keyIter = group.keySet().iterator();
while(keyIter.hasNext()) {
String key = keyIter.next();
StandardItem item = group.get(key);
totalSize +=item.getBytesDataLength(charset);
}
totalSize +=item.getBytesDataLength(charset, root, false);
}
}
break;
case StandardType.FARRAY :
@@ -1086,8 +1149,17 @@ public class StandardItem implements Serializable, Cloneable {
return toByteArray(true, charset);
}
public byte[] toByteArray(boolean withBizData, String charset) {
return toByteArray(withBizData, charset, null, false);
}
/**
* @param root 조건부 블록(refPath/refValue) 평가 기준이 되는 최상위 메시지
* @param parentActivated 조건 성립으로 활성화된 상위 GROUP 하위인지 여부
* @see #isFlatGroupActive(StandardMessage, boolean)
*/
public byte[] toByteArray(boolean withBizData, String charset, StandardMessage root, boolean parentActivated) {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Iterator<String> keyIter = null;
Iterator<String> keyIter = null;
// logger.debug("Encoding : {}", charset);
try {
switch(getType()) {
@@ -1096,7 +1168,7 @@ public class StandardItem implements Serializable, Cloneable {
while(keyIter.hasNext()) {
String key = keyIter.next();
StandardItem item = childs.get(key);
bos.write(item.toByteArray(withBizData,charset)); //하위로 내려갈때 withBizDat 값이 없어져서 추가 jun's 20231101
bos.write(item.toByteArray(withBizData, charset, root, false)); //하위로 내려갈때 withBizDat 값이 없어져서 추가 jun's 20231101
}
break;
case StandardType.FIELD :
@@ -1118,17 +1190,19 @@ public class StandardItem implements Serializable, Cloneable {
}
break;
case StandardType.GROUP :
// skip inactive/conditional group (size==0 means not activated)
if( getSize() == 0 ) {
// ref 조건이 성립하면 size==0 이어도 출력한다(FlatReader 와 동일 기준)
if( !isFlatGroupActive(root, parentActivated) ) {
break;
}
if (isHidden) break;
// 출력하는 GROUP 의 하위 무조건 블록은 함께 출력한다.
// FlatReader 는 이 블록을 조건 없이 읽으므로 빼면 오프셋이 어긋난다.
boolean childActivated = true;
keyIter = childs.keySet().iterator();
while(keyIter.hasNext()) {
String key = keyIter.next();
StandardItem item = childs.get(key);
bos.write(item.toByteArray(withBizData,charset)); //하위로 내려갈때 withBizDat 값이 없어져서 추가 jun's 20231101
}
bos.write(item.toByteArray(withBizData, charset, root, childActivated)); //하위로 내려갈때 withBizDat 값이 없어져서 추가 jun's 20231101
}
break;
case StandardType.GRID :
if( getSize() == 0
@@ -1139,14 +1213,14 @@ public class StandardItem implements Serializable, Cloneable {
break;
}
if (isHidden) break;
for(int p=0; p<list.size(); p++) {
for(int p=0; p<list.size(); p++) {
LinkedHashMap<String , StandardItem> group = list.get(p);
keyIter = group.keySet().iterator();
while(keyIter.hasNext()) {
String key = keyIter.next();
StandardItem item = group.get(key);
bos.write(item.toByteArray(withBizData, charset)); //하위로 내려갈때 withBizDat 값이 없어져서 추가 jun's 20231101
}
bos.write(item.toByteArray(withBizData, charset, root, false)); //하위로 내려갈때 withBizDat 값이 없어져서 추가 jun's 20231101
}
}
break;
case StandardType.FARRAY :
@@ -461,7 +461,8 @@ public class StandardMessage extends StandardItem {
while(keyIter.hasNext()) {
String key = keyIter.next();
StandardItem item = childs.get(key);
bos.write(item.toByteArray(withBizData, charset));
// this 를 root 로 넘겨 조건부 블록(refPath/refValue)을 평가하게 한다
bos.write(item.toByteArray(withBizData, charset, this, false));
}
return bos.toByteArray();
} catch(Exception e) {
@@ -490,7 +491,7 @@ public class StandardMessage extends StandardItem {
while(keyIter.hasNext()) {
String key = keyIter.next();
StandardItem item = childs.get(key);
totalSize +=item.getBytesDataLength(charset);
totalSize +=item.getBytesDataLength(charset, this, false);
}
return totalSize;
} catch(Exception e) {
@@ -516,7 +517,7 @@ public class StandardMessage extends StandardItem {
while (keyIter.hasNext()) {
String key = keyIter.next();
StandardItem item = find.childs.get(key);
totalSize += item.getBytesDataLength(this.bizDataCharset);
totalSize += item.getBytesDataLength(this.bizDataCharset, this, false);
}
return totalSize;
} catch (Exception e) {
@@ -121,24 +121,15 @@ public class FlatReader implements StandardReader {
logger.debug("@GROUP name={}, getRefPath={}, refItemValue=[{}], getRefValue=[{}]"
, currentItem.getName(), currentItem.getRefPath(), refItemValue, currentItem.getRefValue());
if(refItemValue != null) refItemValue = refItemValue.trim();
/*
* 값의 경우의수 NM,EM,""(blank)
* ! : NOT 조건 (: !EM EM이 아닌 경우)
* | : OR 조건 (: NM|EM NM 또는 EM인 경우)
* ! | 조합 가능 (: !NM|EM NM도 아니고 EM도 아닌 경우)
* 판정 기준은 StandardItem.matchRefCondition 하나로 통일한다.
* (FLAT 직렬화 StandardItem.isFlatGroupActive 같은 기준이어야 오프셋이 어긋나지 않음)
*/
String refValue = currentItem.getRefValue();
boolean isNot = refValue.startsWith("!");
if (isNot) {
refValue = refValue.substring(1);
}
boolean matched = matchRefValue(refValue, refItemValue);
if (isNot) {
matched = !matched;
}
boolean matched = StandardItem.matchRefCondition(currentItem.getRefValue(), refItemValue);
if (!matched) {
logger.warn("@GROUP name={} SKIPPED.", currentItem.getName());
@@ -307,21 +298,4 @@ public class FlatReader implements StandardReader {
}
return refItemValue;
}
/**
* refValue와 refItemValue를 비교하여 일치 여부를 반환
* | 구분자로 OR 조건 지원 (: "NM|EM" "NM" 또는 "EM" 일치하면 true)
*/
private boolean matchRefValue(String refValue, String refItemValue) {
if (refValue.contains("|")) {
String[] values = refValue.split("\\|");
for (String val : values) {
if (val.equals(refItemValue)) {
return true;
}
}
return false;
}
return refValue.equals(refItemValue);
}
}
@@ -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;
}
@@ -110,8 +119,21 @@ public class JsonReader implements StandardReader {
switch(currentNode.getNodeType()) {
case OBJECT:
if( currentItem != null && currentItem.getSize() == 0
&& StringUtils.isNotBlank(currentItem.getRefPath())
// refPath/refValue "이 블록이 전문에 들어있는가" 판단하기 위한 조건이다.
// 고정길이(FLAT) 블록의 존재를 알려주는 키가 없어 조건이 유일한 근거지만,
// JSON 키의 존재 자체가 답이다. 여기에 도달했다는 것은 수신 전문에 블록이
// 실제로 들어있다는 뜻이므로, 조건으로 다시 걸러내면 받은 데이터를 잃는다.
//
// 실제 사고(2026-09): 상대가 오류응답을 procs_rslt_dvcd=F 보내면서 요청응답구분
// dman_rspn_dvcd 요청값 S 그대로 에코백했다(표준은 응답 R). MSG 블록의
// 조건이 !S MSG 이하가 통째로 버려졌고, 결과
// - MAIN_MSG 레이아웃 기본값으로 남아 오류가 "정상처리되었습니다." 보고되고
// - 거래로그는 StandardMessage 재직렬화본이라 MSG 이후가 통째로 누락됐다.
//
// 조건 불일치는 상대 헤더의 오류로 보고, 경고만 남기고 파싱은 계속한다.
// (FlatReader 조건이 존재 판단에 반드시 필요하므로 그대로 둔다.)
if( currentItem != null && currentItem.getSize() == 0
&& StringUtils.isNotBlank(currentItem.getRefPath())
&& StringUtils.isNotBlank(currentItem.getRefValue()) ) {
String refItemValue = itemMap.get(currentItem.getRefPath());
@@ -122,25 +144,20 @@ public class JsonReader implements StandardReader {
if (refItemValue != null) refItemValue = refItemValue.trim();
String refValue = currentItem.getRefValue();
if (refValue.startsWith("!")) { // 느낌표가 들어가면 다음에 나오는게 아닌경우
boolean negate = refValue.startsWith("!"); // 느낌표가 들어가면 다음에 나오는게 아닌경우
if (negate) {
refValue = refValue.substring(1);
if (refValue.equals(refItemValue)) {
logger.warn("@GROUP name={} SKIPPED.", currentItem.getName());
currentItem.setHidden(true);
break;
} else {
currentItem.setHidden(false);
}
} else {
if (!currentItem.getRefValue().equals(refItemValue)) {
if (logger.isDebugEnabled())
logger.warn("@GROUP name={} SKIPPED.", currentItem.getName());
currentItem.setHidden(true);
break;
} else {
currentItem.setHidden(false);
}
}
boolean matched = refValue.equals(refItemValue);
if (negate) {
matched = !matched;
}
if (!matched) {
logger.warn("@GROUP name={} 조건 불일치({}=[{}], 기대=[{}]). 수신 전문에 존재하므로 파싱은 계속한다.",
currentItem.getName(), currentItem.getRefPath(), refItemValue,
currentItem.getRefValue());
}
currentItem.setHidden(false);
}
Iterator<String> fieldNames = currentNode.fieldNames();
while(fieldNames.hasNext()) {
@@ -20,6 +20,7 @@ import com.eactive.eai.common.header.HeaderActionKeys;
import com.eactive.eai.common.message.EAIMessage;
import com.eactive.eai.common.message.EAIMessageKeys;
import com.eactive.eai.common.message.MessageType;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.routing.Process;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.submessage.SubMessageManager;
@@ -43,6 +44,12 @@ public abstract class DefaultProcess extends Process {
protected static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
protected static Logger esbLogger = Logger.getLogger(Logger.LOGGER_ESBFW);
/** 표준 오류응답 메시지 형식을 조회할 프로퍼티 그룹/키. 없으면 기본형식을 쓴다. */
public static final String PROP_GROUP = "DefaultProcess";
public static final String PROP_STD_ERR_MSG_FORMAT = "std.error.msg.format";
/** 인자 순서: 1=오류코드, 2=오류메시지, 3=오류상세 */
public static final String DEFAULT_STD_ERR_MSG_FORMAT = "[%s] %s";
protected String guidLogPrefix = this.getClass().getSimpleName() + "] ";
protected static ThreadLocal local = new ThreadLocal(); // 경과시간 동기화를 위한 ThreadLocal
@@ -689,7 +696,7 @@ public abstract class DefaultProcess extends Process {
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));
this.resEaiMsg.setRspErr("RECEAIINA001", formatStdErrorMessage(errorCode, errorMsg, errorDesc));
return;
} else {
this.resEaiMsg.setRspErr("RECEAIINA001", "비표준 오류 응답 수신");
@@ -702,23 +709,52 @@ 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", formatStdErrorMessage(errorCode, errorMsg, errorDesc));
} else {
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;
}
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);
}
}
}
/**
* 표준 오류응답 메시지를 조립한다.
*
* 형식은 PropManager {@code DefaultProcess / std.error.msg.format} 에서 가져온다.
* 프로퍼티는 에이전트의 ReloadPropertyCommand 무중단 갱신될 있으므로,
* 캐시하지 않고 호출할 때마다 조회한다. 그룹이나 키가 없으면
* {@link #DEFAULT_STD_ERR_MSG_FORMAT} 쓴다.
*
* 형식 문자열은 운영에서 편집 가능하므로 잘못된 형식(: %s 개수 불일치) 들어올 있다.
* 메서드는 오류응답 경로에서 호출되므로 여기서 예외가 나면 오류 자체를 내려보낸다.
* 따라서 조립에 실패하면 경고만 남기고 기본형식으로 되돌린다.
*
* @param errorCode 오류코드 (형식 인자 1)
* @param errorMsg 오류메시지 (형식 인자 2)
* @param errorDesc 오류상세 (형식 인자 3)
*/
protected String formatStdErrorMessage(String errorCode, String errorMsg, String errorDesc) {
String format = PropManager.getInstance().getProperty(
PROP_GROUP, PROP_STD_ERR_MSG_FORMAT, DEFAULT_STD_ERR_MSG_FORMAT);
if (StringUtils.isBlank(format)) {
format = DEFAULT_STD_ERR_MSG_FORMAT;
}
try {
return String.format(format, errorCode, errorMsg, errorDesc);
} catch (Exception e) {
logger.warn(guidLogPrefix + " 표준 오류응답 형식이 잘못되어 기본형식으로 대체한다. ["
+ PROP_GROUP + "/" + PROP_STD_ERR_MSG_FORMAT + "=" + format + "] - " + e.getMessage());
return String.format(DEFAULT_STD_ERR_MSG_FORMAT, errorCode, errorMsg, errorDesc);
}
}
@@ -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"; // 개발
}
}
@@ -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 파싱 횟수 절감
@@ -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) {
@@ -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);
}
}
@@ -0,0 +1,549 @@
package com.eactive.eai.adapter.http.dynamic.filter;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
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.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
/**
* ReflectHeaderFilter 단위 테스트.
*
* 복사 대상 헤더 목록은 PropManager(그룹: HttpHeaderFilter,
* : ReflectHeaderFilter.whiteList[.어댑터그룹명])에서 조회하므로
* PropManager Mock 으로 등록하고 필터 로직만 검증한다.
*/
@TestMethodOrder(MethodOrderer.DisplayName.class)
class ReflectHeaderFilterTest {
private static final String GRP = "TEST_GRP";
private static final String ADPT = "TEST_ADPT";
private static final String PROP_GROUP = ReflectHeaderFilter.PROPERTIES_GROUP_NAME;
private static final String KEY = ReflectHeaderFilter.HEADER_KEY_NAMES;
private static final String KEY_GRP = KEY + "." + GRP;
private static GenericApplicationContext ctx;
private static PropManager mockPropManager;
private ReflectHeaderFilter 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();
}
@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();
// 미설정 키는 실제 PropManager 동일하게 기본값을 반환한다.
when(mockPropManager.getProperty(anyString(), anyString(), anyString()))
.thenAnswer(inv -> inv.getArgument(2));
// 필터별 캐시가 테스트간에 섞이지 않도록 매번 인스턴스를 사용한다.
filter = new ReflectHeaderFilter();
}
/** 프로퍼티 설정 - 어댑터 그룹 단위 키. */
private void givenGroupWhiteList(String value) {
when(mockPropManager.getProperty(PROP_GROUP, KEY_GRP, "")).thenReturn(value);
}
/** 프로퍼티 설정 - 전역 키. */
private void givenGlobalWhiteList(String value) {
when(mockPropManager.getProperty(PROP_GROUP, KEY, "")).thenReturn(value);
}
/** 수신 요청 헤더 설정. getHeader 는 서블릿 스펙대로 대소문자를 구분하지 않는다. */
private void givenRequestHeaders(String... nameValuePairs) {
Map<String, String> headers = new LinkedHashMap<>();
for (int i = 0; i < nameValuePairs.length; i += 2) {
headers.put(nameValuePairs[i], nameValuePairs[i + 1]);
}
when(mockRequest.getHeader(anyString())).thenAnswer(inv -> {
String wanted = inv.getArgument(0);
for (Map.Entry<String, String> e : headers.entrySet()) {
if (e.getKey().equalsIgnoreCase(wanted)) return e.getValue();
}
return null;
});
// doPreFilter/doPostFilter 각각 순회할 있도록 호출마다 Enumeration 반환한다.
when(mockRequest.getHeaderNames())
.thenAnswer(inv -> Collections.enumeration(new ArrayList<>(headers.keySet())));
}
// =========================================================================
// 1. 허용 목록에 등록된 헤더만 복사
// =========================================================================
@Test
@DisplayName("1-1. 허용 목록에 등록된 헤더만 응답으로 복사한다")
void testWhiteListedHeadersOnly() throws Exception {
givenGroupWhiteList("x-obp-txid, x-elink-client-id");
givenRequestHeaders(
"x-obp-txid", "TX-001",
"x-elink-client-id", "CLIENT-A",
"Authorization", "Bearer secret",
"Cookie", "JSESSIONID=abc");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-obp-txid", "TX-001");
verify(mockResponse).setHeader("x-elink-client-id", "CLIENT-A");
verify(mockResponse, never()).setHeader(eq("Authorization"), anyString());
verify(mockResponse, never()).setHeader(eq("Cookie"), anyString());
verifyNoMoreInteractions(mockResponse);
}
@Test
@DisplayName("1-2. 헤더명 대소문자를 구분하지 않고 매칭한다")
void testCaseInsensitiveMatch() throws Exception {
givenGroupWhiteList("X-OBP-TXID");
givenRequestHeaders("x-obp-txid", "TX-002");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("X-OBP-TXID", "TX-002");
}
@Test
@DisplayName("1-3. 허용 목록에 있어도 수신 요청에 없는 헤더는 복사하지 않는다")
void testAbsentHeaderNotReflected() throws Exception {
givenGroupWhiteList("x-obp-txid, x-not-sent");
givenRequestHeaders("x-obp-txid", "TX-003");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-obp-txid", "TX-003");
verify(mockResponse, never()).setHeader(eq("x-not-sent"), any());
}
@Test
@DisplayName("1-4. 값이 빈 문자열인 헤더도 복사한다")
void testEmptyValueReflected() throws Exception {
givenGroupWhiteList("x-obp-txid");
givenRequestHeaders("x-obp-txid", "");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-obp-txid", "");
}
@Test
@DisplayName("1-5. 목록의 공백/빈 항목은 무시한다")
void testBlankTokensIgnored() throws Exception {
givenGroupWhiteList(" x-obp-txid , , , ");
givenRequestHeaders("x-obp-txid", "TX-005");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-obp-txid", "TX-005");
verifyNoMoreInteractions(mockResponse);
}
// =========================================================================
// 2. 프로퍼티 조회 우선순위 (어댑터 그룹 전역)
// =========================================================================
@Test
@DisplayName("2-1. 어댑터 그룹 키가 있으면 전역 키를 무시한다(override)")
void testGroupKeyOverridesGlobal() throws Exception {
givenGlobalWhiteList("x-global-only");
givenGroupWhiteList("x-group-only");
givenRequestHeaders(
"x-global-only", "G",
"x-group-only", "S");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-group-only", "S");
verify(mockResponse, never()).setHeader(eq("x-global-only"), anyString());
}
@Test
@DisplayName("2-2. 어댑터 그룹 키가 없으면 전역 키를 사용한다")
void testFallbackToGlobalKey() throws Exception {
givenGlobalWhiteList("x-global-only");
givenRequestHeaders("x-global-only", "G");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-global-only", "G");
}
@Test
@DisplayName("2-3. 어댑터 그룹 키가 공백이면 전역 키로 폴백한다")
void testBlankGroupKeyFallsBackToGlobal() throws Exception {
givenGroupWhiteList(" ");
givenGlobalWhiteList("x-global-only");
givenRequestHeaders("x-global-only", "G");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-global-only", "G");
}
@Test
@DisplayName("2-4. 어댑터 그룹마다 다른 목록을 적용한다")
void testPerGroupWhiteList() throws Exception {
when(mockPropManager.getProperty(PROP_GROUP, KEY + ".GRP_A", "")).thenReturn("x-a");
when(mockPropManager.getProperty(PROP_GROUP, KEY + ".GRP_B", "")).thenReturn("x-b");
givenRequestHeaders("x-a", "A", "x-b", "B");
filter.doPreFilter("GRP_A", ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-a", "A");
verify(mockResponse, never()).setHeader(eq("x-b"), anyString());
reset(mockResponse);
filter.doPreFilter("GRP_B", ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-b", "B");
verify(mockResponse, never()).setHeader(eq("x-a"), anyString());
}
@Test
@DisplayName("2-5. 어댑터 그룹명이 null 이어도 전역 키로 동작한다")
void testNullGroupName() throws Exception {
givenGlobalWhiteList("x-global-only");
givenRequestHeaders("x-global-only", "G");
filter.doPreFilter(null, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-global-only", "G");
}
@Test
@DisplayName("2-6. 어댑터명은 프로퍼티 키 조회에 사용하지 않는다")
void testAdapterNameNotUsedInKeyLookup() throws Exception {
givenGroupWhiteList("x-obp-txid");
givenRequestHeaders("x-obp-txid", "TX-006");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockPropManager, never()).getProperty(eq(PROP_GROUP), contains(ADPT), anyString());
}
// =========================================================================
// 3. 미설정 아무 헤더도 복사하지 않음
// =========================================================================
@Test
@DisplayName("3-1. 설정이 없으면 어떤 헤더도 복사하지 않는다")
void testNoConfigReflectsNothing() throws Exception {
givenRequestHeaders("x-obp-txid", "TX-007", "Authorization", "Bearer secret");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verifyNoInteractions(mockResponse);
}
@Test
@DisplayName("3-2. 설정값이 콤마뿐이어도 예외 없이 아무것도 복사하지 않는다")
void testCommaOnlyConfigReflectsNothing() throws Exception {
givenGroupWhiteList(",,,");
givenRequestHeaders("x-obp-txid", "TX-008");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verifyNoInteractions(mockResponse);
}
// =========================================================================
// 4. 접두사(*) 매칭
// =========================================================================
@Test
@DisplayName("4-1. 접두사(*) 설정은 해당 접두사로 시작하는 수신 헤더를 모두 복사한다")
void testPrefixMatch() throws Exception {
givenGroupWhiteList("X-KKB-*");
givenRequestHeaders(
"X-KKB-API-NAME", "transfer",
"X-KKB-API-TX-ID", "TX-009",
"X-OBP-TXID", "OTHER");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("X-KKB-API-NAME", "transfer");
verify(mockResponse).setHeader("X-KKB-API-TX-ID", "TX-009");
verify(mockResponse, never()).setHeader(eq("X-OBP-TXID"), anyString());
}
@Test
@DisplayName("4-2. 접두사 매칭도 대소문자를 구분하지 않는다")
void testPrefixMatchCaseInsensitive() throws Exception {
givenGroupWhiteList("x-kkb-*");
givenRequestHeaders("X-KKB-API-NAME", "transfer");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("X-KKB-API-NAME", "transfer");
}
@Test
@DisplayName("4-3. 완전일치와 접두사를 함께 설정할 수 있다")
void testExactAndPrefixTogether() throws Exception {
givenGroupWhiteList("x-obp-txid, X-KKB-*");
givenRequestHeaders(
"x-obp-txid", "TX-010",
"X-KKB-API-NAME", "transfer",
"Authorization", "Bearer secret");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-obp-txid", "TX-010");
verify(mockResponse).setHeader("X-KKB-API-NAME", "transfer");
verify(mockResponse, never()).setHeader(eq("Authorization"), anyString());
}
@Test
@DisplayName("4-4. 접두사 설정이 없으면 수신 헤더 전체를 순회하지 않는다")
void testNoPrefixSkipsHeaderNamesScan() throws Exception {
givenGroupWhiteList("x-obp-txid");
givenRequestHeaders("x-obp-txid", "TX-011");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockRequest, never()).getHeaderNames();
}
@Test
@DisplayName("4-5. '*' 단독 설정은 접두사가 비어 무시된다")
void testBareAsteriskIgnored() throws Exception {
givenGroupWhiteList("*");
givenRequestHeaders("x-obp-txid", "TX-012");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verifyNoInteractions(mockResponse);
}
@Test
@DisplayName("4-6. getHeaderNames() 가 null 이어도 예외가 발생하지 않는다")
void testNullHeaderNamesEnumeration() throws Exception {
givenGroupWhiteList("X-KKB-*");
when(mockRequest.getHeaderNames()).thenReturn(null);
assertDoesNotThrow(() -> filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse));
verifyNoInteractions(mockResponse);
}
// =========================================================================
// 5. 복사 금지 헤더 / CRLF 방어
// =========================================================================
@Test
@DisplayName("5-1. 응답 프레이밍 헤더는 허용 목록에 있어도 복사하지 않는다")
void testNeverReflectHeaders() throws Exception {
givenGroupWhiteList("Content-Length, Transfer-Encoding, Connection, Keep-Alive, Upgrade, TE, Trailer, x-obp-txid");
givenRequestHeaders(
"Content-Length", "100",
"Transfer-Encoding", "chunked",
"Connection", "keep-alive",
"Keep-Alive", "timeout=5",
"Upgrade", "websocket",
"TE", "trailers",
"Trailer", "Expires",
"x-obp-txid", "TX-013");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-obp-txid", "TX-013");
verifyNoMoreInteractions(mockResponse);
}
@Test
@DisplayName("5-2. 복사 금지 헤더 판정도 대소문자를 구분하지 않는다")
void testNeverReflectCaseInsensitive() throws Exception {
givenGroupWhiteList("content-length");
givenRequestHeaders("content-length", "100");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verifyNoInteractions(mockResponse);
}
@Test
@DisplayName("5-3. 접두사 매칭으로 걸린 헤더도 복사 금지 목록이 우선한다")
void testNeverReflectAppliesToPrefixMatch() throws Exception {
givenGroupWhiteList("Content-*");
givenRequestHeaders("Content-Length", "100", "Content-MD5", "abc");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse, never()).setHeader(eq("Content-Length"), anyString());
verify(mockResponse).setHeader("Content-MD5", "abc");
}
@Test
@DisplayName("5-4. 값에 CR/LF 가 있으면 응답 분할 방지를 위해 복사하지 않는다")
void testCrLfInValueBlocked() throws Exception {
givenGroupWhiteList("x-obp-txid, x-safe");
givenRequestHeaders(
"x-obp-txid", "TX\r\nX-Injected: 1",
"x-safe", "OK");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse, never()).setHeader(eq("x-obp-txid"), anyString());
verify(mockResponse).setHeader("x-safe", "OK");
}
@Test
@DisplayName("5-5. LF 단독, CR 단독 값도 차단한다")
void testLoneCrOrLfBlocked() throws Exception {
givenGroupWhiteList("x-lf, x-cr");
givenRequestHeaders("x-lf", "a\nb", "x-cr", "a\rb");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verifyNoInteractions(mockResponse);
}
// =========================================================================
// 6. doPreFilter / doPostFilter 반환값
// =========================================================================
@Test
@DisplayName("6-1. doPreFilter 는 원 메시지를 그대로 반환한다")
void testDoPreFilterReturnsMessage() throws Exception {
givenGroupWhiteList("x-obp-txid");
givenRequestHeaders("x-obp-txid", "TX-014");
Object message = "request-body";
Object result = filter.doPreFilter(GRP, ADPT, message, prop, mockRequest, mockResponse);
assertSame(message, result);
}
@Test
@DisplayName("6-2. doPostFilter 도 헤더를 복사하고 원 메시지를 그대로 반환한다")
void testDoPostFilterReflectsAndReturnsMessage() throws Exception {
givenGroupWhiteList("x-obp-txid");
givenRequestHeaders("x-obp-txid", "TX-015");
Object message = "response-body";
Object result = filter.doPostFilter(GRP, ADPT, message, prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-obp-txid", "TX-015");
assertSame(message, result);
}
@Test
@DisplayName("6-3. setHeader 사용으로 pre/post 연속 호출 시 헤더가 중복되지 않는다")
void testPreAndPostUseSetHeader() throws Exception {
givenGroupWhiteList("x-obp-txid");
givenRequestHeaders("x-obp-txid", "TX-016");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
filter.doPostFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse, times(2)).setHeader("x-obp-txid", "TX-016");
verify(mockResponse, never()).addHeader(anyString(), anyString());
}
@Test
@DisplayName("6-4. 응답 헤더 설정 중 예외가 발생해도 필터는 메시지를 반환한다")
void testExceptionSwallowed() throws Exception {
givenGroupWhiteList("x-obp-txid");
givenRequestHeaders("x-obp-txid", "TX-017");
doThrow(new IllegalStateException("response already committed"))
.when(mockResponse).setHeader(anyString(), anyString());
Object result = filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
assertEquals("msg", result);
}
// =========================================================================
// 7. 프로퍼티 캐시
// =========================================================================
@Test
@DisplayName("7-1. 같은 설정으로 반복 호출해도 동작이 동일하다(캐시 재사용)")
void testCacheReuse() throws Exception {
givenGroupWhiteList("x-obp-txid");
givenRequestHeaders("x-obp-txid", "TX-018");
for (int i = 0; i < 3; i++) {
reset(mockResponse);
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-obp-txid", "TX-018");
verifyNoMoreInteractions(mockResponse);
}
}
@Test
@DisplayName("7-2. 프로퍼티가 변경되면 다음 호출에 즉시 반영된다")
void testCacheInvalidatedOnPropertyChange() throws Exception {
givenGroupWhiteList("x-first");
givenRequestHeaders("x-first", "F", "x-second", "S");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-first", "F");
reset(mockResponse);
givenGroupWhiteList("x-second");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-second", "S");
verify(mockResponse, never()).setHeader(eq("x-first"), anyString());
}
@Test
@DisplayName("7-3. 설정이 제거되면 다음 호출부터 아무것도 복사하지 않는다")
void testCacheInvalidatedOnPropertyRemoval() throws Exception {
givenGroupWhiteList("x-obp-txid");
givenRequestHeaders("x-obp-txid", "TX-019");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verify(mockResponse).setHeader("x-obp-txid", "TX-019");
reset(mockResponse);
givenGroupWhiteList("");
filter.doPreFilter(GRP, ADPT, "msg", prop, mockRequest, mockResponse);
verifyNoInteractions(mockResponse);
}
}
@@ -0,0 +1,609 @@
package com.eactive.eai.authoutbound.client.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.nullable;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.context.ApplicationContext;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
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;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* HttpClientAccessTokenServiceWithConfig 단위테스트
*
* - HTTP 호출부(execute) 외부 연동이 필요하므로 제외하고, 설정 해석/요청 조립/응답 파싱 로직을 검증한다.
* - 대상 메서드는 package-private 이므로 같은 패키지에서 직접 호출한다.
* - PropManager ApplicationContextProvider mock 리플렉션으로 주입해 격리한다.
* (TemplateAdapterErrorMsgHandlerTest 동일한 방식)
*/
class HttpClientAccessTokenServiceWithConfigTest {
/** 테스트에서 사용하는 어댑터그룹명 */
private static final String GROUP = "TESTGRP";
private HttpClientAccessTokenServiceWithConfig service;
/** mock PropManager 가 바라보는 프로퍼티 저장소 */
private Properties props;
@BeforeEach
void setUp() throws Exception {
service = new HttpClientAccessTokenServiceWithConfig();
props = new Properties();
injectMockPropManager(true);
}
// ================================================================
// 공통 헬퍼
// ================================================================
/**
* ApplicationContextProvider mock PropManager 주입한다.
*
* @param containProperties isContainProperties() 반환값
*/
private void injectMockPropManager(boolean containProperties) throws Exception {
PropManager mockPropManager = Mockito.mock(PropManager.class);
Mockito.when(mockPropManager.getProperty(anyString(), anyString(), nullable(String.class)))
.thenAnswer(invocation -> props.getProperty(invocation.getArgument(1), invocation.getArgument(2)));
Mockito.when(mockPropManager.isContainProperties(anyString())).thenReturn(containProperties);
Mockito.when(mockPropManager.getProperties(anyString())).thenReturn(props);
ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager);
Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
ctxField.setAccessible(true);
ctxField.set(null, mockCtx);
}
/** "{어댑터그룹명}.{key}" 프로퍼티를 설정한다. */
private void prop(String key, String value) {
props.setProperty(GROUP + "." + key, value);
}
/** clientId/clientSecret 만 채운 VO */
private OutboundOAuthCredentialVo vo(String clientId, String clientSecret) {
OutboundOAuthCredentialVo vo = new OutboundOAuthCredentialVo();
vo.setClientId(clientId);
vo.setClientSecret(clientSecret);
return vo;
}
/** intervalSec 까지 채운 VO */
private OutboundOAuthCredentialVo vo(String clientId, String clientSecret, int intervalSec) {
OutboundOAuthCredentialVo vo = vo(clientId, clientSecret);
vo.setIntervalSec(intervalSec);
return vo;
}
// ================================================================
// 응답 파싱
// ================================================================
@Nested
@DisplayName("parseToken - 응답 파싱")
class ParseToken {
@Test
@DisplayName("1-1. 표준 응답의 모든 필드를 매핑한다")
void 표준응답() throws Exception {
String response = "{\"access_token\":\"tok-1\",\"token_type\":\"Bearer\",\"expires_in\":100,\"scope\":\"read\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 1_000_000L, vo("id", "secret"));
assertEquals("tok-1", token.getAccessToken());
assertEquals("Bearer", ((OAuth2AccessTokenVO) token).getTokenType());
assertEquals("read", ((OAuth2AccessTokenVO) token).getScope());
assertEquals(1_000_000L + 100 * 1000L, token.getExpiration().getTime());
}
@Test
@DisplayName("1-2. scope 가 없어도 토큰이 채워진다 (기존 구현체 회귀 방지)")
void scope없어도_토큰세팅() throws Exception {
// 기존 구현체 5개는 access_token/token_type/expires_in/scope 모두 있어야 세팅했고,
// 하나라도 없으면 토큰을 정상인 것처럼 반환했다.
String response = "{\"access_token\":\"tok-2\",\"token_type\":\"Bearer\",\"expires_in\":100}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals("tok-2", token.getAccessToken());
assertNull(((OAuth2AccessTokenVO) token).getScope());
}
@Test
@DisplayName("1-3. access_token 이 없으면 예외")
void accessToken없음() {
String response = "{\"token_type\":\"Bearer\",\"expires_in\":100}";
Exception e = assertThrows(Exception.class,
() -> service.parseToken(GROUP, response, 0L, vo("id", "secret")));
assertTrue(e.getMessage().contains("access_token"));
}
@Test
@DisplayName("1-4. dot-path 로 중첩된 응답을 매핑한다 (NiceOn dataBody 형태)")
void dotPath매핑() throws Exception {
prop("response.field.access-token", "dataBody.access_token");
prop("response.field.token-type", "dataBody.token_type");
prop("response.field.expires-in", "dataBody.expires_in");
prop("response.field.scope", "dataBody.scope");
String response = "{\"dataHeader\":{\"GW_RSLT_CD\":\"1200\"},"
+ "\"dataBody\":{\"access_token\":\"tok-3\",\"token_type\":\"Bearer\",\"expires_in\":60,\"scope\":\"all\"}}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals("tok-3", token.getAccessToken());
assertEquals("all", ((OAuth2AccessTokenVO) token).getScope());
}
@Test
@DisplayName("1-5. 성공코드가 일치하면 정상 처리한다")
void 성공코드일치() throws Exception {
prop("response.success.field", "dataHeader.GW_RSLT_CD");
prop("response.success.value", "1200");
prop("response.field.access-token", "dataBody.access_token");
String response = "{\"dataHeader\":{\"GW_RSLT_CD\":\"1200\"},\"dataBody\":{\"access_token\":\"tok-4\"}}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals("tok-4", token.getAccessToken());
}
@Test
@DisplayName("1-6. 성공코드가 다르면 예외 (기존 NiceOn 은 NPE 였다)")
void 성공코드불일치() {
prop("response.success.field", "dataHeader.GW_RSLT_CD");
prop("response.success.value", "1200");
prop("response.field.access-token", "dataBody.access_token");
String response = "{\"dataHeader\":{\"GW_RSLT_CD\":\"9999\"},\"dataBody\":{\"access_token\":\"tok-5\"}}";
Exception e = assertThrows(Exception.class,
() -> service.parseToken(GROUP, response, 0L, vo("id", "secret")));
assertTrue(e.getMessage().contains("9999"));
}
@Test
@DisplayName("1-7. 성공코드 필드 자체가 없으면 예외")
void 성공코드필드없음() {
prop("response.success.field", "dataHeader.GW_RSLT_CD");
prop("response.success.value", "1200");
String response = "{\"access_token\":\"tok-6\"}";
assertThrows(Exception.class, () -> service.parseToken(GROUP, response, 0L, vo("id", "secret")));
}
@Test
@DisplayName("1-8. expires_in 이 없으면 설정한 기본값을 쓴다")
void expiresIn_프로퍼티기본값() throws Exception {
prop("response.expires-in.default", "7200");
String response = "{\"access_token\":\"tok-7\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret", 300));
assertEquals(7200 * 1000L, token.getExpiration().getTime());
}
@Test
@DisplayName("1-9. expires_in 도 설정도 없으면 intervalSec 을 쓴다 (DJBErpNsmapi 형태)")
void expiresIn_intervalSec() throws Exception {
String response = "{\"data\":{\"access_token\":\"tok-8\"}}";
prop("response.field.access-token", "data.access_token");
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret", 300));
assertEquals(300 * 1000L, token.getExpiration().getTime());
}
@Test
@DisplayName("1-10. expires_in / 설정 / intervalSec 이 모두 없으면 3600 초")
void expiresIn_기본3600() throws Exception {
String response = "{\"access_token\":\"tok-9\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals(3600 * 1000L, token.getExpiration().getTime());
}
@Test
@DisplayName("1-11. none 으로 끈 응답 필드는 조회하지 않는다")
void none으로_끈필드() throws Exception {
prop("response.field.token-type", "none");
prop("response.field.scope", "none");
String response = "{\"access_token\":\"tok-10\",\"token_type\":\"Bearer\",\"scope\":\"read\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals("tok-10", token.getAccessToken());
assertNull(((OAuth2AccessTokenVO) token).getTokenType());
assertNull(((OAuth2AccessTokenVO) token).getScope());
}
@Test
@DisplayName("1-12. client_use_code 와 clientId 를 채운다")
void clientUseCode와_clientId() throws Exception {
String response = "{\"access_token\":\"tok-11\",\"client_use_code\":\"U01\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("my-client", "secret"));
assertEquals("U01", ((OAuth2AccessTokenVO) token).getClientUseCode());
assertEquals("my-client", ((OAuth2AccessTokenVO) token).getClientId());
}
}
// ================================================================
// Content-Type
// ================================================================
@Nested
@DisplayName("buildContentTypeHeader - Content-Type 조립")
class ContentTypeHeader {
@Test
@DisplayName("2-1. 기본은 charset 을 붙인다")
void 기본charset부착() {
assertEquals("application/x-www-form-urlencoded; charset=UTF-8",
service.buildContentTypeHeader(GROUP, "application/x-www-form-urlencoded", "UTF-8"));
}
@Test
@DisplayName("2-2. content-type.charset=N 이면 charset 을 붙이지 않는다")
void charset미부착() {
prop("content-type.charset", "N");
assertEquals("application/x-www-form-urlencoded",
service.buildContentTypeHeader(GROUP, "application/x-www-form-urlencoded", "UTF-8"));
}
@Test
@DisplayName("2-3. charset=N 이면 뒤에 붙은 세미콜론도 정리한다")
void charset미부착_세미콜론정리() {
prop("content-type.charset", "N");
assertEquals("application/json", service.buildContentTypeHeader(GROUP, "application/json;", "UTF-8"));
}
@Test
@DisplayName("2-4. 이미 charset 이 있으면 중복해서 붙이지 않는다")
void charset중복방지() {
assertEquals("application/json; charset=EUC-KR",
service.buildContentTypeHeader(GROUP, "application/json; charset=EUC-KR", "UTF-8"));
}
@Test
@DisplayName("2-5. 세미콜론으로 끝나도 구분자가 중복되지 않는다")
void 구분자중복방지() {
assertEquals("application/json; charset=UTF-8",
service.buildContentTypeHeader(GROUP, "application/json;", "UTF-8"));
}
@Test
@DisplayName("2-6. json 계열 판정 (vnd.+json, 대소문자, 파라미터 포함)")
void json판정() {
assertTrue(service.isJsonContentType("application/json"));
assertTrue(service.isJsonContentType("Application/JSON; charset=UTF-8"));
assertTrue(service.isJsonContentType("application/vnd.api+json"));
assertFalse(service.isJsonContentType("text/xml"));
}
@Test
@DisplayName("2-7. form 계열 판정")
void form판정() {
assertTrue(service.isFormContentType("application/x-www-form-urlencoded"));
assertTrue(service.isFormContentType("APPLICATION/X-WWW-FORM-URLENCODED; charset=UTF-8"));
assertFalse(service.isFormContentType("application/json"));
assertFalse(service.isFormContentType("text/xml"));
}
}
// ================================================================
// 자격증명 헤더
// ================================================================
@Nested
@DisplayName("resolveCredentialValue - 헤더에 담을 자격증명")
class CredentialValue {
@Test
@DisplayName("3-1. basic 은 base64(id:secret)")
void basic() throws Exception {
String expected = Base64.getEncoder()
.encodeToString("id:secret".getBytes(StandardCharsets.UTF_8));
assertEquals(expected, service.resolveCredentialValue(GROUP, "basic", vo("id", "secret")));
}
@Test
@DisplayName("3-2. client-id 는 DB 값 원본 (DJBErpNsmapi 형태)")
void clientId원본() throws Exception {
assertEquals("already-encoded-value",
service.resolveCredentialValue(GROUP, "client-id", vo("already-encoded-value", "secret")));
}
@Test
@DisplayName("3-3. client-secret 은 secret 값")
void clientSecret() throws Exception {
assertEquals("secret", service.resolveCredentialValue(GROUP, "client-secret", vo("id", "secret")));
}
@Test
@DisplayName("3-4. 지원하지 않는 값이면 예외")
void 미지원값() {
Exception e = assertThrows(Exception.class,
() -> service.resolveCredentialValue(GROUP, "unknown", vo("id", "secret")));
assertTrue(e.getMessage().contains("credential.header.value"));
}
}
// ================================================================
// 필드명 매핑 / 프로퍼티 조회
// ================================================================
@Nested
@DisplayName("getFieldName / getPropsByPrefix - 설정 조회")
class PropLookup {
@Test
@DisplayName("4-1. 미설정이면 기본 필드명을 쓴다")
void 미설정() {
assertEquals("client_id", service.getFieldName(GROUP, "request.field.client-id", "client_id"));
}
@Test
@DisplayName("4-2. 설정하면 설정한 필드명을 쓴다")
void 설정됨() {
prop("request.field.client-id", "clientKey");
assertEquals("clientKey", service.getFieldName(GROUP, "request.field.client-id", "client_id"));
}
@Test
@DisplayName("4-3. none 이면 null (대소문자 무시)")
void none이면null() {
prop("request.field.client-id", "none");
prop("request.field.scope", "NONE");
assertNull(service.getFieldName(GROUP, "request.field.client-id", "client_id"));
assertNull(service.getFieldName(GROUP, "request.field.scope", "scope"));
}
@Test
@DisplayName("4-4. header. 접두어 스캔은 credential.header.* 를 걷어오지 않는다")
void header접두어스캔() {
prop("header.X-API-KEY", "abcd");
prop("header.X-TRACE", "t-1");
prop("credential.header.name", "Authorization");
prop("body.institution_code", "0088");
Map<String, String> headers = service.getPropsByPrefix(GROUP, "header.");
assertEquals(2, headers.size());
assertEquals("abcd", headers.get("X-API-KEY"));
assertEquals("t-1", headers.get("X-TRACE"));
}
@Test
@DisplayName("4-5. body. 접두어 스캔")
void body접두어스캔() {
prop("body.institution_code", "0088");
prop("header.X-API-KEY", "abcd");
Map<String, String> body = service.getPropsByPrefix(GROUP, "body.");
assertEquals(1, body.size());
assertEquals("0088", body.get("institution_code"));
}
@Test
@DisplayName("4-6. 다른 어댑터그룹의 설정은 걷어오지 않는다")
void 다른그룹제외() {
prop("header.X-API-KEY", "mine");
props.setProperty("OTHERGRP.header.X-API-KEY", "other");
Map<String, String> headers = service.getPropsByPrefix(GROUP, "header.");
assertEquals(1, headers.size());
assertEquals("mine", headers.get("X-API-KEY"));
}
@Test
@DisplayName("4-7. 프로퍼티 그룹 자체가 없으면 빈 Map (RuntimeException 방지)")
void 그룹없음() throws Exception {
// PropManager.getProperties() 그룹이 없으면 RuntimeException 던진다.
injectMockPropManager(false);
assertTrue(service.getPropsByPrefix(GROUP, "header.").isEmpty());
}
}
// ================================================================
// 필드 전송 위치 (바디 / 헤더)
// ================================================================
@Nested
@DisplayName("parseInHeaderFields / assignField - 필드 전송 위치")
class FieldPlacement {
private Map<String, String> params;
private Map<String, String> headers;
@BeforeEach
void init() {
params = new LinkedHashMap<>();
headers = new LinkedHashMap<>();
}
/** VO 값 4개를 in-header 설정에 따라 배치한다. */
private void assignAll() {
Set<String> inHeader = service.parseInHeaderFields(GROUP);
service.assignField(params, headers, inHeader, "client-id",
service.getFieldName(GROUP, "request.field.client-id", "client_id"), "id-1");
service.assignField(params, headers, inHeader, "client-secret",
service.getFieldName(GROUP, "request.field.client-secret", "client_secret"), "secret-1");
service.assignField(params, headers, inHeader, "scope",
service.getFieldName(GROUP, "request.field.scope", "scope"), "read");
service.assignField(params, headers, inHeader, "grant-type",
service.getFieldName(GROUP, "request.field.grant-type", "grant_type"), "client_credentials");
}
@Test
@DisplayName("6-1. 미설정이면 4개 모두 바디로 간다 (기존 동작)")
void 기본은바디() {
assignAll();
assertEquals(4, params.size());
assertTrue(headers.isEmpty());
assertEquals("id-1", params.get("client_id"));
}
@Test
@DisplayName("6-2. 4개를 모두 나열하면 바디가 비고 전부 헤더로 간다")
void 전부헤더() {
prop("request.fields.in-header", "client-id,client-secret,scope,grant-type");
assignAll();
assertTrue(params.isEmpty(), "바디로 갈 파라미터가 없어야 한다 (바디 자체를 붙이지 않는 조건)");
assertEquals(4, headers.size());
assertEquals("id-1", headers.get("client_id"));
assertEquals("secret-1", headers.get("client_secret"));
assertEquals("read", headers.get("scope"));
assertEquals("client_credentials", headers.get("grant_type"));
}
@Test
@DisplayName("6-3. 헤더명은 request.field.* 매핑을 그대로 쓴다")
void 헤더명매핑() {
prop("request.fields.in-header", "client-id,client-secret,scope,grant-type");
prop("request.field.client-id", "X-CLIENT-ID");
prop("request.field.client-secret", "X-CLIENT-SECRET");
prop("request.field.scope", "X-SCOPE");
prop("request.field.grant-type", "X-GRANT-TYPE");
assignAll();
assertTrue(params.isEmpty());
assertEquals("id-1", headers.get("X-CLIENT-ID"));
assertEquals("secret-1", headers.get("X-CLIENT-SECRET"));
assertEquals("read", headers.get("X-SCOPE"));
assertEquals("client_credentials", headers.get("X-GRANT-TYPE"));
}
@Test
@DisplayName("6-4. 일부만 나열하면 나머지는 바디로 간다")
void 일부만헤더() {
prop("request.fields.in-header", "scope, grant-type");
assignAll();
assertEquals(2, params.size());
assertEquals("id-1", params.get("client_id"));
assertEquals("secret-1", params.get("client_secret"));
assertEquals(2, headers.size());
assertEquals("read", headers.get("scope"));
}
@Test
@DisplayName("6-5. 공백/대소문자/빈 항목을 허용한다")
void 목록파싱() {
prop("request.fields.in-header", " Client-ID , , SCOPE ");
Set<String> fields = service.parseInHeaderFields(GROUP);
assertEquals(2, fields.size());
assertTrue(fields.contains("client-id"));
assertTrue(fields.contains("scope"));
}
@Test
@DisplayName("6-6. 미설정이면 빈 집합")
void 미설정이면빈집합() {
assertTrue(service.parseInHeaderFields(GROUP).isEmpty());
}
@Test
@DisplayName("6-7. none 으로 끈 필드는 헤더로도 가지 않는다")
void none은전송안함() {
prop("request.fields.in-header", "client-id,scope");
prop("request.field.scope", "none");
assignAll();
assertEquals(1, headers.size());
assertEquals("id-1", headers.get("client_id"));
assertFalse(headers.containsKey("scope"));
assertFalse(params.containsKey("scope"));
}
}
// ================================================================
// dot-path
// ================================================================
@Nested
@DisplayName("findByPath / putJsonPath - dot-path 처리")
class DotPath {
private final ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
@Test
@DisplayName("5-1. 중첩 경로를 조회한다")
void 중첩조회() throws Exception {
JsonNode root = mapper.readTree("{\"a\":{\"b\":{\"c\":\"v\"}}}");
assertEquals("v", service.findByPath(root, "a.b.c").asText());
}
@Test
@DisplayName("5-2. 없는 경로 / null 값 / 빈 경로는 null")
void 없는경로() throws Exception {
JsonNode root = mapper.readTree("{\"a\":{\"b\":null}}");
assertNull(service.findByPath(root, "a.b"));
assertNull(service.findByPath(root, "a.x"));
assertNull(service.findByPath(root, "x.y.z"));
assertNull(service.findByPath(root, null));
assertNull(service.findByPath(root, ""));
}
@Test
@DisplayName("5-3. 중첩 경로로 요청 바디를 만든다")
void 중첩생성() {
ObjectNode root = mapper.createObjectNode();
service.putJsonPath(root, "auth.clientId", "id-1");
service.putJsonPath(root, "auth.clientSecret", "secret-1");
service.putJsonPath(root, "grant_type", "client_credentials");
assertEquals("id-1", root.path("auth").path("clientId").asText());
assertEquals("secret-1", root.path("auth").path("clientSecret").asText());
assertEquals("client_credentials", root.path("grant_type").asText());
assertNotNull(root.get("auth"));
}
}
}
@@ -0,0 +1,192 @@
package com.eactive.eai.common.authoutbound;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import java.lang.reflect.Field;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.function.Function;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.authoutbound.client.HttpClientAccessTokenServiceByDB;
import com.eactive.eai.common.session.SessionManager;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* AccessTokenManagerByDB.retryAccessTokenVO 단위테스트
*
* 거래 재발급이 SessionManager 분산락 경로(reissueOutboundAccessToken) 타는지,
* 발급 구현체를 호출자가 넘긴 어댑터 속성 기준으로 고르는지를 검증한다.
*/
class AccessTokenManagerByDBRetryTest {
private static final String GROUP = "TESTGRP";
private static final String SERVICE_CLASS = RecordingTokenService.class.getName();
private AccessTokenManagerByDB manager;
private SessionManager mockSessionManager;
@BeforeEach
void setUp() throws Exception {
manager = new AccessTokenManagerByDB();
RecordingTokenService.reset();
mockSessionManager = Mockito.mock(SessionManager.class);
injectSessionManager(mockSessionManager);
// 분산락 구간을 흉내 낸다. supplier 그대로 실행해 결과를 돌려준다.
Mockito.when(mockSessionManager.reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any()))
.thenAnswer(invocation -> {
Function<AccessTokenVO, AccessTokenVO> supplier = invocation.getArgument(3);
return supplier.apply(null);
});
}
/** SessionManager 싱글턴에 mock 을 주입한다. */
private void injectSessionManager(SessionManager sessionManager) throws Exception {
Field field = SessionManager.class.getDeclaredField("instance");
field.setAccessible(true);
field.set(null, sessionManager);
}
/** 인증 정보를 등록된 것으로 만든다. */
@SuppressWarnings("unchecked")
private OutboundOAuthCredentialVo registerCredential(String useYn) throws Exception {
OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo();
credential.setAdapterGroupName(GROUP);
credential.setUseYn(useYn);
credential.setClientId("client-1");
credential.setClientSecret("secret-1");
Field field = AccessTokenManagerByDB.class.getDeclaredField("outboundOAuthCredentialVos");
field.setAccessible(true);
Map<String, OutboundOAuthCredentialVo> map = (Map<String, OutboundOAuthCredentialVo>) field.get(manager);
map.put(GROUP, credential);
return credential;
}
/** 발급 구현체를 지정한 어댑터 속성 */
private Properties adapterProp(String serviceClass) {
Properties properties = new Properties();
properties.setProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE", serviceClass);
properties.setProperty("URL", "https://api.example.com");
return properties;
}
@Test
@DisplayName("1. 재발급은 SessionManager 의 분산락 경로를 통해 일어난다")
void 분산락경로_사용() throws Exception {
registerCredential("Y");
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD");
assertEquals("NEW", result.getAccessToken());
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD"), Mockito.anyLong(), any());
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
}
@Test
@DisplayName("2. 발급 구현체는 호출자가 넘긴 어댑터 속성으로 고른다")
void 호출자속성으로_구현체선택() throws Exception {
OutboundOAuthCredentialVo credential = registerCredential("Y");
Properties prop = adapterProp(SERVICE_CLASS);
manager.retryAccessTokenVO(GROUP, prop, "OLD");
assertEquals(1, RecordingTokenService.callCount, "구현체가 한 번 실행돼야 한다");
assertEquals(GROUP, RecordingTokenService.lastAdapterGroupName);
assertSame(prop, RecordingTokenService.lastProperties, "호출자가 넘긴 속성이 그대로 전달돼야 한다");
assertSame(credential, RecordingTokenService.lastCredential, "DB 인증정보가 전달돼야 한다");
}
@Test
@DisplayName("3. 인증 정보가 없거나 useYn 이 N 이면 예외")
void 미등록이면_예외() throws Exception {
registerCredential("N");
assertThrows(Exception.class, () -> manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD"));
assertThrows(Exception.class, () -> manager.retryAccessTokenVO("NO-SUCH-GROUP",
adapterProp(SERVICE_CLASS), "OLD"));
assertEquals(0, RecordingTokenService.callCount);
}
@Test
@DisplayName("4. 구현체를 찾지 못하면 발급하지 않고 캐시 조회로 넘어간다")
void 구현체없음_폴백() throws Exception {
registerCredential("Y");
OAuth2AccessTokenVO cached = new OAuth2AccessTokenVO();
cached.setAccessToken("CACHED");
cached.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
Mockito.when(mockSessionManager.getOutboundAccessToken(eq(GROUP), any())).thenReturn(cached);
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp("no.such.TokenService"), "OLD");
assertEquals("CACHED", result.getAccessToken());
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
}
@Test
@DisplayName("5. 발급 중 예외가 나면 null 을 돌려줘 캐시에 남지 않게 한다")
void 발급실패는_null() throws Exception {
registerCredential("Y");
RecordingTokenService.throwOnExecute = true;
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD");
assertNull(result, "실패를 빈 토큰이 아니라 null 로 알려야 캐시에 저장되지 않는다");
}
/**
* 호출 인자를 기록하는 테스트용 발급 구현체.
*
* HttpClientAccessTokenServiceFactoryByDB 클래스명으로 생성하므로 public 이어야 한다.
*/
public static class RecordingTokenService implements HttpClientAccessTokenServiceByDB {
static int callCount;
static String lastAdapterGroupName;
static Properties lastProperties;
static OutboundOAuthCredentialVo lastCredential;
static boolean throwOnExecute;
static void reset() {
callCount = 0;
lastAdapterGroupName = null;
lastProperties = null;
lastCredential = null;
throwOnExecute = false;
}
@Override
public Object execute(String adapterGroupName, Properties adapterProp,
OutboundOAuthCredentialVo oAuthCredentialVo) throws Exception {
callCount++;
lastAdapterGroupName = adapterGroupName;
lastProperties = adapterProp;
lastCredential = oAuthCredentialVo;
if (throwOnExecute) {
throw new Exception("token issue failed");
}
OAuth2AccessTokenVO token = new OAuth2AccessTokenVO();
token.setAccessToken("NEW");
token.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
return token;
}
}
}
@@ -0,0 +1,136 @@
package com.eactive.eai.common.authoutbound;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.session.SessionManager;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* AccessTokenManagerByDB 스케줄러 태스크의 갱신 판정 단위테스트
*
* 캐시 조회(peek) 결과에 따라 어느 경로로 가는지를 본다.
* - 토큰 없음 : getOutboundAccessToken (분산락 안에서 발급)
* - 다음 만료 : reissueOutboundAccessToken (선제 갱신)
* - 아직 유효 : 아무것도 하지 않음
*/
class AccessTokenManagerByDBScheduleTest {
private static final String GROUP = "TESTGRP";
/** 토큰 재발급 주기(초) */
private static final int INTERVAL_SEC = 30;
private AccessTokenManagerByDB manager;
private SessionManager mockSessionManager;
private Method executeTokenTask;
@BeforeEach
void setUp() throws Exception {
manager = new AccessTokenManagerByDB();
mockSessionManager = Mockito.mock(SessionManager.class);
Field field = SessionManager.class.getDeclaredField("instance");
field.setAccessible(true);
field.set(null, mockSessionManager);
executeTokenTask = AccessTokenManagerByDB.class.getDeclaredMethod("executeTokenTask", String.class,
OutboundOAuthCredentialVo.class);
executeTokenTask.setAccessible(true);
}
@AfterEach
void tearDown() throws Exception {
Field field = AccessTokenManagerByDB.class.getDeclaredField("scheduler");
field.setAccessible(true);
((ExecutorService) field.get(manager)).shutdownNow();
}
/** 스케줄러 태스크를 한 번 실행한다. */
private void runTask() throws Exception {
OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo();
credential.setAdapterGroupName(GROUP);
credential.setUseYn("Y");
credential.setIntervalSec(INTERVAL_SEC);
executeTokenTask.invoke(manager, GROUP, credential);
}
/** 만료까지 expiresInSec 남은 캐시 토큰을 준비한다. */
private void cached(String accessToken, Long expiresInSec) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
vo.setAccessToken(accessToken);
if (expiresInSec != null) {
vo.setExpiration(new Date(System.currentTimeMillis() + expiresInSec * 1000L));
}
Mockito.when(mockSessionManager.peekOutboundAccessToken(GROUP)).thenReturn(vo);
}
@Test
@DisplayName("1. 캐시에 토큰이 없으면 분산락 경로로 발급한다")
void 토큰없음_발급() throws Exception {
Mockito.when(mockSessionManager.peekOutboundAccessToken(GROUP)).thenReturn(null);
runTask();
Mockito.verify(mockSessionManager).getOutboundAccessToken(eq(GROUP), any());
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
}
@Test
@DisplayName("2. 다음 틱 전에 만료되면 선제 갱신한다 (만료 전이어도)")
void 만료임박_선제갱신() throws Exception {
// 아직 20초 남았지만 다음 (30초 ) 전에 만료된다.
cached("OLD-TOKEN", 20L);
runTask();
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD-TOKEN"), Mockito.anyLong(), any());
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
}
@Test
@DisplayName("3. 이미 만료된 토큰도 선제 갱신 경로로 간다")
void 이미만료_갱신() throws Exception {
cached("OLD-TOKEN", -60L);
runTask();
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD-TOKEN"), Mockito.anyLong(), any());
}
@Test
@DisplayName("4. 다음 틱까지 유효하면 아무것도 하지 않는다")
void 유효하면_그대로() throws Exception {
cached("VALID-TOKEN", 600L);
runTask();
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
}
@Test
@DisplayName("5. 만료시각이 없는 토큰은 갱신 판단을 하지 않는다")
void 만료시각없음_보류() throws Exception {
cached("NO-EXPIRATION", null);
runTask();
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
}
}
@@ -0,0 +1,111 @@
package com.eactive.eai.common.authoutbound;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* AccessTokenManagerByDB 토큰 발급 이력 단위테스트
*
* 이력 기록은 private 이라 리플렉션으로 호출한다. 조회(getIssueHistories) 공개 API .
*/
class TokenIssueHistoryTest {
private static final String GROUP = "TESTGRP";
private AccessTokenManagerByDB manager;
private Method recordMethod;
@BeforeEach
void setUp() throws Exception {
manager = new AccessTokenManagerByDB();
recordMethod = AccessTokenManagerByDB.class.getDeclaredMethod("recordIssueHistory", String.class, String.class,
String.class, long.class, AccessTokenVO.class, String.class);
recordMethod.setAccessible(true);
}
private void record(String trigger, String serviceClass, AccessTokenVO token, String failReason) throws Exception {
recordMethod.invoke(manager, GROUP, trigger, serviceClass, System.currentTimeMillis(), token, failReason);
}
private OAuth2AccessTokenVO token(String accessToken) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
vo.setAccessToken(accessToken);
vo.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
return vo;
}
@Test
@DisplayName("1. 발급 성공 이력을 남기고 토큰은 마스킹한다")
void 성공이력() throws Exception {
record(TokenIssueHistory.TRIGGER_SCHEDULE, "com.example.TokenService", token("abcdefghijklmnop"), null);
List<TokenIssueHistory> histories = manager.getIssueHistories(GROUP);
assertEquals(1, histories.size());
TokenIssueHistory history = histories.get(0);
assertTrue(history.isSuccess());
assertEquals("SCHEDULE", history.getTrigger());
assertEquals("com.example.TokenService", history.getServiceClass());
assertEquals("abcdefgh***", history.getAccessTokenMasked());
assertNull(history.getFailReason());
assertFalse(history.getAccessTokenMasked().contains("ijklmnop"), "토큰 뒷부분이 남으면 안 된다");
}
@Test
@DisplayName("2. 실패 이력은 사유를 남기고 토큰은 담지 않는다")
void 실패이력() throws Exception {
record(TokenIssueHistory.TRIGGER_RETRY, "com.example.TokenService", null, "Connection refused");
TokenIssueHistory history = manager.getIssueHistories(GROUP).get(0);
assertFalse(history.isSuccess());
assertEquals("RETRY", history.getTrigger());
assertEquals("Connection refused", history.getFailReason());
assertNull(history.getAccessTokenMasked());
assertNull(history.getExpiration());
}
@Test
@DisplayName("3. 빈 토큰은 사유를 채워 실패로 남긴다")
void 빈토큰은_실패() throws Exception {
record(TokenIssueHistory.TRIGGER_SCHEDULE, "com.example.TokenService", new OAuth2AccessTokenVO(), null);
TokenIssueHistory history = manager.getIssueHistories(GROUP).get(0);
assertFalse(history.isSuccess());
assertTrue(history.getFailReason().contains("비어 있음"));
}
@Test
@DisplayName("4. 최근 10건만 보관하고 최신이 앞에 온다")
void 최근10건만_보관() throws Exception {
for (int i = 1; i <= 15; i++) {
record(TokenIssueHistory.TRIGGER_SCHEDULE, "svc-" + i, token("token-" + i), null);
}
List<TokenIssueHistory> histories = manager.getIssueHistories(GROUP);
assertEquals(10, histories.size());
assertEquals("svc-15", histories.get(0).getServiceClass(), "최신 건이 앞에 와야 한다");
assertEquals("svc-6", histories.get(9).getServiceClass(), "11번째부터는 밀려나야 한다");
}
@Test
@DisplayName("5. 이력이 없는 어댑터그룹은 빈 목록")
void 이력없음() {
assertTrue(manager.getIssueHistories("NO-SUCH-GROUP").isEmpty());
}
}
@@ -0,0 +1,319 @@
package com.eactive.eai.common.session;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import java.lang.reflect.Field;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import org.apache.ignite.Ignite;
import org.apache.ignite.IgniteCache;
import org.apache.ignite.IgniteTransactions;
import org.apache.ignite.transactions.Transaction;
import org.apache.ignite.transactions.TransactionConcurrency;
import org.apache.ignite.transactions.TransactionIsolation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* SessionManagerForIgnite.reissueOutboundAccessToken 단위테스트
*
* Ignite 캐시 자리에 ConcurrentHashMap 으로 동작하는 mock IgniteCache 리플렉션으로 주입하고,
* 분산락 자리에는 어댑터그룹별 ReentrantLock 물려 안의 판정 로직과 경합 동작을 검증한다.
*
* 노드 상호배제 자체는 Ignite lock(key) 보장하는 부분이라 여기서 검증할 없다.
* 테스트가 보는 것은 "락을 잡은 뒤 무엇을 판단하는가" 이다.
*/
class SessionManagerForIgniteReissueTest {
private static final String GROUP = "TESTGRP";
private SessionManagerForIgnite sessionManager;
/** mock IgniteCache 의 실제 저장소 */
private Map<String, AccessTokenVO> store;
/** 어댑터그룹별 락 (같은 키면 같은 인스턴스를 돌려줘야 경합 테스트가 성립한다) */
private Map<String, Lock> locks;
/** readFromPrimary 가 여는 트랜잭션 */
private Transaction transaction;
@BeforeEach
void setUp() throws Exception {
sessionManager = new SessionManagerForIgnite();
store = new ConcurrentHashMap<>();
locks = new ConcurrentHashMap<>();
@SuppressWarnings("unchecked")
IgniteCache<String, AccessTokenVO> cache = Mockito.mock(IgniteCache.class);
Mockito.when(cache.get(anyString())).thenAnswer(invocation -> store.get(invocation.getArgument(0)));
Mockito.doAnswer(invocation -> {
store.put(invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(cache).put(anyString(), any(AccessTokenVO.class));
Mockito.when(cache.lock(anyString()))
.thenAnswer(invocation -> locks.computeIfAbsent(invocation.getArgument(0),
key -> new ReentrantLock()));
injectCache(cache);
injectIgnite();
}
/**
* readFromPrimary 쓰는 Ignite 트랜잭션을 mock 으로 주입한다.
* 주입하지 않으면 NPE 폴백 경로를 타게 되어 실제 동작을 검증하지 못한다.
*/
private void injectIgnite() throws Exception {
transaction = Mockito.mock(Transaction.class);
IgniteTransactions transactions = Mockito.mock(IgniteTransactions.class);
Mockito.when(transactions.txStart(any(TransactionConcurrency.class), any(TransactionIsolation.class)))
.thenReturn(transaction);
Ignite ignite = Mockito.mock(Ignite.class);
Mockito.when(ignite.transactions()).thenReturn(transactions);
Field field = SessionManagerForIgnite.class.getDeclaredField("manager");
field.setAccessible(true);
field.set(null, ignite);
}
/** private static cacheOutBoundAccessToken 에 mock 을 주입한다. */
private void injectCache(IgniteCache<String, AccessTokenVO> cache) throws Exception {
Field field = SessionManagerForIgnite.class.getDeclaredField("cacheOutBoundAccessToken");
field.setAccessible(true);
field.set(null, cache);
}
/** 만료되지 않은 토큰 */
private OAuth2AccessTokenVO token(String accessToken) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
vo.setAccessToken(accessToken);
vo.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
return vo;
}
/** 호출 횟수를 세면서 지정한 토큰을 발급하는 supplier */
private Function<AccessTokenVO, AccessTokenVO> supplier(AtomicInteger counter, String newToken) {
return current -> {
counter.incrementAndGet();
return newToken == null ? null : token(newToken);
};
}
@Test
@DisplayName("1. 캐시가 비어 있으면 발급하고 캐시에 저장한다")
void 캐시비었을때_발급() {
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
assertEquals(1, issued.get());
assertEquals("NEW", result.getAccessToken());
assertEquals("NEW", store.get(GROUP).getAccessToken());
}
@Test
@DisplayName("2. 캐시 토큰이 거부된 토큰과 같으면 재발급한다")
void 같은토큰이면_재발급() {
store.put(GROUP, token("OLD"));
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
assertEquals(1, issued.get());
assertEquals("NEW", result.getAccessToken());
assertEquals("NEW", store.get(GROUP).getAccessToken());
}
@Test
@DisplayName("3. 다른 노드가 이미 갱신했으면 발급하지 않고 그 토큰을 쓴다")
void 이미갱신됨_발급안함() {
store.put(GROUP, token("ALREADY-NEW"));
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
assertEquals(0, issued.get(), "다른 토큰이 이미 캐시에 있으면 발급하지 않아야 한다");
assertEquals("ALREADY-NEW", result.getAccessToken());
}
@Test
@DisplayName("4. 빈 토큰이 발급되면 캐시에 저장하지 않는다")
void 빈토큰_미캐싱() {
store.put(GROUP, token("OLD"));
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, null));
assertEquals(1, issued.get());
assertNull(result);
assertEquals("OLD", store.get(GROUP).getAccessToken(), "캐시는 그대로여야 한다");
}
@Test
@DisplayName("5. 락을 얻지 못하면 발급하지 않고 기존 토큰을 반환한다")
void 락획득실패() throws Exception {
store.put(GROUP, token("OLD"));
Lock neverAcquired = Mockito.mock(Lock.class);
Mockito.when(neverAcquired.tryLock(Mockito.anyLong(), any(TimeUnit.class))).thenReturn(false);
locks.put(GROUP, neverAcquired);
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
assertEquals(0, issued.get());
assertEquals("OLD", result.getAccessToken());
Mockito.verify(neverAcquired, Mockito.never()).unlock();
}
@Test
@DisplayName("5-1. 캐시 토큰이 요구 시각까지 유효하면 값이 같아도 발급하지 않는다")
void 충분히유효하면_발급안함() {
// 다른 노드가 방금 넣어둔 토큰. 조회 시점 차이로 oldToken 값이 같게 들어와도
// 다음 틱까지 유효하면 다시 발급할 이유가 없다.
store.put(GROUP, token("SAME-TOKEN"));
AtomicInteger issued = new AtomicInteger();
long validUntil = System.currentTimeMillis() + 60_000L;
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "SAME-TOKEN", validUntil,
supplier(issued, "NEW"));
assertEquals(0, issued.get(), "요구 시각까지 유효하면 발급하지 않아야 한다");
assertEquals("SAME-TOKEN", result.getAccessToken());
}
@Test
@DisplayName("5-2. 요구 시각 전에 만료되면 발급한다")
void 요구시각전_만료면_발급() {
store.put(GROUP, token("SAME-TOKEN"));
AtomicInteger issued = new AtomicInteger();
// 캐시 토큰은 10분 만료인데 20분 뒤까지 유효해야 한다면 갱신 대상이다.
long validUntil = System.currentTimeMillis() + 1_200_000L;
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "SAME-TOKEN", validUntil,
supplier(issued, "NEW"));
assertEquals(1, issued.get());
assertEquals("NEW", result.getAccessToken());
}
@Test
@DisplayName("5-3. 캐시 조회는 비관적 트랜잭션 안에서 하고 바로 커밋한다")
void 트랜잭션으로_읽는다() {
store.put(GROUP, token("OLD"));
sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(new AtomicInteger(), "NEW"));
// 1회 + 1회
Mockito.verify(transaction, Mockito.times(2)).commit();
// 발급(HTTP) 구간까지 트랜잭션을 열어두면 장기 트랜잭션이 되어 파티션 교환을 막는다.
Mockito.verify(transaction, Mockito.never()).rollback();
}
@Test
@DisplayName("6. 경합 - 여러 스레드가 같은 토큰으로 동시에 재발급해도 발급은 한 번만 일어난다")
void 동시재발급_한번만() throws Exception {
final int threadCount = 20;
store.put(GROUP, token("OLD"));
final AtomicInteger issued = new AtomicInteger();
final Function<AccessTokenVO, AccessTokenVO> slowSupplier = current -> {
issued.incrementAndGet();
try {
// 발급에 시간이 걸리는 상황을 만들어 경합을 유도한다.
Thread.sleep(50L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return token("NEW");
};
final CountDownLatch start = new CountDownLatch(1);
final CountDownLatch done = new CountDownLatch(threadCount);
final AccessTokenVO[] results = new AccessTokenVO[threadCount];
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
try {
for (int i = 0; i < threadCount; i++) {
final int index = i;
executor.submit(() -> {
try {
start.await();
results[index] = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, slowSupplier);
} catch (Exception e) {
// 결과가 null 남아 아래 검증에서 걸린다.
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(30, TimeUnit.SECONDS), "모든 스레드가 끝나야 한다");
} finally {
executor.shutdownNow();
}
assertEquals(1, issued.get(), "동시에 들어와도 실제 발급은 한 번이어야 한다");
assertEquals("NEW", store.get(GROUP).getAccessToken());
for (int i = 0; i < threadCount; i++) {
assertEquals("NEW", results[i].getAccessToken(), "모든 스레드가 새 토큰을 받아야 한다");
}
}
@Test
@DisplayName("7. 경합 - 어댑터그룹이 다르면 서로 막지 않는다")
void 다른그룹은_독립적() throws Exception {
final AtomicInteger issued = new AtomicInteger();
final CountDownLatch bothInside = new CountDownLatch(2);
final Function<AccessTokenVO, AccessTokenVO> blockingSupplier = current -> {
issued.incrementAndGet();
bothInside.countDown();
try {
// 그룹이 같은 락을 쓰면 여기서 서로를 기다리다 타임아웃된다.
if (!bothInside.await(5, TimeUnit.SECONDS)) {
return null;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return token("NEW");
};
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
executor.submit(() -> sessionManager.reissueOutboundAccessToken("GRP-A", "OLD", 0L, blockingSupplier));
executor.submit(() -> sessionManager.reissueOutboundAccessToken("GRP-B", "OLD", 0L, blockingSupplier));
assertTrue(bothInside.await(10, TimeUnit.SECONDS), "두 그룹이 동시에 발급 구간에 들어가야 한다");
} finally {
executor.shutdownNow();
}
assertEquals(2, issued.get());
assertTrue(locks.containsKey("GRP-A") && locks.containsKey("GRP-B"), "그룹별로 락이 분리돼야 한다");
assertSame(locks.get("GRP-A"), locks.get("GRP-A"));
}
}
@@ -0,0 +1,339 @@
package com.eactive.eai.manage.oauthtoken;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.context.ApplicationContext;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB;
import com.eactive.eai.common.authoutbound.TokenIssueHistory;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* OAuthTokenStatusService 단위테스트
*
* AccessTokenManagerByDB ApplicationContextProvider 통해 조회되므로
* mock ApplicationContext 리플렉션으로 주입해 격리한다.
*/
class OAuthTokenStatusServiceTest {
private static final String GROUP = "TESTGRP";
private OAuthTokenStatusService service;
private AccessTokenManagerByDB mockManager;
@BeforeEach
void setUp() throws Exception {
service = new OAuthTokenStatusService();
mockManager = Mockito.mock(AccessTokenManagerByDB.class);
ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
Mockito.when(mockCtx.getBean(AccessTokenManagerByDB.class)).thenReturn(mockManager);
Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
ctxField.setAccessible(true);
ctxField.set(null, mockCtx);
}
/** 인증 정보가 등록된 것으로 mock 설정한다. */
private OutboundOAuthCredentialVo registerCredential(String adapterGroupName) {
OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo();
credential.setAdapterGroupName(adapterGroupName);
credential.setUseYn("Y");
credential.setUrl("https://token.example.com/oauth/token");
credential.setIntervalSec(1800);
credential.setClientId("client-1");
credential.setClientSecret("super-secret");
Mockito.when(mockManager.getOutboundOAuthCredentialVo(adapterGroupName)).thenReturn(credential);
return credential;
}
/**
* 최근 순으로 정렬된 발급 이력 count (svc-0 최신).
*
* TokenIssueHistory 생성자는 package-private 이라 mock 으로 만든다.
*/
private List<TokenIssueHistory> histories(int count) {
List<TokenIssueHistory> result = new ArrayList<>();
for (int i = 0; i < count; i++) {
TokenIssueHistory history = Mockito.mock(TokenIssueHistory.class);
Mockito.when(history.getServiceClass()).thenReturn("svc-" + i);
result.add(history);
}
return result;
}
/** 만료까지 expiresInSec 남은 토큰 */
private OAuth2AccessTokenVO token(String accessToken, long expiresInSec) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
vo.setAccessToken(accessToken);
vo.setTokenType("Bearer");
vo.setScope("read");
vo.setClientId("client-1");
vo.setExpiration(new Date(System.currentTimeMillis() + expiresInSec * 1000L));
return vo;
}
@Test
@DisplayName("1. 등록되지 않은 어댑터그룹이면 사유만 반환한다")
void 미등록그룹() {
Mockito.when(mockManager.getOutboundOAuthCredentialVo(anyString())).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertEquals(GROUP, dto.getAdapterGroupName());
assertFalse(dto.isCached());
assertNotNull(dto.getMessage());
}
@Test
@DisplayName("2. 캐시에 토큰이 없으면 등록 정보만 반환한다")
void 캐시에토큰없음() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertFalse(dto.isCached());
assertEquals("Y", dto.getUseYn());
assertEquals(1800, dto.getIntervalSec());
assertEquals("https://token.example.com/oauth/token", dto.getTokenUrl());
assertNotNull(dto.getMessage());
}
@Test
@DisplayName("3. 토큰 캐시를 지원하지 않는 백엔드면 사유를 담아 반환한다 (Ehcache)")
void 백엔드미지원() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenThrow(new UnsupportedOperationException());
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertFalse(dto.isCached());
assertTrue(dto.getMessage().contains("지원하지 않습니다"));
}
@Test
@DisplayName("4. 캐시된 토큰의 만료/잔여시간/부가정보를 반환한다")
void 캐시된토큰() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijklmnop", 600));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertTrue(dto.isCached());
assertFalse(dto.isExpired());
assertNotNull(dto.getExpiration());
assertTrue(dto.getRemainSec() > 0 && dto.getRemainSec() <= 600);
assertEquals("Bearer", dto.getTokenType());
assertEquals("read", dto.getScope());
assertEquals("client-1", dto.getClientId());
}
@Test
@DisplayName("5. accessToken 은 앞 8자만 남기고 마스킹한다")
void 토큰마스킹() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijklmnop", 600));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertEquals("abcdefgh***", dto.getAccessTokenMasked());
assertEquals(16, dto.getAccessTokenLength());
assertFalse(dto.getAccessTokenMasked().contains("ijklmnop"), "토큰 뒷부분이 노출되면 안 된다");
}
@Test
@DisplayName("6. 8자 이하 토큰은 전부 마스킹한다")
void 짧은토큰마스킹() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abc", 600));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertEquals("***", dto.getAccessTokenMasked());
assertEquals(3, dto.getAccessTokenLength());
}
@Test
@DisplayName("7. 만료된 토큰은 expired=true, 잔여시간 음수")
void 만료된토큰() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", -60));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertTrue(dto.isCached());
assertTrue(dto.isExpired());
assertTrue(dto.getRemainSec() < 0);
}
@Test
@DisplayName("8. 응답에 clientSecret 은 담기지 않는다")
void secret비노출() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertFalse(dto.toString().contains("super-secret"), "clientSecret 이 응답에 포함되면 안 된다");
}
@Test
@DisplayName("8-1. 캐시에 빈 토큰이 있으면 경고 메시지를 담는다")
void 빈토큰경고() {
registerCredential(GROUP);
// 기존 구현체는 발급에 실패해도 OAuth2AccessTokenVO 반환하고, 그게 그대로 캐시된다.
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(new OAuth2AccessTokenVO());
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertTrue(dto.isCached());
assertFalse(dto.isExpired());
assertEquals(0, dto.getAccessTokenLength());
assertTrue(dto.getMessage().contains("빈 토큰"));
}
@Test
@DisplayName("8-2. 만료시각이 없는 토큰은 재발급되지 않음을 알린다")
void 만료시각없음경고() {
registerCredential(GROUP);
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
vo.setAccessToken("abcdefghijkl");
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(vo);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertTrue(dto.isCached());
assertNull(dto.getExpiration());
assertTrue(dto.getMessage().contains("재발급"));
}
@Test
@DisplayName("8-3. useYn 이 N 이면 스케줄러가 발급하지 않음을 알린다")
void 사용안함경고() {
OutboundOAuthCredentialVo credential = registerCredential(GROUP);
credential.setUseYn("N");
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertTrue(dto.getMessage().contains("사용 안 함"));
assertTrue(dto.getMessage().contains("캐시에 토큰이 없습니다"), "다른 사유도 함께 표시돼야 한다");
}
@Test
@DisplayName("8-4. 어댑터그룹을 찾지 못하면 그 사유를 알린다")
void 어댑터그룹없음경고() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
// 테스트 환경에는 AdapterManager 없으므로 그룹을 찾지 못한 것으로 처리된다.
assertNull(dto.getAdapterTokenServiceClasses());
assertTrue(dto.getMessage().contains("어댑터그룹을 찾을 수 없습니다"));
}
@Test
@DisplayName("8-5. 상대 경로 토큰 URL 은 어댑터 URL 과 조합한다")
void 토큰URL조합() {
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com", "oauth2/token"));
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com/", "oauth2/token"));
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com", "/oauth2/token"));
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com/", "/oauth2/token"));
}
@Test
@DisplayName("8-6. 절대 URL 은 그대로 쓰고, 조합할 수 없으면 null")
void 토큰URL절대경로() {
assertEquals("https://token.example.com/oauth/token",
service.resolveTokenUrl("https://api.example.com", "https://token.example.com/oauth/token"));
assertNull(service.resolveTokenUrl("https://api.example.com", null));
assertNull(service.resolveTokenUrl(null, "oauth2/token"));
}
@Test
@DisplayName("9. 전체 목록은 등록된 어댑터그룹 수만큼 반환한다")
void 전체목록() {
Set<String> groups = new LinkedHashSet<>(Arrays.asList("GRP-A", "GRP-B"));
Mockito.when(mockManager.getRegisteredAdapterGroupNames()).thenReturn(groups);
registerCredential("GRP-A");
registerCredential("GRP-B");
Mockito.when(mockManager.peekAccessTokenVO("GRP-A")).thenReturn(token("abcdefghijkl", 600));
Mockito.when(mockManager.peekAccessTokenVO("GRP-B")).thenReturn(null);
List<OAuthTokenStatusDTO> list = service.getStatusList();
assertEquals(2, list.size());
assertEquals("GRP-A", list.get(0).getAdapterGroupName());
assertTrue(list.get(0).isCached());
assertFalse(list.get(1).isCached());
}
@Test
@DisplayName("9-1. 단건 조회는 이력 전체, 목록은 최근 1건만 담는다")
void 이력포함범위() {
Set<String> groups = new LinkedHashSet<>(Arrays.asList(GROUP));
Mockito.when(mockManager.getRegisteredAdapterGroupNames()).thenReturn(groups);
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
// histories() 안에서 mock 만들므로 when(...) 인자에 직접 넣으면 스터빙이 중첩된다.
List<TokenIssueHistory> histories = histories(3);
Mockito.when(mockManager.getIssueHistories(GROUP)).thenReturn(histories);
OAuthTokenStatusDTO single = service.getStatus(GROUP);
OAuthTokenStatusDTO fromList = service.getStatusList().get(0);
assertEquals(3, single.getIssueHistory().size(), "단건 조회는 보관 중인 이력 전체");
assertEquals(1, fromList.getIssueHistory().size(), "목록은 최근 1건만");
assertEquals("svc-0", fromList.getIssueHistory().get(0).getServiceClass(), "최신 건이어야 한다");
}
@Test
@DisplayName("9-2. 이력이 없으면 빈 목록 (null 아님)")
void 이력없으면_빈목록() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
Mockito.when(mockManager.getIssueHistories(GROUP)).thenReturn(Collections.emptyList());
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertNotNull(dto.getIssueHistory());
assertTrue(dto.getIssueHistory().isEmpty());
}
@Test
@DisplayName("10. 조회가 토큰 발급을 유발하지 않는다 (peek 만 호출)")
void 발급유발없음() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
service.getStatus(GROUP);
Mockito.verify(mockManager).peekAccessTokenVO(GROUP);
Mockito.verify(mockManager, Mockito.never()).getAccessTokenVO(anyString());
}
}