Compare commits

...

4 Commits

Author SHA1 Message Date
curry772 0f9d38918a OAuth 토큰 현황 조회 API 진단 항목 보강
- 캐시에 빈 토큰이 있거나 만료시각이 없으면 재발급되지 않음을 message 로 알림
- 그룹 내 어댑터별 ADAPTER_TOKEN_ISSUING_CLIENT_TYPE 을 adapterTokenServiceClasses 로 노출
- 스케줄러와 거래 중 재발급이 서로 다른 어댑터 속성을 쓰는 경우를 확인하기 위함
- 단위테스트 2건 추가
2026-09-11 10:14:19 +09:00
curry772 76a74cd3a1 빈 아웃바운드 OAuth 토큰이 캐시에 고착되는 문제 수정
- 발급 실패를 삼키고 빈 토큰을 반환하는 구현체가 있어 그 토큰이 그대로 캐시되던 문제
- 빈 토큰은 expiration 이 없어 isExpired() 가 항상 false 라 재기동 전까지 재발급되지 않음
- SessionManagerForIgnite : 빈 토큰은 캐시하지 않고 warn 후 다음 주기에 재시도
- AccessTokenManagerByDB : 발급 결과가 비어 있으면 실패로 처리하고 null 반환
2026-09-11 10:14:12 +09:00
curry772 7723e830cf 아웃바운드 OAuth 토큰 현황 조회 API 추가
- GET /manage/oauth-token/status[/{어댑터그룹명}] 조회 전용
- SessionManager.peekOutboundAccessToken 추가 : 캐시만 조회해 상태 조회가 토큰 발급을 유발하지 않도록 함
- AccessTokenManagerByDB 에 조회 전용 getter 추가
- accessToken 은 앞 8자만 노출하고 clientSecret 은 응답에 미포함
- Ehcache 백엔드는 아웃바운드 토큰 캐시 미지원이므로 사유를 담아 응답
- 단위테스트 10건 추가
2026-09-10 16:35:45 +09:00
curry772 af5695f6bf 설정 기반 범용 OAuth 토큰 발급 구현체 추가
- HttpClientAccessTokenServiceWithConfig 신규
- 프로퍼티 그룹 OAuthTokenClient 에서 어댑터그룹별 요청/응답 형태를 읽어 조립
- 값(client_id/secret 등)은 DB, 형태(필드명/전달위치/응답구조)만 프로퍼티로 분리
- 설정이 없으면 WithDefault 와 동일 동작
- 기존 구현체 6종을 설정으로 대체 가능 (WithParamAddBody 는 DB 데이터 정리 선행 필요)
- 단위테스트 40건 추가
- build.gradle: libs 의 json-simple 을 테스트 클래스패스에 추가 (테스트 컴파일 불가 상태 해소)
2026-09-10 16:35:20 +09:00
11 changed files with 2162 additions and 2 deletions
+3
View File
@@ -177,6 +177,9 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test:2.6.15' testImplementation 'org.springframework.boot:spring-boot-starter-test:2.6.15'
testImplementation 'junit:junit:4.4' testImplementation 'junit:junit:4.4'
testImplementation files('libs/kjb-safedb.jar') testImplementation files('libs/kjb-safedb.jar')
// libs 는 main 에서 compileOnly 라 테스트 클래스패스에 오르지 않는다.
// JsonToSetStatusFilterTest 가 org.json.simple.JSONObject 를 쓰므로 테스트에만 추가한다.
testImplementation files('libs/json-simple-1.1.1-custom-1.2.jar')
} }
test { test {
@@ -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. 처리 개요 :
* - 토큰 발급에 필요한 <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;
/**
* 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);
}
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<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);
}
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<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;
}
}
}
@@ -316,6 +316,14 @@ public class AccessTokenManagerByDB implements Lifecycle {
if(service != null) { if(service != null) {
logger.debug("Executing token service of type: {} for adapter group: {}", type, adapterGroupName); logger.debug("Executing token service of type: {} for adapter group: {}", type, adapterGroupName);
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties, outboundOAuthCredentialVo); accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties, outboundOAuthCredentialVo);
// 발급에 실패했는데도 빈 토큰을 반환하는 구현체가 있다. 그대로 캐시되면
// 만료시각이 없어 재발급 대상이 되지 않으므로 실패로 처리한다.
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); logger.info("New token issued successfully for adapter group: {}", adapterGroupName);
return accessToken; return accessToken;
@@ -384,6 +392,39 @@ public class AccessTokenManagerByDB implements Lifecycle {
lifecycle.removeLifecycleListener(listener); lifecycle.removeLifecycleListener(listener);
} }
/**
* 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){ public boolean isOAuthCredentialRegistered(String adapterGroupName){
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName); OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
return outboundOAuthCredentialVo != null && "Y".equals(outboundOAuthCredentialVo.getUseYn()); return outboundOAuthCredentialVo != null && "Y".equals(outboundOAuthCredentialVo.getUseYn());
@@ -214,6 +214,14 @@ public abstract class SessionManager implements Lifecycle {
//Outbound Access Token Cache //Outbound Access Token Cache
public abstract AccessTokenVO getOutboundAccessToken(String key, Function<AccessTokenVO, AccessTokenVO> getNewToken); public abstract AccessTokenVO getOutboundAccessToken(String key, Function<AccessTokenVO, AccessTokenVO> getNewToken);
/**
* 캐시에 있는 토큰만 조회한다. 없거나 만료됐어도 새로 발급하지 않는다. (상태 조회용)
*
* @param key 어댑터그룹명
* @return 캐시에 있는 토큰. 없으면 null.
*/
public abstract AccessTokenVO peekOutboundAccessToken(String key);
public abstract void removeOutboundAccessToken(String key); public abstract void removeOutboundAccessToken(String key);
public abstract void clearOutboundAccessToken(); public abstract void clearOutboundAccessToken();
@@ -951,6 +951,11 @@ public class SessionManagerForEhcache extends SessionManager {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Override
public AccessTokenVO peekOutboundAccessToken(String key) {
throw new UnsupportedOperationException();
}
@Override @Override
public void removeOutboundAccessToken(String key) { public void removeOutboundAccessToken(String key) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
@@ -824,6 +824,14 @@ public class SessionManagerForIgnite extends SessionManager {
return convert(cacheOutBoundAccessToken); return convert(cacheOutBoundAccessToken);
} }
@Override
public AccessTokenVO peekOutboundAccessToken(String key) {
if (cacheOutBoundAccessToken == null) {
return null;
}
return cacheOutBoundAccessToken.get(key);
}
@Override @Override
public void putWebSocketTimeout(String key, SessionVO value) { public void putWebSocketTimeout(String key, SessionVO value) {
cacheWebSocketTimeout.put(key, value); cacheWebSocketTimeout.put(key, value);
@@ -930,7 +938,13 @@ public class SessionManagerForIgnite extends SessionManager {
logger.debug(String.format("getting token is null or expired borrow new token =%s", token)); 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 else
@@ -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,75 @@
package com.eactive.eai.manage.oauthtoken;
import java.util.Map;
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) 때
* 쓰이는 구현체가 달라진다. 그 불일치를 확인하기 위한 항목이다.
*/
Map<String, String> adapterTokenServiceClasses;
/** 토큰 발급 URL */
String tokenUrl;
/** 토큰 재발급 주기(초) */
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;
}
@@ -0,0 +1,201 @@
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.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.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;
/**
* 등록된 모든 어댑터그룹의 토큰 현황을 반환한다.
*
* @return 어댑터그룹명 순으로 정렬된 현황 목록
*/
public List<OAuthTokenStatusDTO> getStatusList() {
List<OAuthTokenStatusDTO> result = new ArrayList<OAuthTokenStatusDTO>();
for (String adapterGroupName : AccessTokenManagerByDB.getInstance().getRegisteredAdapterGroupNames()) {
result.add(getStatus(adapterGroupName));
}
return result;
}
/**
* 어댑터그룹 하나의 토큰 현황을 반환한다.
*
* @param adapterGroupName 어댑터그룹명
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
*/
public OAuthTokenStatusDTO getStatus(String adapterGroupName) {
OAuthTokenStatusDTO dto = new OAuthTokenStatusDTO();
dto.setAdapterGroupName(adapterGroupName);
AccessTokenManagerByDB manager = AccessTokenManagerByDB.getInstance();
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());
dto.setTokenServiceClass(findTokenServiceClass(adapterGroupName));
dto.setAdapterTokenServiceClasses(findAdapterTokenServiceClasses(adapterGroupName));
AccessTokenVO accessToken;
try {
accessToken = manager.peekAccessTokenVO(adapterGroupName);
} catch (UnsupportedOperationException e) {
dto.setMessage("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다.");
return dto;
} catch (Exception e) {
if (logger.isWarn()) {
logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e);
}
dto.setMessage("토큰 캐시 조회 실패 : " + e.getMessage());
return dto;
}
if (accessToken == null) {
dto.setMessage("캐시에 토큰이 없습니다.");
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())) {
dto.setMessage("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다."
+ " 만료시각이 없어 자동 재발급되지 않습니다.");
} else if (expiration == null) {
dto.setMessage("만료시각이 없어 자동 재발급되지 않습니다.");
}
return dto;
}
/**
* 어댑터 속성에서 토큰 발급 구현체 클래스명을 찾는다.
*
* @param adapterGroupName 어댑터그룹명
* @return 구현체 클래스명. 찾지 못하면 null.
*/
private String findTokenServiceClass(String adapterGroupName) {
try {
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (gvo == null || !gvo.getAdapters().hasNext()) {
return null;
}
AdapterVO avo = gvo.getAdapters().next();
Properties properties = AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
return properties.getProperty(PROP_TOKEN_SERVICE_CLASS);
} catch (Exception e) {
if (logger.isDebug()) {
logger.debug("토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName);
}
return null;
}
}
/**
* 그룹에 속한 어댑터별로 토큰 발급 구현체 클래스명을 모은다.
*
* 스케줄러(issueToken)는 getAdapters().next() 로 어댑터 하나만 골라 쓰고, 거래 중 재발급
* (retryAccessTokenVO)은 호출한 어댑터 자신의 속성을 쓴다. 어댑터마다 설정이 다르면
* 서로 다른 구현체가 동작하므로 전체를 보여준다.
*
* @param adapterGroupName 어댑터그룹명
* @return 어댑터명 → 구현체 클래스명. 조회 실패 시 빈 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 result;
}
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 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) + "***";
}
}
@@ -0,0 +1,609 @@
package com.eactive.eai.authoutbound.client.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.nullable;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.context.ApplicationContext;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.property.PropManager;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.eactive.eai.common.util.JacksonUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* HttpClientAccessTokenServiceWithConfig 단위테스트
*
* - HTTP 호출부(execute)는 외부 연동이 필요하므로 제외하고, 설정 해석/요청 조립/응답 파싱 로직을 검증한다.
* - 대상 메서드는 package-private 이므로 같은 패키지에서 직접 호출한다.
* - PropManager 는 ApplicationContextProvider 에 mock 을 리플렉션으로 주입해 격리한다.
* (TemplateAdapterErrorMsgHandlerTest 와 동일한 방식)
*/
class HttpClientAccessTokenServiceWithConfigTest {
/** 테스트에서 사용하는 어댑터그룹명 */
private static final String GROUP = "TESTGRP";
private HttpClientAccessTokenServiceWithConfig service;
/** mock PropManager 가 바라보는 프로퍼티 저장소 */
private Properties props;
@BeforeEach
void setUp() throws Exception {
service = new HttpClientAccessTokenServiceWithConfig();
props = new Properties();
injectMockPropManager(true);
}
// ================================================================
// 공통 헬퍼
// ================================================================
/**
* ApplicationContextProvider 에 mock PropManager 를 주입한다.
*
* @param containProperties isContainProperties() 반환값
*/
private void injectMockPropManager(boolean containProperties) throws Exception {
PropManager mockPropManager = Mockito.mock(PropManager.class);
Mockito.when(mockPropManager.getProperty(anyString(), anyString(), nullable(String.class)))
.thenAnswer(invocation -> props.getProperty(invocation.getArgument(1), invocation.getArgument(2)));
Mockito.when(mockPropManager.isContainProperties(anyString())).thenReturn(containProperties);
Mockito.when(mockPropManager.getProperties(anyString())).thenReturn(props);
ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
Mockito.when(mockCtx.getBean(PropManager.class)).thenReturn(mockPropManager);
Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
ctxField.setAccessible(true);
ctxField.set(null, mockCtx);
}
/** "{어댑터그룹명}.{key}" 프로퍼티를 설정한다. */
private void prop(String key, String value) {
props.setProperty(GROUP + "." + key, value);
}
/** clientId/clientSecret 만 채운 VO */
private OutboundOAuthCredentialVo vo(String clientId, String clientSecret) {
OutboundOAuthCredentialVo vo = new OutboundOAuthCredentialVo();
vo.setClientId(clientId);
vo.setClientSecret(clientSecret);
return vo;
}
/** intervalSec 까지 채운 VO */
private OutboundOAuthCredentialVo vo(String clientId, String clientSecret, int intervalSec) {
OutboundOAuthCredentialVo vo = vo(clientId, clientSecret);
vo.setIntervalSec(intervalSec);
return vo;
}
// ================================================================
// 응답 파싱
// ================================================================
@Nested
@DisplayName("parseToken - 응답 파싱")
class ParseToken {
@Test
@DisplayName("1-1. 표준 응답의 모든 필드를 매핑한다")
void 표준응답() throws Exception {
String response = "{\"access_token\":\"tok-1\",\"token_type\":\"Bearer\",\"expires_in\":100,\"scope\":\"read\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 1_000_000L, vo("id", "secret"));
assertEquals("tok-1", token.getAccessToken());
assertEquals("Bearer", ((OAuth2AccessTokenVO) token).getTokenType());
assertEquals("read", ((OAuth2AccessTokenVO) token).getScope());
assertEquals(1_000_000L + 100 * 1000L, token.getExpiration().getTime());
}
@Test
@DisplayName("1-2. scope 가 없어도 토큰이 채워진다 (기존 구현체 회귀 방지)")
void scope없어도_토큰세팅() throws Exception {
// 기존 구현체 5개는 access_token/token_type/expires_in/scope 가 모두 있어야 세팅했고,
// 하나라도 없으면 빈 토큰을 정상인 것처럼 반환했다.
String response = "{\"access_token\":\"tok-2\",\"token_type\":\"Bearer\",\"expires_in\":100}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals("tok-2", token.getAccessToken());
assertNull(((OAuth2AccessTokenVO) token).getScope());
}
@Test
@DisplayName("1-3. access_token 이 없으면 예외")
void accessToken없음() {
String response = "{\"token_type\":\"Bearer\",\"expires_in\":100}";
Exception e = assertThrows(Exception.class,
() -> service.parseToken(GROUP, response, 0L, vo("id", "secret")));
assertTrue(e.getMessage().contains("access_token"));
}
@Test
@DisplayName("1-4. dot-path 로 중첩된 응답을 매핑한다 (NiceOn dataBody 형태)")
void dotPath매핑() throws Exception {
prop("response.field.access-token", "dataBody.access_token");
prop("response.field.token-type", "dataBody.token_type");
prop("response.field.expires-in", "dataBody.expires_in");
prop("response.field.scope", "dataBody.scope");
String response = "{\"dataHeader\":{\"GW_RSLT_CD\":\"1200\"},"
+ "\"dataBody\":{\"access_token\":\"tok-3\",\"token_type\":\"Bearer\",\"expires_in\":60,\"scope\":\"all\"}}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals("tok-3", token.getAccessToken());
assertEquals("all", ((OAuth2AccessTokenVO) token).getScope());
}
@Test
@DisplayName("1-5. 성공코드가 일치하면 정상 처리한다")
void 성공코드일치() throws Exception {
prop("response.success.field", "dataHeader.GW_RSLT_CD");
prop("response.success.value", "1200");
prop("response.field.access-token", "dataBody.access_token");
String response = "{\"dataHeader\":{\"GW_RSLT_CD\":\"1200\"},\"dataBody\":{\"access_token\":\"tok-4\"}}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals("tok-4", token.getAccessToken());
}
@Test
@DisplayName("1-6. 성공코드가 다르면 예외 (기존 NiceOn 은 NPE 였다)")
void 성공코드불일치() {
prop("response.success.field", "dataHeader.GW_RSLT_CD");
prop("response.success.value", "1200");
prop("response.field.access-token", "dataBody.access_token");
String response = "{\"dataHeader\":{\"GW_RSLT_CD\":\"9999\"},\"dataBody\":{\"access_token\":\"tok-5\"}}";
Exception e = assertThrows(Exception.class,
() -> service.parseToken(GROUP, response, 0L, vo("id", "secret")));
assertTrue(e.getMessage().contains("9999"));
}
@Test
@DisplayName("1-7. 성공코드 필드 자체가 없으면 예외")
void 성공코드필드없음() {
prop("response.success.field", "dataHeader.GW_RSLT_CD");
prop("response.success.value", "1200");
String response = "{\"access_token\":\"tok-6\"}";
assertThrows(Exception.class, () -> service.parseToken(GROUP, response, 0L, vo("id", "secret")));
}
@Test
@DisplayName("1-8. expires_in 이 없으면 설정한 기본값을 쓴다")
void expiresIn_프로퍼티기본값() throws Exception {
prop("response.expires-in.default", "7200");
String response = "{\"access_token\":\"tok-7\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret", 300));
assertEquals(7200 * 1000L, token.getExpiration().getTime());
}
@Test
@DisplayName("1-9. expires_in 도 설정도 없으면 intervalSec 을 쓴다 (DJBErpNsmapi 형태)")
void expiresIn_intervalSec() throws Exception {
String response = "{\"data\":{\"access_token\":\"tok-8\"}}";
prop("response.field.access-token", "data.access_token");
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret", 300));
assertEquals(300 * 1000L, token.getExpiration().getTime());
}
@Test
@DisplayName("1-10. expires_in / 설정 / intervalSec 이 모두 없으면 3600 초")
void expiresIn_기본3600() throws Exception {
String response = "{\"access_token\":\"tok-9\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals(3600 * 1000L, token.getExpiration().getTime());
}
@Test
@DisplayName("1-11. none 으로 끈 응답 필드는 조회하지 않는다")
void none으로_끈필드() throws Exception {
prop("response.field.token-type", "none");
prop("response.field.scope", "none");
String response = "{\"access_token\":\"tok-10\",\"token_type\":\"Bearer\",\"scope\":\"read\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("id", "secret"));
assertEquals("tok-10", token.getAccessToken());
assertNull(((OAuth2AccessTokenVO) token).getTokenType());
assertNull(((OAuth2AccessTokenVO) token).getScope());
}
@Test
@DisplayName("1-12. client_use_code 와 clientId 를 채운다")
void clientUseCode와_clientId() throws Exception {
String response = "{\"access_token\":\"tok-11\",\"client_use_code\":\"U01\"}";
AccessTokenVO token = service.parseToken(GROUP, response, 0L, vo("my-client", "secret"));
assertEquals("U01", ((OAuth2AccessTokenVO) token).getClientUseCode());
assertEquals("my-client", ((OAuth2AccessTokenVO) token).getClientId());
}
}
// ================================================================
// Content-Type
// ================================================================
@Nested
@DisplayName("buildContentTypeHeader - Content-Type 조립")
class ContentTypeHeader {
@Test
@DisplayName("2-1. 기본은 charset 을 붙인다")
void 기본charset부착() {
assertEquals("application/x-www-form-urlencoded; charset=UTF-8",
service.buildContentTypeHeader(GROUP, "application/x-www-form-urlencoded", "UTF-8"));
}
@Test
@DisplayName("2-2. content-type.charset=N 이면 charset 을 붙이지 않는다")
void charset미부착() {
prop("content-type.charset", "N");
assertEquals("application/x-www-form-urlencoded",
service.buildContentTypeHeader(GROUP, "application/x-www-form-urlencoded", "UTF-8"));
}
@Test
@DisplayName("2-3. charset=N 이면 뒤에 붙은 세미콜론도 정리한다")
void charset미부착_세미콜론정리() {
prop("content-type.charset", "N");
assertEquals("application/json", service.buildContentTypeHeader(GROUP, "application/json;", "UTF-8"));
}
@Test
@DisplayName("2-4. 이미 charset 이 있으면 중복해서 붙이지 않는다")
void charset중복방지() {
assertEquals("application/json; charset=EUC-KR",
service.buildContentTypeHeader(GROUP, "application/json; charset=EUC-KR", "UTF-8"));
}
@Test
@DisplayName("2-5. 세미콜론으로 끝나도 구분자가 중복되지 않는다")
void 구분자중복방지() {
assertEquals("application/json; charset=UTF-8",
service.buildContentTypeHeader(GROUP, "application/json;", "UTF-8"));
}
@Test
@DisplayName("2-6. json 계열 판정 (vnd.+json, 대소문자, 파라미터 포함)")
void json판정() {
assertTrue(service.isJsonContentType("application/json"));
assertTrue(service.isJsonContentType("Application/JSON; charset=UTF-8"));
assertTrue(service.isJsonContentType("application/vnd.api+json"));
assertFalse(service.isJsonContentType("text/xml"));
}
@Test
@DisplayName("2-7. form 계열 판정")
void form판정() {
assertTrue(service.isFormContentType("application/x-www-form-urlencoded"));
assertTrue(service.isFormContentType("APPLICATION/X-WWW-FORM-URLENCODED; charset=UTF-8"));
assertFalse(service.isFormContentType("application/json"));
assertFalse(service.isFormContentType("text/xml"));
}
}
// ================================================================
// 자격증명 헤더 값
// ================================================================
@Nested
@DisplayName("resolveCredentialValue - 헤더에 담을 자격증명")
class CredentialValue {
@Test
@DisplayName("3-1. basic 은 base64(id:secret)")
void basic() throws Exception {
String expected = Base64.getEncoder()
.encodeToString("id:secret".getBytes(StandardCharsets.UTF_8));
assertEquals(expected, service.resolveCredentialValue(GROUP, "basic", vo("id", "secret")));
}
@Test
@DisplayName("3-2. client-id 는 DB 값 원본 (DJBErpNsmapi 형태)")
void clientId원본() throws Exception {
assertEquals("already-encoded-value",
service.resolveCredentialValue(GROUP, "client-id", vo("already-encoded-value", "secret")));
}
@Test
@DisplayName("3-3. client-secret 은 secret 값")
void clientSecret() throws Exception {
assertEquals("secret", service.resolveCredentialValue(GROUP, "client-secret", vo("id", "secret")));
}
@Test
@DisplayName("3-4. 지원하지 않는 값이면 예외")
void 미지원값() {
Exception e = assertThrows(Exception.class,
() -> service.resolveCredentialValue(GROUP, "unknown", vo("id", "secret")));
assertTrue(e.getMessage().contains("credential.header.value"));
}
}
// ================================================================
// 필드명 매핑 / 프로퍼티 조회
// ================================================================
@Nested
@DisplayName("getFieldName / getPropsByPrefix - 설정 조회")
class PropLookup {
@Test
@DisplayName("4-1. 미설정이면 기본 필드명을 쓴다")
void 미설정() {
assertEquals("client_id", service.getFieldName(GROUP, "request.field.client-id", "client_id"));
}
@Test
@DisplayName("4-2. 설정하면 설정한 필드명을 쓴다")
void 설정됨() {
prop("request.field.client-id", "clientKey");
assertEquals("clientKey", service.getFieldName(GROUP, "request.field.client-id", "client_id"));
}
@Test
@DisplayName("4-3. none 이면 null (대소문자 무시)")
void none이면null() {
prop("request.field.client-id", "none");
prop("request.field.scope", "NONE");
assertNull(service.getFieldName(GROUP, "request.field.client-id", "client_id"));
assertNull(service.getFieldName(GROUP, "request.field.scope", "scope"));
}
@Test
@DisplayName("4-4. header. 접두어 스캔은 credential.header.* 를 걷어오지 않는다")
void header접두어스캔() {
prop("header.X-API-KEY", "abcd");
prop("header.X-TRACE", "t-1");
prop("credential.header.name", "Authorization");
prop("body.institution_code", "0088");
Map<String, String> headers = service.getPropsByPrefix(GROUP, "header.");
assertEquals(2, headers.size());
assertEquals("abcd", headers.get("X-API-KEY"));
assertEquals("t-1", headers.get("X-TRACE"));
}
@Test
@DisplayName("4-5. body. 접두어 스캔")
void body접두어스캔() {
prop("body.institution_code", "0088");
prop("header.X-API-KEY", "abcd");
Map<String, String> body = service.getPropsByPrefix(GROUP, "body.");
assertEquals(1, body.size());
assertEquals("0088", body.get("institution_code"));
}
@Test
@DisplayName("4-6. 다른 어댑터그룹의 설정은 걷어오지 않는다")
void 다른그룹제외() {
prop("header.X-API-KEY", "mine");
props.setProperty("OTHERGRP.header.X-API-KEY", "other");
Map<String, String> headers = service.getPropsByPrefix(GROUP, "header.");
assertEquals(1, headers.size());
assertEquals("mine", headers.get("X-API-KEY"));
}
@Test
@DisplayName("4-7. 프로퍼티 그룹 자체가 없으면 빈 Map (RuntimeException 방지)")
void 그룹없음() throws Exception {
// PropManager.getProperties() 는 그룹이 없으면 RuntimeException 을 던진다.
injectMockPropManager(false);
assertTrue(service.getPropsByPrefix(GROUP, "header.").isEmpty());
}
}
// ================================================================
// 필드 전송 위치 (바디 / 헤더)
// ================================================================
@Nested
@DisplayName("parseInHeaderFields / assignField - 필드 전송 위치")
class FieldPlacement {
private Map<String, String> params;
private Map<String, String> headers;
@BeforeEach
void init() {
params = new LinkedHashMap<>();
headers = new LinkedHashMap<>();
}
/** VO 값 4개를 in-header 설정에 따라 배치한다. */
private void assignAll() {
Set<String> inHeader = service.parseInHeaderFields(GROUP);
service.assignField(params, headers, inHeader, "client-id",
service.getFieldName(GROUP, "request.field.client-id", "client_id"), "id-1");
service.assignField(params, headers, inHeader, "client-secret",
service.getFieldName(GROUP, "request.field.client-secret", "client_secret"), "secret-1");
service.assignField(params, headers, inHeader, "scope",
service.getFieldName(GROUP, "request.field.scope", "scope"), "read");
service.assignField(params, headers, inHeader, "grant-type",
service.getFieldName(GROUP, "request.field.grant-type", "grant_type"), "client_credentials");
}
@Test
@DisplayName("6-1. 미설정이면 4개 모두 바디로 간다 (기존 동작)")
void 기본은바디() {
assignAll();
assertEquals(4, params.size());
assertTrue(headers.isEmpty());
assertEquals("id-1", params.get("client_id"));
}
@Test
@DisplayName("6-2. 4개를 모두 나열하면 바디가 비고 전부 헤더로 간다")
void 전부헤더() {
prop("request.fields.in-header", "client-id,client-secret,scope,grant-type");
assignAll();
assertTrue(params.isEmpty(), "바디로 갈 파라미터가 없어야 한다 (바디 자체를 붙이지 않는 조건)");
assertEquals(4, headers.size());
assertEquals("id-1", headers.get("client_id"));
assertEquals("secret-1", headers.get("client_secret"));
assertEquals("read", headers.get("scope"));
assertEquals("client_credentials", headers.get("grant_type"));
}
@Test
@DisplayName("6-3. 헤더명은 request.field.* 매핑을 그대로 쓴다")
void 헤더명매핑() {
prop("request.fields.in-header", "client-id,client-secret,scope,grant-type");
prop("request.field.client-id", "X-CLIENT-ID");
prop("request.field.client-secret", "X-CLIENT-SECRET");
prop("request.field.scope", "X-SCOPE");
prop("request.field.grant-type", "X-GRANT-TYPE");
assignAll();
assertTrue(params.isEmpty());
assertEquals("id-1", headers.get("X-CLIENT-ID"));
assertEquals("secret-1", headers.get("X-CLIENT-SECRET"));
assertEquals("read", headers.get("X-SCOPE"));
assertEquals("client_credentials", headers.get("X-GRANT-TYPE"));
}
@Test
@DisplayName("6-4. 일부만 나열하면 나머지는 바디로 간다")
void 일부만헤더() {
prop("request.fields.in-header", "scope, grant-type");
assignAll();
assertEquals(2, params.size());
assertEquals("id-1", params.get("client_id"));
assertEquals("secret-1", params.get("client_secret"));
assertEquals(2, headers.size());
assertEquals("read", headers.get("scope"));
}
@Test
@DisplayName("6-5. 공백/대소문자/빈 항목을 허용한다")
void 목록파싱() {
prop("request.fields.in-header", " Client-ID , , SCOPE ");
Set<String> fields = service.parseInHeaderFields(GROUP);
assertEquals(2, fields.size());
assertTrue(fields.contains("client-id"));
assertTrue(fields.contains("scope"));
}
@Test
@DisplayName("6-6. 미설정이면 빈 집합")
void 미설정이면빈집합() {
assertTrue(service.parseInHeaderFields(GROUP).isEmpty());
}
@Test
@DisplayName("6-7. none 으로 끈 필드는 헤더로도 가지 않는다")
void none은전송안함() {
prop("request.fields.in-header", "client-id,scope");
prop("request.field.scope", "none");
assignAll();
assertEquals(1, headers.size());
assertEquals("id-1", headers.get("client_id"));
assertFalse(headers.containsKey("scope"));
assertFalse(params.containsKey("scope"));
}
}
// ================================================================
// dot-path
// ================================================================
@Nested
@DisplayName("findByPath / putJsonPath - dot-path 처리")
class DotPath {
private final ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
@Test
@DisplayName("5-1. 중첩 경로를 조회한다")
void 중첩조회() throws Exception {
JsonNode root = mapper.readTree("{\"a\":{\"b\":{\"c\":\"v\"}}}");
assertEquals("v", service.findByPath(root, "a.b.c").asText());
}
@Test
@DisplayName("5-2. 없는 경로 / null 값 / 빈 경로는 null")
void 없는경로() throws Exception {
JsonNode root = mapper.readTree("{\"a\":{\"b\":null}}");
assertNull(service.findByPath(root, "a.b"));
assertNull(service.findByPath(root, "a.x"));
assertNull(service.findByPath(root, "x.y.z"));
assertNull(service.findByPath(root, null));
assertNull(service.findByPath(root, ""));
}
@Test
@DisplayName("5-3. 중첩 경로로 요청 바디를 만든다")
void 중첩생성() {
ObjectNode root = mapper.createObjectNode();
service.putJsonPath(root, "auth.clientId", "id-1");
service.putJsonPath(root, "auth.clientSecret", "secret-1");
service.putJsonPath(root, "grant_type", "client_credentials");
assertEquals("id-1", root.path("auth").path("clientId").asText());
assertEquals("secret-1", root.path("auth").path("clientSecret").asText());
assertEquals("client_credentials", root.path("grant_type").asText());
assertNotNull(root.get("auth"));
}
}
}
@@ -0,0 +1,241 @@
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.Arrays;
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.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;
}
/** 만료까지 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("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("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());
}
}