diff --git a/build.gradle b/build.gradle index c2e6d7b..5f8d1fe 100644 --- a/build.gradle +++ b/build.gradle @@ -177,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 { diff --git a/src/main/java/com/eactive/eai/authoutbound/client/impl/HttpClientAccessTokenServiceWithConfig.java b/src/main/java/com/eactive/eai/authoutbound/client/impl/HttpClientAccessTokenServiceWithConfig.java new file mode 100644 index 0000000..2183554 --- /dev/null +++ b/src/main/java/com/eactive/eai/authoutbound/client/impl/HttpClientAccessTokenServiceWithConfig.java @@ -0,0 +1,901 @@ + +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.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. 처리 개요 : + * - 토큰 발급에 필요한 (client_id/secret/scope/grant_type/URL)은 기존과 동일하게 DB(OutboundOAuthCredentialVo)에서 읽고, + * 사이트마다 달라지는 형태(파라미터명, 자격증명 전달 위치, 응답 구조)는 PropManager 의 + * "{@value #PROP_GROUP}" 프로퍼티 그룹에서 어댑터그룹 단위로 읽어 조립한다. + * - 프로퍼티를 하나도 설정하지 않으면 HttpClientAccessTokenServiceWithDefault 와 동일하게 동작한다. + * + *
+ * {어댑터그룹명}.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
+ * 
+ * + * 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; + + /** + * mTLS 여부/clientId 조합별로 CloseableHttpClient(및 내부 PoolingHttpClientConnectionManager)를 + * 1회만 생성해 재사용한다. 이 서비스 인스턴스는 HttpClientAccessTokenServiceFactoryByDB 에 + * className 기준으로 캐시되어 재사용되므로, 이 필드도 인스턴스 생명주기 동안 안전하게 재사용된다. + * (어댑터그룹별 설정을 들고 있는 것이 아니므로 stateless 원칙에 어긋나지 않는다) + */ + private final ConcurrentHashMap httpClientCache = new ConcurrentHashMap(); + + /** + * 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 inHeaderFields = parseInHeaderFields(name); + + warnIgnoredCredentialFields(name, location, inHeaderFields); + + // 요청 바디(또는 GET 쿼리) 파라미터와 요청 헤더 구성 + Map params = new LinkedHashMap(); + Map headers = new LinkedHashMap(); + + 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 addBodyMap = bodyJsonMapper.readValue(addBodyJson, Map.class); + for (Map.Entry 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 addHeaderMap = headerJsonMapper.readValue(addHeaderJson, Map.class); + for (Map.Entry 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 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 entry : params.entrySet()) { + putJsonPath(bodyNode, entry.getKey(), entry.getValue()); + } + request.setEntity(new StringEntity(objMapper.writeValueAsString(bodyNode), charset)); + } else if (isFormContentType(contentType)) { + List formParams = new ArrayList(); + for (Map.Entry 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 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); + } + + RequestConfig requestConfig = requestConfigBuilder + .setConnectTimeout(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 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); + } + + HttpClientBuilder builder = HttpClients.custom().setConnectionManager(connectionManager); + 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 parseInHeaderFields(String name) { + Set fields = new LinkedHashSet(); + 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 params, Map headers, Set 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 getPropsByPrefix(String name, String prefix) { + Map result = new LinkedHashMap(); + 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 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; + } + } +} diff --git a/src/test/java/com/eactive/eai/authoutbound/client/impl/HttpClientAccessTokenServiceWithConfigTest.java b/src/test/java/com/eactive/eai/authoutbound/client/impl/HttpClientAccessTokenServiceWithConfigTest.java new file mode 100644 index 0000000..b6152d7 --- /dev/null +++ b/src/test/java/com/eactive/eai/authoutbound/client/impl/HttpClientAccessTokenServiceWithConfigTest.java @@ -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 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 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 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 params; + private Map headers; + + @BeforeEach + void init() { + params = new LinkedHashMap<>(); + headers = new LinkedHashMap<>(); + } + + /** VO 값 4개를 in-header 설정에 따라 배치한다. */ + private void assignAll() { + Set 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 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")); + } + } +}