Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c48dd834a | |||
| 11b439aaf1 | |||
| e46678ed76 | |||
| b92518eb5a | |||
| 324d8f8f8a | |||
| 6d3cbe1d8b | |||
| 822ca52ef0 | |||
| c6053055a4 | |||
| 0f9d38918a | |||
| 76a74cd3a1 | |||
| 7723e830cf | |||
| af5695f6bf | |||
| 9eea585b52 | |||
| 66e51d1755 | |||
| 5e07100c3d | |||
| 1bf2c78f03 | |||
| 1941292079 | |||
| abf0de7757 | |||
| a7fde3f81e | |||
| bfec1edea2 |
+30
-4
@@ -75,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") {
|
||||
@@ -91,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'
|
||||
@@ -146,7 +163,13 @@ dependencies {
|
||||
// XmlMapper/JacksonXml* 사용처가 전 소스에 0건이고, 선언 버전(2.13.1)이
|
||||
// 실제 해석되는 jackson-core/databind(2.12.7)와 마이너 불일치라 승격 시 위험했다.
|
||||
|
||||
api "org.springframework.security:spring-security-jwt:1.1.1.RELEASE"
|
||||
// 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'
|
||||
@@ -154,6 +177,9 @@ dependencies {
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test:2.6.15'
|
||||
testImplementation 'junit:junit:4.4'
|
||||
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 {
|
||||
|
||||
+44
-17
@@ -78,7 +78,6 @@ 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;
|
||||
|
||||
@@ -108,6 +107,8 @@ 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\" : ";
|
||||
@@ -135,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");
|
||||
|
||||
@@ -253,23 +260,38 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,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())) {
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+911
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+6
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -119,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());
|
||||
|
||||
@@ -131,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", "비표준 오류 응답 수신");
|
||||
@@ -707,7 +714,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));
|
||||
} else {
|
||||
this.resEaiMsg.setRspErr("RECEAIINA001", "비표준 오류 응답 수신");
|
||||
}
|
||||
@@ -720,6 +727,37 @@ public abstract class DefaultProcess extends Process {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 표준 오류응답 메시지를 조립한다.
|
||||
*
|
||||
* 형식은 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);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isTgtTranCall() {
|
||||
return this.isTgtTran;
|
||||
}
|
||||
|
||||
+609
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+192
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user