Compare commits

..

8 Commits

Author SHA1 Message Date
curry772 1c48dd834a 토큰 현황 목록 조회에 최근 발급 이력 1건 포함
- 목록에서 issueHistory 가 null 로만 나와 "값이 없는 것"과 "안 실린 것"이 구분되지 않던 문제
- 목록은 최근 1건, 단건 조회는 보관 중인 전체(최대 10건)를 담는다
- 이력이 없으면 null 대신 빈 목록을 반환하고, serverName 도 함께 담는다
- 단위테스트 2건 추가
2026-09-11 17:35:36 +09:00
curry772 11b439aaf1 클러스터에서 토큰이 중복 발급되던 문제 수정
- near 캐시 사본 때문에 다른 노드가 방금 교체한 토큰을 못 보고 같은 주기에 두 번 발급되던 문제
  (분산락을 잡은 뒤의 조회에서도 옛 값을 읽어 락만으로는 막지 못했음)
- 캐시 조회를 짧은 비관적 트랜잭션으로 감싸 primary 값을 읽도록 함
  발급 HTTP 호출은 트랜잭션 밖에 두어 장기 트랜잭션이 파티션 맵 교환을 막지 않게 함
- 재발급 판정에 validUntilTime 추가 : 요구 시각까지 유효한 토큰이 있으면 발급하지 않음
  거래 중 재발급은 0 을 넘겨 만료 기준 검사를 건너뛴다 (만료 전이어도 기관이 거부한 상황)
- 2노드 시험에서 주기당 발급 1회, 만료 창 0건 확인
- 단위테스트 3건 추가
2026-09-11 15:05:18 +09:00
curry772 e46678ed76 토큰 만료 전 선제 갱신
- getOutboundAccessToken 은 토큰이 없거나 이미 만료됐을 때만 발급 함수를 호출해서,
  그 안에 있던 "다음 스케줄 전에 만료되면 재발급" 판정에 도달하지 못했음
  (토큰이 항상 만료된 뒤에야 갱신되어 최대 intervalSec 동안 만료 토큰을 사용)
- 스케줄러가 peekOutboundAccessToken 으로 먼저 확인하고, 다음 틱 전에 만료되면
  reissueOutboundAccessToken 으로 미리 갱신하도록 판정 위치를 옮김
- 여러 노드가 동시에 들어와도 분산락 + oldToken 비교로 발급은 한 번만 일어남
- 단위테스트 5건 추가
2026-09-11 14:08:18 +09:00
curry772 b92518eb5a 거래 중 토큰 재발급을 분산락 경로로 통합
- 재발급 결과를 캐시에 반영하지 않아 거래마다 발급되고, synchronized 가 JVM 단위라
  클러스터에서 노드 수만큼 중복 발급되던 문제
- SessionManager.reissueOutboundAccessToken 추가 : 분산락 안에서 oldToken 을 비교해
  이미 갱신됐으면 그 토큰을 쓰고, 아니면 한 번만 발급 후 캐시에 반영
- 발급 구현체는 호출자가 넘긴 어댑터 속성 기준으로 선택 (기존 의미 유지)
- HttpClient5AdapterServiceRest : 응답 기반 재발급도 DB 기반 매니저를 쓰도록 정리
- 아웃바운드 토큰 분산락 키를 전역 상수에서 어댑터그룹별로 변경
- 단위테스트 12건 추가 (경합 상황 포함)
2026-09-11 14:08:04 +09:00
curry772 324d8f8f8a OAuth 토큰 발급 이력 조회 기능 추가
- 어댑터그룹별 최근 10건을 노드 메모리에 보관. 성공뿐 아니라 실패도 남긴다
  (발급 실패는 캐시에 아무것도 남지 않아 사후 추적이 어려웠음)
- 기록 항목 : 시각, SCHEDULE/RETRY, 구현체, 성공여부, 소요시간, 만료시각, 실패사유
- accessToken 은 기록 시점에 앞 8자만 남겨 보관
- GET /manage/oauth-token/status/{어댑터그룹명} 에만 포함. 목록 조회에는 미포함
- 이력이 노드 로컬이므로 응답에 serverName 을 함께 담는다
- 단위테스트 6건 추가
2026-09-11 14:07:50 +09:00
curry772 6d3cbe1d8b 거래 중 토큰 재발급 시 새 토큰을 헤더에 반영
- 재발급받은 newaccessToken 을 쓰지 않고 방금 실패한 토큰을 그대로 헤더에 넣어
  재시도가 같은 사유로 실패하던 문제
- newaccessToken 이 null 인 경우(구현체 미확인)에는 기존 동작 유지
- Bypass 계열과 HttpClientAdapterServiceRest 는 이미 새 토큰을 사용 중
2026-09-11 12:39:24 +09:00
curry772 822ca52ef0 OAuth 토큰 현황 조회 API 진단 정보 추가
- 토큰이 발급되지 않는 사유를 message 에 모아서 표시
  (useYn=N, 어댑터그룹 없음, 이 서버에 배정된 어댑터 없음, 캐시 없음 등)
- tokenUrlResolved 추가 : 상대 경로 토큰 URL 을 어댑터 URL 과 조합한 실제 호출 주소
- adapterTokenServiceClasses 는 어댑터그룹을 찾지 못하면 null, 어댑터가 없으면 빈 값으로 구분
- 단위테스트 4건 추가
2026-09-11 11:13:30 +09:00
curry772 c6053055a4 토큰 발급 시 커넥션 풀 고갈 방지
- 풀 대기 시간 미지정 시 기본 3분을 기다려, 스케줄러 태스크 타임아웃(30초)에 먼저 인터럽트되고
  커넥션이 반납되지 않아 캐시한 풀이 마르는 문제
- setConnectionRequestTimeout 지정으로 풀 대기도 CONNECTION_TIMEOUT 을 따르도록 함
- evictExpiredConnections / evictIdleConnections 추가로 캐시한 풀의 죽은 커넥션 정리
2026-09-11 11:13:29 +09:00
14 changed files with 1410 additions and 75 deletions
@@ -78,7 +78,6 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.JsonPath;
import com.openbanking.eai.common.token.AccessTokenManager;
import com.openbanking.eai.common.token.AccessTokenVO; import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO; import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
@@ -604,12 +603,17 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
+ "]"); + "]");
} }
// 토큰 재발급 // 토큰 재발급. 전송 전 조회(:353)와 같은 DB 기반 매니저를 써야 Ignite 캐시가 갱신된다.
AccessTokenManager tokenManager = AccessTokenManager.getInstance(); AccessTokenManagerByDB tokenManager = AccessTokenManagerByDB.getInstance();
String oldToken = accessToken == null ? null : accessToken.getAccessToken(); String oldToken = accessToken == null ? null : accessToken.getAccessToken();
OAuth2AccessTokenVO newaccessToken = (OAuth2AccessTokenVO) tokenManager OAuth2AccessTokenVO newaccessToken = (OAuth2AccessTokenVO) tokenManager
.retryAccessTokenVO(vo.getAdapterGroupName(), prop, oldToken); .retryAccessTokenVO(vo.getAdapterGroupName(), prop, oldToken);
// 재발급받은 토큰으로 헤더를 다시 세팅한다. 기존에는 방금 실패한 토큰을 그대로 넣어
// 재시도가 같은 사유로 실패했다.
if (newaccessToken != null) {
accessToken = newaccessToken;
}
setAuthHeaders(method, accessToken, authorizationHeaderName); setAuthHeaders(method, accessToken, authorizationHeaderName);
if (logger.isDebug() && "N".equals(vo.getTestCallYn())) { if (logger.isDebug() && "N".equals(vo.getTestCallYn())) {
@@ -41,6 +41,7 @@ import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.message.BasicNameValuePair; import org.apache.hc.core5.http.message.BasicNameValuePair;
import org.apache.hc.core5.net.URIBuilder; import org.apache.hc.core5.net.URIBuilder;
import org.apache.hc.core5.ssl.SSLContextBuilder; import org.apache.hc.core5.ssl.SSLContextBuilder;
import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout; import org.apache.hc.core5.util.Timeout;
import org.springframework.security.web.util.UrlUtils; import org.springframework.security.web.util.UrlUtils;
@@ -155,6 +156,9 @@ public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessT
private static final long DEFAULT_EXPIRES_IN = 3600L; private static final long DEFAULT_EXPIRES_IN = 3600L;
/** 캐시한 HttpClient 의 유휴 커넥션 정리 주기(초) */
private static final int IDLE_CONNECTION_EVICT_SECONDS = 30;
/** /**
* mTLS 여부/clientId 조합별로 CloseableHttpClient(및 내부 PoolingHttpClientConnectionManager)를 * mTLS 여부/clientId 조합별로 CloseableHttpClient(및 내부 PoolingHttpClientConnectionManager)를
* 1회만 생성해 재사용한다. 이 서비스 인스턴스는 HttpClientAccessTokenServiceFactoryByDB 에 * 1회만 생성해 재사용한다. 이 서비스 인스턴스는 HttpClientAccessTokenServiceFactoryByDB 에
@@ -381,8 +385,11 @@ public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessT
requestConfigBuilder.setProxy(proxy); requestConfigBuilder.setProxy(proxy);
} }
// 커넥션 풀 대기 시간을 지정하지 않으면 기본 3분을 기다린다. 스케줄러의 태스크 타임아웃(30초)이
// 먼저 걸려 요청이 인터럽트되고, 그 과정에서 커넥션이 반납되지 않아 풀이 마르는 악순환이 생긴다.
RequestConfig requestConfig = requestConfigBuilder RequestConfig requestConfig = requestConfigBuilder
.setConnectTimeout(Timeout.ofMilliseconds(connectionTimeout)) .setConnectTimeout(Timeout.ofMilliseconds(connectionTimeout))
.setConnectionRequestTimeout(Timeout.ofMilliseconds(connectionTimeout))
.setResponseTimeout(Timeout.ofMilliseconds(timeout)).build(); .setResponseTimeout(Timeout.ofMilliseconds(timeout)).build();
request.setConfig(requestConfig); request.setConfig(requestConfig);
@@ -641,7 +648,10 @@ public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessT
adapterGroupName, useMtls, clientId, contentCompression, defaultUserAgent); adapterGroupName, useMtls, clientId, contentCompression, defaultUserAgent);
} }
HttpClientBuilder builder = HttpClients.custom().setConnectionManager(connectionManager); // HttpClient 를 캐시해 재사용하므로 죽은 커넥션이 풀에 남지 않도록 정리 설정을 건다.
HttpClientBuilder builder = HttpClients.custom().setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(TimeValue.ofSeconds(IDLE_CONNECTION_EVICT_SECONDS));
if (!contentCompression) { if (!contentCompression) {
builder.disableContentCompression(); builder.disableContentCompression();
} }
@@ -68,6 +68,15 @@ public class AccessTokenManagerByDB implements Lifecycle {
*/ */
private Map<String, OutboundOAuthCredentialVo> outboundOAuthCredentialVos; private Map<String, OutboundOAuthCredentialVo> outboundOAuthCredentialVos;
/** 어댑터그룹별로 보관할 토큰 발급 이력 건수 */
private static final int ISSUE_HISTORY_SIZE = 10;
/** 이력에 남길 accessToken 앞자리 수 */
private static final int UNMASKED_TOKEN_LENGTH = 8;
/** 어댑터그룹별 토큰 발급 이력. 이 노드 메모리에만 존재하며 재기동 시 사라진다. */
private final Map<String, Deque<TokenIssueHistory>> issueHistories = new ConcurrentHashMap<>();
@Autowired @Autowired
OutboundOAuthCredentialDao outboundOAuthCredentialDao; OutboundOAuthCredentialDao outboundOAuthCredentialDao;
@@ -238,33 +247,38 @@ public class AccessTokenManagerByDB implements Lifecycle {
try { try {
logger.debug("Executing token issuance for adapter group: {}", adapterGroupName); logger.debug("Executing token issuance for adapter group: {}", adapterGroupName);
SessionManager.getInstance().getOutboundAccessToken(adapterGroupName, new Function<AccessTokenVO, AccessTokenVO>() { SessionManager sessionManager = SessionManager.getInstance();
@Override // 발급을 유발하지 않는 조회로 먼저 상태를 본다.
public AccessTokenVO apply(AccessTokenVO accessToken) { // getOutboundAccessToken "없거나 이미 만료" 때만 발급 함수를 부르기 때문에,
long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000); // 만료 임박 판정을 안에 두면 도달하지 못한다.
// // 토큰이 없거나, 다음 스케줄 시간 전에 만료될 경우 재발급 AccessTokenVO cached = sessionManager.peekOutboundAccessToken(adapterGroupName);
if (accessToken == null) { long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000L);
logger.debug("Token not exists for adapter group: {}", adapterGroupName);
return issueToken(credential); if (cached == null) {
} else if(accessToken.getExpiration() != null) { logger.debug("Token not exists for adapter group: {}", adapterGroupName);
// 토큰이 있고 만료시간이 설정 경우 sessionManager.getOutboundAccessToken(adapterGroupName,
if(accessToken.getExpiration().before(new Date(intervalTime))){ token -> issueToken(credential));
// 다음 스케줄 전에 토큰이 만료되는 경우 재발급
logger.debug("Token expired : {}, expiration date: {}", adapterGroupName, accessToken.getExpiration()); } else if (cached.getExpiration() == null) {
return issueToken(credential); // 만료시각이 없는 토큰은 갱신 시점을 판단할 없다.
} else { logger.debug("Token exists but expiration time is null for adapter group: {}",
// 토큰이 아직 유효한 경우 adapterGroupName);
logger.debug("Token still valid until next schedule for adapter group: {}, expiration date: {}", adapterGroupName, accessToken.getExpiration());
return accessToken; } else if (cached.getExpiration().before(new Date(intervalTime))) {
} // 다음 스케줄 전에 만료되므로 미리 갱신한다.
} else { // 여러 노드가 동시에 들어와도 분산락 안에서 다시 확인해 번만 발급된다.
//토큰이 있지만 만료 시간이 없는 경우 // 다른 노드가 이미 넣어둔 토큰이 다음 틱까지 유효하면 발급하지 않는다.
logger.debug("Token exists but expiration time is null for adapter group: {}", adapterGroupName); logger.debug("Token expires before next schedule : {}, expiration date: {}",
return accessToken; adapterGroupName, cached.getExpiration());
} sessionManager.reissueOutboundAccessToken(adapterGroupName, cached.getAccessToken(),
} intervalTime, token -> issueToken(credential));
});
} else {
logger.debug(
"Token still valid until next schedule for adapter group: {}, expiration date: {}",
adapterGroupName, cached.getExpiration());
}
logger.debug("Token issuance completed for adapter group: {}", adapterGroupName); logger.debug("Token issuance completed for adapter group: {}", adapterGroupName);
} catch (Exception e) { } catch (Exception e) {
@@ -315,7 +329,19 @@ public class AccessTokenManagerByDB implements Lifecycle {
HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type); HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
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);
long startTime = System.currentTimeMillis();
try {
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
} catch (Exception e) {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, type, startTime, null,
toFailReason(e));
throw e;
}
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, type, startTime,
accessToken, null);
// 발급에 실패했는데도 토큰을 반환하는 구현체가 있다. 그대로 캐시되면 // 발급에 실패했는데도 토큰을 반환하는 구현체가 있다. 그대로 캐시되면
// 만료시각이 없어 재발급 대상이 되지 않으므로 실패로 처리한다. // 만료시각이 없어 재발급 대상이 되지 않으므로 실패로 처리한다.
@@ -328,9 +354,13 @@ public class AccessTokenManagerByDB implements Lifecycle {
return accessToken; return accessToken;
} else { } else {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, type,
System.currentTimeMillis(), null, "토큰 발급 구현체를 찾을 수 없음");
logger.warn("Token service not found for type: {} and adapter group: {}", type, adapterGroupName); logger.warn("Token service not found for type: {} and adapter group: {}", type, adapterGroupName);
} }
} else { } else {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, null,
System.currentTimeMillis(), null, "어댑터 설정을 찾을 수 없음");
logger.warn("No valid adapter configuration found for adapter group: {}", adapterGroupName); logger.warn("No valid adapter configuration found for adapter group: {}", adapterGroupName);
} }
@@ -392,6 +422,78 @@ public class AccessTokenManagerByDB implements Lifecycle {
lifecycle.removeLifecycleListener(listener); lifecycle.removeLifecycleListener(listener);
} }
/**
* 1. 기능 : 토큰 발급 이력을 남긴다.
* 2. 처리 개요 : 어댑터그룹별로 최근 ISSUE_HISTORY_SIZE 건만 노드 메모리에 보관한다.
* 성공뿐 아니라 실패도 남긴다. 실패는 캐시에 아무것도 남지 않아 사후 추적이 어렵기 때문이다.
* 3. 주의사항 : accessToken UNMASKED_TOKEN_LENGTH 자만 남겨 보관한다.
*
* @param adapterGroupName 어댑터그룹명
* @param trigger SCHEDULE / RETRY
* @param serviceClass 사용한 발급 구현체 클래스명
* @param startTime 발급 시도 시각
* @param accessToken 발급된 토큰. 실패 null.
* @param failReason 실패 사유. 성공 null.
**/
private void recordIssueHistory(String adapterGroupName, String trigger, String serviceClass, long startTime,
AccessTokenVO accessToken, String failReason) {
String reason = failReason;
String maskedToken = null;
Date expiration = null;
if (reason == null) {
if (accessToken == null || StringUtils.isBlank(accessToken.getAccessToken())) {
reason = "발급된 토큰이 비어 있음";
} else {
maskedToken = maskToken(accessToken.getAccessToken());
expiration = accessToken.getExpiration();
}
}
TokenIssueHistory history = new TokenIssueHistory(startTime, trigger, serviceClass, maskedToken, expiration,
reason);
Deque<TokenIssueHistory> histories = issueHistories.computeIfAbsent(adapterGroupName,
key -> new ArrayDeque<TokenIssueHistory>());
synchronized (histories) {
histories.addFirst(history);
while (histories.size() > ISSUE_HISTORY_SIZE) {
histories.removeLast();
}
}
}
/** 예외 메시지가 비어 있는 경우를 대비해 클래스명이라도 이력에 남긴다. */
private String toFailReason(Exception e) {
return StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getName());
}
/** accessToken 앞자리만 남기고 마스킹한다. */
private String maskToken(String accessToken) {
if (accessToken.length() <= UNMASKED_TOKEN_LENGTH) {
return StringUtils.repeat('*', accessToken.length());
}
return StringUtils.substring(accessToken, 0, UNMASKED_TOKEN_LENGTH) + "***";
}
/**
* 1. 기능 : 어댑터그룹의 토큰 발급 이력을 최근 순으로 반환한다.
* 2. 처리 개요 : 상태 조회 API 에서 사용한다. 노드에서 일어난 발급만 담긴다.
*
* @param adapterGroupName 어댑터그룹명
* @return 최근 이력 목록. 없으면 목록.
**/
public List<TokenIssueHistory> getIssueHistories(String adapterGroupName) {
Deque<TokenIssueHistory> histories = issueHistories.get(adapterGroupName);
if (histories == null) {
return Collections.emptyList();
}
synchronized (histories) {
return new ArrayList<>(histories);
}
}
/** /**
* 1. 기능 : 등록된 OAuth 인증 정보의 어댑터그룹명 목록을 반환한다. * 1. 기능 : 등록된 OAuth 인증 정보의 어댑터그룹명 목록을 반환한다.
* 2. 처리 개요 : 상태 조회 API 에서 사용한다. * 2. 처리 개요 : 상태 조회 API 에서 사용한다.
@@ -505,29 +607,50 @@ public class AccessTokenManagerByDB implements Lifecycle {
* 3. 주의사항 * 3. 주의사항
* *
**/ **/
public synchronized AccessTokenVO retryAccessTokenVO(String adapterGroupName, Properties properties, public AccessTokenVO retryAccessTokenVO(String adapterGroupName, Properties properties, String oldToken)
String oldToken) throws Exception { throws Exception {
AccessTokenVO accessToken = getAccessTokenVO(adapterGroupName); if (isOAuthCredentialRegistered(adapterGroupName) == false) {
throw new Exception("There is no OAuthCredentialRegistered information, or whether to use it is 'N'.");
// 동시 요청이 발생할 경우 synchronized 처리를 했기때문에 토큰을 다시 한번 체크한다.
if (accessToken == null || accessToken.isExpired()
|| StringUtils.equals(accessToken.getAccessToken(), oldToken)) {
if (isOAuthCredentialRegistered(adapterGroupName) == false) {
throw new Exception(
"There is no OAuthCredentialRegistered information, or whether to use it is 'N'.");
}
String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE");
HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
if (service != null) {
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
}
} }
return accessToken; String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE");
final HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
if (service == null) {
logger.warn("Token service not found for type: {} and adapter group: {}", type, adapterGroupName);
return getAccessTokenVO(adapterGroupName);
}
final OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
// 클러스터 전역에서 번만 발급되도록 분산락 안에서 처리한다. 발급 결과는 캐시에 반영되므로
// 뒤따르는 거래는 재발급하지 않는다. 구현체는 호출자가 넘긴 어댑터 속성 기준으로 고른다.
// 거부된 토큰을 바꾸는 것이 목적이므로 만료 기준 검사는 하지 않고 oldToken 비교만 한다.
// (만료 전이어도 상대 기관이 거부한 상황이다)
return SessionManager.getInstance().reissueOutboundAccessToken(adapterGroupName, oldToken, 0L,
new Function<AccessTokenVO, AccessTokenVO>() {
@Override
public AccessTokenVO apply(AccessTokenVO currentToken) {
long startTime = System.currentTimeMillis();
try {
logger.info("Reissuing token for adapter group: {}, type: {}", adapterGroupName, type);
AccessTokenVO issued = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_RETRY, type, startTime,
issued, null);
return issued;
} catch (Exception e) {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_RETRY, type, startTime,
null, toFailReason(e));
logger.error("Token reissue failed for adapter group: {}", adapterGroupName, e);
return null;
}
}
});
} }
} }
@@ -0,0 +1,61 @@
package com.eactive.eai.common.authoutbound;
import java.text.SimpleDateFormat;
import java.util.Date;
import lombok.Getter;
/**
* 아웃바운드 OAuth 토큰 발급 이력 .
*
* 진단 목적이므로 성공뿐 아니라 실패도 남긴다. accessToken 마스킹된 값만 담는다.
* 인스턴스는 노드 메모리에만 존재하며 재기동 사라진다.
*/
@Getter
public class TokenIssueHistory {
/** 스케줄러의 주기 발급 */
public static final String TRIGGER_SCHEDULE = "SCHEDULE";
/** 거래 중 재발급 */
public static final String TRIGGER_RETRY = "RETRY";
private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
/** 발급을 시도한 시각 */
private final String issuedAt;
/** SCHEDULE | RETRY */
private final String trigger;
/** 사용한 발급 구현체 클래스명 */
private final String serviceClass;
private final boolean success;
/** 발급에 걸린 시간(ms) */
private final long elapsedMs;
/** 앞 8자만 남긴 accessToken. 실패 시 null. */
private final String accessTokenMasked;
/** 발급된 토큰의 만료 시각. 실패 시 null. */
private final String expiration;
/** 실패 사유. 성공 시 null. */
private final String failReason;
TokenIssueHistory(long startTime, String trigger, String serviceClass, String accessTokenMasked, Date expiration,
String failReason) {
SimpleDateFormat formatter = new SimpleDateFormat(DATE_FORMAT);
this.issuedAt = formatter.format(new Date(startTime));
this.trigger = trigger;
this.serviceClass = serviceClass;
this.elapsedMs = System.currentTimeMillis() - startTime;
this.failReason = failReason;
this.success = failReason == null;
this.accessTokenMasked = accessTokenMasked;
this.expiration = expiration == null ? null : formatter.format(expiration);
}
}
@@ -222,6 +222,22 @@ public abstract class SessionManager implements Lifecycle {
*/ */
public abstract AccessTokenVO peekOutboundAccessToken(String key); public abstract AccessTokenVO peekOutboundAccessToken(String key);
/**
* 사용 중이던 토큰이 거부된 경우 재발급한다.
*
* 분산락 안에서 캐시의 토큰이 oldToken 같은지 확인한 뒤에만 발급하므로, 여러 노드가 동시에
* 재발급을 시도해도 실제 발급은 번만 일어난다. 발급에 성공하면 캐시에 반영한다.
*
* @param key 어댑터그룹명
* @param oldToken 거부된(또는 갱신 대상인) accessToken
* @param validUntilTime 시각까지 유효한 토큰이 캐시에 있으면 발급하지 않는다.
* 0 이하면 만료 기준 검사를 하지 않고 oldToken 비교만 한다.
* @param tokenSupplier 실제 발급 처리
* @return 재발급된 토큰. 다른 노드가 이미 갱신했다면 토큰.
*/
public abstract AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, long validUntilTime,
Function<AccessTokenVO, AccessTokenVO> tokenSupplier);
public abstract void removeOutboundAccessToken(String key); public abstract void removeOutboundAccessToken(String key);
public abstract void clearOutboundAccessToken(); public abstract void clearOutboundAccessToken();
@@ -956,6 +956,12 @@ public class SessionManagerForEhcache extends SessionManager {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@Override
public AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, long validUntilTime,
Function<AccessTokenVO, AccessTokenVO> tokenSupplier) {
throw new UnsupportedOperationException();
}
@Override @Override
public void removeOutboundAccessToken(String key) { public void removeOutboundAccessToken(String key) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
@@ -1,6 +1,7 @@
package com.eactive.eai.common.session; package com.eactive.eai.common.session;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Properties; import java.util.Properties;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -44,6 +45,9 @@ import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi;
import org.apache.ignite.spi.discovery.tcp.ipfinder.kubernetes.TcpDiscoveryKubernetesIpFinder; import org.apache.ignite.spi.discovery.tcp.ipfinder.kubernetes.TcpDiscoveryKubernetesIpFinder;
import org.apache.ignite.spi.discovery.tcp.ipfinder.multicast.TcpDiscoveryMulticastIpFinder; import org.apache.ignite.spi.discovery.tcp.ipfinder.multicast.TcpDiscoveryMulticastIpFinder;
import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder; import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder;
import org.apache.ignite.transactions.Transaction;
import org.apache.ignite.transactions.TransactionConcurrency;
import org.apache.ignite.transactions.TransactionIsolation;
import com.eactive.eai.adapter.socket2.common.Env; import com.eactive.eai.adapter.socket2.common.Env;
import com.eactive.eai.authserver.service.BearerTokenInfo; import com.eactive.eai.authserver.service.BearerTokenInfo;
@@ -72,7 +76,12 @@ import ch.qos.logback.classic.Level;
public class SessionManagerForIgnite extends SessionManager { public class SessionManagerForIgnite extends SessionManager {
private static final String DISTRIBUTED_TOKEN_LOCK = "DISTRIBUTED_TOKEN_LOCK";
/** 아웃바운드 토큰 분산락 대기 시간(초). 스케줄러 경로에서 사용한다. */
private static final int TOKEN_LOCK_WAIT_SECONDS = 60;
/** 거래 중 재발급은 거래 스레드를 잡으므로 스케줄러 경로보다 짧게 기다린다. */
private static final int REISSUE_LOCK_WAIT_SECONDS = 10;
// evictMaster Instance - single socket 관련 // evictMaster Instance - single socket 관련
private static IgniteCache<String, String> evictMasterCache = null; private static IgniteCache<String, String> evictMasterCache = null;
// Socket Session cache 정보 // Socket Session cache 정보
@@ -831,6 +840,112 @@ public class SessionManagerForIgnite extends SessionManager {
} }
return cacheOutBoundAccessToken.get(key); return cacheOutBoundAccessToken.get(key);
} }
/**
* 아웃바운드 토큰을 primary 노드 기준으로 읽는다.
*
* 일반 get() near 캐시 사본을 돌려줄 있어, 다른 노드가 방금 교체한 토큰을 놓칠 있다.
* 비관적 트랜잭션 안에서 읽으면 primary 값을 보장받는다. 트랜잭션은 읽기 직후 바로 닫는다.
* (발급 HTTP 호출 구간까지 열어두면 장기 트랜잭션이 되어 파티션 교환을 막는다)
*
* @param key 어댑터그룹명
* @return 캐시에 있는 토큰. 없으면 null.
*/
private AccessTokenVO readFromPrimary(String key) {
try (Transaction tx = manager.transactions().txStart(TransactionConcurrency.PESSIMISTIC,
TransactionIsolation.REPEATABLE_READ)) {
AccessTokenVO token = cacheOutBoundAccessToken.get(key);
tx.commit();
return token;
} catch (Throwable e) {
logger.error("occuring exception in readFromPrimary. key=" + key, e);
return cacheOutBoundAccessToken.get(key);
}
}
/** 토큰으로 쓸 수 있는 값인지 (빈 토큰은 발급 실패로 본다) */
private boolean isUsableToken(AccessTokenVO token) {
return token != null && StringUtils.isNotBlank(token.getAccessToken());
}
/**
* 캐시의 토큰이 이미 갱신된 것이라 다시 발급할 필요가 없는지 판단한다.
*
* 값이 바뀌었는지(oldToken 비교), 요구하는 시각까지 유효한지를 함께 본다.
* 비교만 하면 서로 다른 시점에 조회한 노드들이 각자 발급할 있다.
*
* @param token 캐시에 있는 토큰
* @param oldToken 갱신 대상으로 보고 들어온 accessToken
* @param validUntilTime 시각까지 유효하면 갱신 불필요. 0 이하면 만료 기준 검사 생략.
* @return 갱신이 필요 없으면 true
*/
private boolean isAlreadyReissued(AccessTokenVO token, String oldToken, long validUntilTime) {
if (!isUsableToken(token)) {
return false;
}
if (validUntilTime > 0 && token.getExpiration() != null
&& !token.getExpiration().before(new Date(validUntilTime))) {
// 다른 노드가 넣어둔 토큰이 요구 시각까지 유효하다.
return true;
}
return !StringUtils.equals(token.getAccessToken(), oldToken);
}
@Override
public AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, long validUntilTime,
Function<AccessTokenVO, AccessTokenVO> tokenSupplier) {
AccessTokenVO token = readFromPrimary(key);
// 락을 잡기 전에 먼저 확인한다. 다른 노드가 이미 갱신했으면 토큰을 쓴다.
if (isAlreadyReissued(token, oldToken, validUntilTime)) {
return token;
}
// 어댑터그룹별로 락을 잡는다. 거래 스레드에서 호출되므로 대기 시간을 짧게 둔다.
Lock lock = cacheOutBoundAccessToken.lock(key);
boolean acquired = false;
try {
acquired = lock.tryLock(REISSUE_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
if (!acquired) {
logger.warn("reissueOutboundAccessToken lock timeout. key=" + key);
return token;
}
// 획득 사이에 다른 노드가 갱신했을 있으므로 다시 확인한다.
// near 캐시 사본은 무효화가 늦을 있어 그냥 get() 하면 이미 교체된 토큰을 읽는다.
// 짧은 비관적 트랜잭션으로 읽어 primary 값을 보장받는다. (발급 구간까지 끌고 가지 않는다)
token = readFromPrimary(key);
if (isAlreadyReissued(token, oldToken, validUntilTime)) {
logger.debug("reissueOutboundAccessToken already reissued by another node. key=" + key);
return token;
}
AccessTokenVO newToken = tokenSupplier.apply(token);
if (isUsableToken(newToken)) {
cacheOutBoundAccessToken.put(key, newToken);
return newToken;
}
logger.warn("reissueOutboundAccessToken got empty token. not cached. key=" + key);
return newToken;
} catch (Throwable e) {
logger.error("occuring exception in reissueOutboundAccessToken. key=" + key, e);
return token;
} finally {
try {
if (acquired) {
lock.unlock();
}
} catch (Throwable e) {
logger.error("occuring exception in reissueOutboundAccessToken unlock fail.", e);
}
}
}
@Override @Override
public void putWebSocketTimeout(String key, SessionVO value) { public void putWebSocketTimeout(String key, SessionVO value) {
@@ -917,13 +1032,14 @@ public class SessionManagerForIgnite extends SessionManager {
// 2. 만료되었거나 없다면 획득 시도 // 2. 만료되었거나 없다면 획득 시도
if (token == null || token.isExpired()) { if (token == null || token.isExpired()) {
Lock lock = cacheOutBoundAccessToken.lock(DISTRIBUTED_TOKEN_LOCK); // Ignite 분산 // 어댑터그룹별로 락을 잡는다. 전역 하나를 쓰면 모든 그룹의 토큰 처리가 직렬화된다.
Lock lock = cacheOutBoundAccessToken.lock(key); // Ignite 분산
boolean acquired = false; boolean acquired = false;
try { try {
logger.debug("calling func getOutboundAccessToken = distributed ignite trylock"); logger.debug("calling func getOutboundAccessToken = distributed ignite trylock");
acquired = lock.tryLock(60, TimeUnit.SECONDS); acquired = lock.tryLock(TOKEN_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
if ( acquired ) { if ( acquired ) {
logger.debug("calling func getOutboundAccessToken = distributed ignite trylock success=>"+acquired); logger.debug("calling func getOutboundAccessToken = distributed ignite trylock success=>"+acquired);
@@ -972,13 +1088,13 @@ public class SessionManagerForIgnite extends SessionManager {
@Override @Override
public void removeOutboundAccessToken(String key) { public void removeOutboundAccessToken(String key) {
Lock lock = cacheOutBoundAccessToken.lock(DISTRIBUTED_TOKEN_LOCK); // Ignite 분산 Lock lock = cacheOutBoundAccessToken.lock(key); // Ignite 분산 (어댑터그룹별)
boolean acquired = false; boolean acquired = false;
try { try {
logger.debug("calling func removeOutboundAccessToken = distributed ignite trylock"); logger.debug("calling func removeOutboundAccessToken = distributed ignite trylock");
acquired = lock.tryLock(60, TimeUnit.SECONDS); acquired = lock.tryLock(TOKEN_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
if( acquired ) { if( acquired ) {
if (logger.isDebug()) { if (logger.isDebug()) {
@@ -1,7 +1,10 @@
package com.eactive.eai.manage.oauthtoken; package com.eactive.eai.manage.oauthtoken;
import java.util.List;
import java.util.Map; import java.util.Map;
import com.eactive.eai.common.authoutbound.TokenIssueHistory;
import lombok.Data; import lombok.Data;
/** /**
@@ -33,12 +36,22 @@ public class OAuthTokenStatusDTO {
* *
* 어댑터마다 값이 다르면 스케줄러가 쓰는 구현체와 거래 재발급(retryAccessTokenVO) * 어댑터마다 값이 다르면 스케줄러가 쓰는 구현체와 거래 재발급(retryAccessTokenVO)
* 쓰이는 구현체가 달라진다. 불일치를 확인하기 위한 항목이다. * 쓰이는 구현체가 달라진다. 불일치를 확인하기 위한 항목이다.
*
* 어댑터그룹 자체를 찾지 못하면 null, 그룹은 있으나 서버에 배정된 어댑터가 없으면 값이다.
*/ */
Map<String, String> adapterTokenServiceClasses; Map<String, String> adapterTokenServiceClasses;
/** 토큰 발급 URL */ /** 토큰 발급 URL. DB(OutboundOAuthCredentialVo)에 설정된 원본이라 상대 경로일 수 있다. */
String tokenUrl; String tokenUrl;
/**
* 실제로 호출되는 토큰 발급 URL.
*
* tokenUrl 절대 URL 아니면 어댑터 속성 URL 뒤에 붙여 호출하므로(구현체들의 appendPath 처리),
* 같은 방식으로 조합한 값이다. 어댑터 속성을 찾지 못하면 null.
*/
String tokenUrlResolved;
/** 토큰 재발급 주기(초) */ /** 토큰 재발급 주기(초) */
int intervalSec; int intervalSec;
@@ -72,4 +85,15 @@ public class OAuthTokenStatusDTO {
/** 조회 불가 사유 등 부가 메시지 */ /** 조회 불가 사유 등 부가 메시지 */
String message; String message;
/** 이 응답을 만든 서버. 발급 이력이 노드 로컬이라 어느 노드인지 함께 알려준다. */
String serverName;
/**
* 토큰 발급 이력 (최근 ).
*
* 노드에서 일어난 발급만 담기며 재기동 사라진다.
* 단건 조회는 보관 중인 전체(최대 10건), 목록 조회는 최근 1건만 담는다.
*/
List<TokenIssueHistory> issueHistory;
} }
@@ -10,6 +10,7 @@ import java.util.Map;
import java.util.Properties; import java.util.Properties;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.eactive.eai.adapter.AdapterGroupVO; import com.eactive.eai.adapter.AdapterGroupVO;
@@ -18,6 +19,8 @@ import com.eactive.eai.adapter.AdapterPropManager;
import com.eactive.eai.adapter.AdapterVO; import com.eactive.eai.adapter.AdapterVO;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo; import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB; import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB;
import com.eactive.eai.common.authoutbound.TokenIssueHistory;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.Logger; import com.eactive.eai.common.util.Logger;
import com.openbanking.eai.common.token.AccessTokenVO; import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO; import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
@@ -43,6 +46,9 @@ public class OAuthTokenStatusService {
/** accessToken 중 노출할 앞자리 수 */ /** accessToken 중 노출할 앞자리 수 */
private static final int UNMASKED_LENGTH = 8; private static final int UNMASKED_LENGTH = 8;
/** 목록 조회에 담을 발급 이력 건수 */
private static final int LIST_HISTORY_SIZE = 1;
/** /**
* 등록된 모든 어댑터그룹의 토큰 현황을 반환한다. * 등록된 모든 어댑터그룹의 토큰 현황을 반환한다.
* *
@@ -51,22 +57,38 @@ public class OAuthTokenStatusService {
public List<OAuthTokenStatusDTO> getStatusList() { public List<OAuthTokenStatusDTO> getStatusList() {
List<OAuthTokenStatusDTO> result = new ArrayList<OAuthTokenStatusDTO>(); List<OAuthTokenStatusDTO> result = new ArrayList<OAuthTokenStatusDTO>();
for (String adapterGroupName : AccessTokenManagerByDB.getInstance().getRegisteredAdapterGroupNames()) { for (String adapterGroupName : AccessTokenManagerByDB.getInstance().getRegisteredAdapterGroupNames()) {
result.add(getStatus(adapterGroupName)); // 목록에는 최근 1건만 담는다. 전체를 담으면 그룹 수만큼 응답이 커진다.
result.add(getStatus(adapterGroupName, LIST_HISTORY_SIZE));
} }
return result; return result;
} }
/**
* 어댑터그룹 하나의 토큰 현황을 발급 이력 전체와 함께 반환한다.
*
* @param adapterGroupName 어댑터그룹명
* @return 토큰 현황. 등록돼 있지 않으면 message 채워서 반환한다.
*/
public OAuthTokenStatusDTO getStatus(String adapterGroupName) {
return getStatus(adapterGroupName, Integer.MAX_VALUE);
}
/** /**
* 어댑터그룹 하나의 토큰 현황을 반환한다. * 어댑터그룹 하나의 토큰 현황을 반환한다.
* *
* @param adapterGroupName 어댑터그룹명 * @param adapterGroupName 어댑터그룹명
* @param historyLimit 담을 발급 이력 건수 (최근 )
* @return 토큰 현황. 등록돼 있지 않으면 message 채워서 반환한다. * @return 토큰 현황. 등록돼 있지 않으면 message 채워서 반환한다.
*/ */
public OAuthTokenStatusDTO getStatus(String adapterGroupName) { private OAuthTokenStatusDTO getStatus(String adapterGroupName, int historyLimit) {
OAuthTokenStatusDTO dto = new OAuthTokenStatusDTO(); OAuthTokenStatusDTO dto = new OAuthTokenStatusDTO();
dto.setAdapterGroupName(adapterGroupName); dto.setAdapterGroupName(adapterGroupName);
AccessTokenManagerByDB manager = AccessTokenManagerByDB.getInstance(); AccessTokenManagerByDB manager = AccessTokenManagerByDB.getInstance();
// 이력은 노드 로컬이므로 어느 노드가 응답했는지 함께 담는다.
dto.setServerName(findLocalServerName());
dto.setIssueHistory(latest(manager.getIssueHistories(adapterGroupName), historyLimit));
OutboundOAuthCredentialVo credential = manager.getOutboundOAuthCredentialVo(adapterGroupName); OutboundOAuthCredentialVo credential = manager.getOutboundOAuthCredentialVo(adapterGroupName);
if (credential == null) { if (credential == null) {
@@ -77,25 +99,50 @@ public class OAuthTokenStatusService {
dto.setUseYn(credential.getUseYn()); dto.setUseYn(credential.getUseYn());
dto.setTokenUrl(credential.getUrl()); dto.setTokenUrl(credential.getUrl());
dto.setIntervalSec(credential.getIntervalSec()); dto.setIntervalSec(credential.getIntervalSec());
dto.setTokenServiceClass(findTokenServiceClass(adapterGroupName)); // 스케줄러가 고르는 어댑터의 속성에서 발급 구현체와 기준 URL 함께 읽는다.
dto.setAdapterTokenServiceClasses(findAdapterTokenServiceClasses(adapterGroupName)); Properties schedulerAdapterProp = findSchedulerAdapterProperties(adapterGroupName);
if (schedulerAdapterProp != null) {
dto.setTokenServiceClass(schedulerAdapterProp.getProperty(PROP_TOKEN_SERVICE_CLASS));
dto.setTokenUrlResolved(resolveTokenUrl(schedulerAdapterProp.getProperty("URL"), credential.getUrl()));
}
Map<String, String> adapterClasses = findAdapterTokenServiceClasses(adapterGroupName);
dto.setAdapterTokenServiceClasses(adapterClasses);
// 확인이 필요한 상태는 모아서 message 하나로 돌려준다.
List<String> notes = new ArrayList<String>();
// init() useYn 무관하게 인증 정보를 모두 등록하지만 startToken() Y 때만 스케줄을 건다.
if (!"Y".equalsIgnoreCase(credential.getUseYn())) {
notes.add("OAuth 인증 정보가 사용 안 함(useYn=" + credential.getUseYn()
+ ") 상태라 스케줄러가 토큰을 발급하지 않습니다.");
}
if (adapterClasses == null) {
notes.add("어댑터그룹을 찾을 수 없습니다. 그룹명이 정확한지, 그룹 사용여부가 '사용' 인지 확인하세요.");
} else if (adapterClasses.isEmpty()) {
notes.add("어댑터그룹에 이 서버로 배정된 어댑터가 없어 토큰을 발급할 수 없습니다.");
}
AccessTokenVO accessToken; AccessTokenVO accessToken;
try { try {
accessToken = manager.peekAccessTokenVO(adapterGroupName); accessToken = manager.peekAccessTokenVO(adapterGroupName);
} catch (UnsupportedOperationException e) { } catch (UnsupportedOperationException e) {
dto.setMessage("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다."); notes.add("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다.");
dto.setMessage(joinNotes(notes));
return dto; return dto;
} catch (Exception e) { } catch (Exception e) {
if (logger.isWarn()) { if (logger.isWarn()) {
logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e); logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e);
} }
dto.setMessage("토큰 캐시 조회 실패 : " + e.getMessage()); notes.add("토큰 캐시 조회 실패 : " + e.getMessage());
dto.setMessage(joinNotes(notes));
return dto; return dto;
} }
if (accessToken == null) { if (accessToken == null) {
dto.setMessage("캐시에 토큰이 없습니다."); notes.add("캐시에 토큰이 없습니다.");
dto.setMessage(joinNotes(notes));
return dto; return dto;
} }
@@ -120,38 +167,109 @@ public class OAuthTokenStatusService {
// 만료시각이 없는 토큰은 isExpired() false 재발급 대상이 되지 않는다. // 만료시각이 없는 토큰은 isExpired() false 재발급 대상이 되지 않는다.
// 토큰이 캐시에 들어가면 기동 계속 그대로 사용되므로 조회 눈에 띄게 알린다. // 토큰이 캐시에 들어가면 기동 계속 그대로 사용되므로 조회 눈에 띄게 알린다.
if (StringUtils.isBlank(accessToken.getAccessToken())) { if (StringUtils.isBlank(accessToken.getAccessToken())) {
dto.setMessage("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다." notes.add("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다."
+ " 만료시각이 없어 자동 재발급되지 않습니다."); + " 만료시각이 없어 자동 재발급되지 않습니다.");
} else if (expiration == null) { } else if (expiration == null) {
dto.setMessage("만료시각이 없어 자동 재발급되지 않습니다."); notes.add("만료시각이 없어 자동 재발급되지 않습니다.");
} }
dto.setMessage(joinNotes(notes));
return dto; return dto;
} }
/** /**
* 어댑터 속성에서 토큰 발급 구현체 클래스명을 찾는. * 발급 이력에서 최근 건만 잘라낸다. 이력은 이미 최근 순으로 정렬돼 .
*
* @param histories 발급 이력
* @param limit 담을 건수
* @return 최근 limit
*/
private List<TokenIssueHistory> latest(List<TokenIssueHistory> histories, int limit) {
if (histories.size() <= limit) {
return histories;
}
return new ArrayList<TokenIssueHistory>(histories.subList(0, limit));
}
/**
* 응답을 만든 서버명을 찾는다. 발급 이력이 노드 로컬이라 함께 알려준다.
*
* @return 서버명. 조회하지 못하면 null.
*/
private String findLocalServerName() {
try {
return EAIServerManager.getInstance().getLocalServerName();
} catch (Exception e) {
return null;
}
}
/**
* 확인이 필요한 상태 메시지를 하나로 합친다.
*
* @param notes 메시지 목록
* @return 합친 메시지. 없으면 null.
*/
private String joinNotes(List<String> notes) {
if (notes.isEmpty()) {
return null;
}
return StringUtils.join(notes, " ");
}
/**
* 스케줄러가 토큰 발급에 사용하는 어댑터의 속성을 찾는다.
*
* AccessTokenManagerByDB.issueToken() 동일하게 getAdapters().next() 어댑터 하나를 고른다.
* *
* @param adapterGroupName 어댑터그룹명 * @param adapterGroupName 어댑터그룹명
* @return 구현체 클래스명. 찾지 못하면 null. * @return 어댑터 속성. 찾지 못하면 null.
*/ */
private String findTokenServiceClass(String adapterGroupName) { private Properties findSchedulerAdapterProperties(String adapterGroupName) {
try { try {
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName); AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (gvo == null || !gvo.getAdapters().hasNext()) { if (gvo == null || !gvo.getAdapters().hasNext()) {
return null; return null;
} }
AdapterVO avo = gvo.getAdapters().next(); AdapterVO avo = gvo.getAdapters().next();
Properties properties = AdapterPropManager.getInstance().getProperties(avo.getPropGroupName()); return AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
return properties.getProperty(PROP_TOKEN_SERVICE_CLASS);
} catch (Exception e) { } catch (Exception e) {
if (logger.isDebug()) { if (logger.isDebug()) {
logger.debug("토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName); logger.debug("어댑터 속성 조회 실패. adapterGroupName : " + adapterGroupName);
} }
return null; return null;
} }
} }
/**
* 실제로 호출되는 토큰 발급 URL 만든다.
*
* 구현체들과 동일하게, 절대 URL 아니면 어댑터 속성 URL 뒤에 이어 붙인다.
*
* @param adapterUrl 어댑터 속성의 URL
* @param tokenUrl DB 설정된 토큰 발급 URL
* @return 조합된 URL. 만들 없으면 null.
*/
String resolveTokenUrl(String adapterUrl, String tokenUrl) {
if (StringUtils.isBlank(tokenUrl)) {
return null;
}
if (UrlUtils.isAbsoluteUrl(tokenUrl)) {
return tokenUrl;
}
if (StringUtils.isBlank(adapterUrl)) {
return null;
}
if (!adapterUrl.endsWith("/") && !tokenUrl.startsWith("/")) {
return adapterUrl + "/" + tokenUrl;
}
if (adapterUrl.endsWith("/") && tokenUrl.startsWith("/")) {
return adapterUrl + tokenUrl.substring(1);
}
return adapterUrl + tokenUrl;
}
/** /**
* 그룹에 속한 어댑터별로 토큰 발급 구현체 클래스명을 모은다. * 그룹에 속한 어댑터별로 토큰 발급 구현체 클래스명을 모은다.
* *
@@ -160,14 +278,14 @@ public class OAuthTokenStatusService {
* 서로 다른 구현체가 동작하므로 전체를 보여준다. * 서로 다른 구현체가 동작하므로 전체를 보여준다.
* *
* @param adapterGroupName 어댑터그룹명 * @param adapterGroupName 어댑터그룹명
* @return 어댑터명 구현체 클래스명. 조회 실패 Map. * @return 어댑터명 구현체 클래스명. 어댑터그룹 자체를 찾지 못하면 null, 어댑터가 없으면 Map.
*/ */
private Map<String, String> findAdapterTokenServiceClasses(String adapterGroupName) { private Map<String, String> findAdapterTokenServiceClasses(String adapterGroupName) {
Map<String, String> result = new LinkedHashMap<String, String>(); Map<String, String> result = new LinkedHashMap<String, String>();
try { try {
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName); AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (gvo == null) { if (gvo == null) {
return result; return null;
} }
Iterator<AdapterVO> adapters = gvo.getAdapters(); Iterator<AdapterVO> adapters = gvo.getAdapters();
while (adapters.hasNext()) { while (adapters.hasNext()) {
@@ -179,6 +297,7 @@ public class OAuthTokenStatusService {
if (logger.isDebug()) { if (logger.isDebug()) {
logger.debug("어댑터별 토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName); logger.debug("어댑터별 토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName);
} }
return null;
} }
return result; return result;
} }
@@ -0,0 +1,192 @@
package com.eactive.eai.common.authoutbound;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import java.lang.reflect.Field;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.function.Function;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.authoutbound.client.HttpClientAccessTokenServiceByDB;
import com.eactive.eai.common.session.SessionManager;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* AccessTokenManagerByDB.retryAccessTokenVO 단위테스트
*
* 거래 재발급이 SessionManager 분산락 경로(reissueOutboundAccessToken) 타는지,
* 발급 구현체를 호출자가 넘긴 어댑터 속성 기준으로 고르는지를 검증한다.
*/
class AccessTokenManagerByDBRetryTest {
private static final String GROUP = "TESTGRP";
private static final String SERVICE_CLASS = RecordingTokenService.class.getName();
private AccessTokenManagerByDB manager;
private SessionManager mockSessionManager;
@BeforeEach
void setUp() throws Exception {
manager = new AccessTokenManagerByDB();
RecordingTokenService.reset();
mockSessionManager = Mockito.mock(SessionManager.class);
injectSessionManager(mockSessionManager);
// 분산락 구간을 흉내 낸다. supplier 그대로 실행해 결과를 돌려준다.
Mockito.when(mockSessionManager.reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any()))
.thenAnswer(invocation -> {
Function<AccessTokenVO, AccessTokenVO> supplier = invocation.getArgument(3);
return supplier.apply(null);
});
}
/** SessionManager 싱글턴에 mock 을 주입한다. */
private void injectSessionManager(SessionManager sessionManager) throws Exception {
Field field = SessionManager.class.getDeclaredField("instance");
field.setAccessible(true);
field.set(null, sessionManager);
}
/** 인증 정보를 등록된 것으로 만든다. */
@SuppressWarnings("unchecked")
private OutboundOAuthCredentialVo registerCredential(String useYn) throws Exception {
OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo();
credential.setAdapterGroupName(GROUP);
credential.setUseYn(useYn);
credential.setClientId("client-1");
credential.setClientSecret("secret-1");
Field field = AccessTokenManagerByDB.class.getDeclaredField("outboundOAuthCredentialVos");
field.setAccessible(true);
Map<String, OutboundOAuthCredentialVo> map = (Map<String, OutboundOAuthCredentialVo>) field.get(manager);
map.put(GROUP, credential);
return credential;
}
/** 발급 구현체를 지정한 어댑터 속성 */
private Properties adapterProp(String serviceClass) {
Properties properties = new Properties();
properties.setProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE", serviceClass);
properties.setProperty("URL", "https://api.example.com");
return properties;
}
@Test
@DisplayName("1. 재발급은 SessionManager 의 분산락 경로를 통해 일어난다")
void 분산락경로_사용() throws Exception {
registerCredential("Y");
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD");
assertEquals("NEW", result.getAccessToken());
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD"), Mockito.anyLong(), any());
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
}
@Test
@DisplayName("2. 발급 구현체는 호출자가 넘긴 어댑터 속성으로 고른다")
void 호출자속성으로_구현체선택() throws Exception {
OutboundOAuthCredentialVo credential = registerCredential("Y");
Properties prop = adapterProp(SERVICE_CLASS);
manager.retryAccessTokenVO(GROUP, prop, "OLD");
assertEquals(1, RecordingTokenService.callCount, "구현체가 한 번 실행돼야 한다");
assertEquals(GROUP, RecordingTokenService.lastAdapterGroupName);
assertSame(prop, RecordingTokenService.lastProperties, "호출자가 넘긴 속성이 그대로 전달돼야 한다");
assertSame(credential, RecordingTokenService.lastCredential, "DB 인증정보가 전달돼야 한다");
}
@Test
@DisplayName("3. 인증 정보가 없거나 useYn 이 N 이면 예외")
void 미등록이면_예외() throws Exception {
registerCredential("N");
assertThrows(Exception.class, () -> manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD"));
assertThrows(Exception.class, () -> manager.retryAccessTokenVO("NO-SUCH-GROUP",
adapterProp(SERVICE_CLASS), "OLD"));
assertEquals(0, RecordingTokenService.callCount);
}
@Test
@DisplayName("4. 구현체를 찾지 못하면 발급하지 않고 캐시 조회로 넘어간다")
void 구현체없음_폴백() throws Exception {
registerCredential("Y");
OAuth2AccessTokenVO cached = new OAuth2AccessTokenVO();
cached.setAccessToken("CACHED");
cached.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
Mockito.when(mockSessionManager.getOutboundAccessToken(eq(GROUP), any())).thenReturn(cached);
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp("no.such.TokenService"), "OLD");
assertEquals("CACHED", result.getAccessToken());
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
}
@Test
@DisplayName("5. 발급 중 예외가 나면 null 을 돌려줘 캐시에 남지 않게 한다")
void 발급실패는_null() throws Exception {
registerCredential("Y");
RecordingTokenService.throwOnExecute = true;
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD");
assertNull(result, "실패를 빈 토큰이 아니라 null 로 알려야 캐시에 저장되지 않는다");
}
/**
* 호출 인자를 기록하는 테스트용 발급 구현체.
*
* HttpClientAccessTokenServiceFactoryByDB 클래스명으로 생성하므로 public 이어야 한다.
*/
public static class RecordingTokenService implements HttpClientAccessTokenServiceByDB {
static int callCount;
static String lastAdapterGroupName;
static Properties lastProperties;
static OutboundOAuthCredentialVo lastCredential;
static boolean throwOnExecute;
static void reset() {
callCount = 0;
lastAdapterGroupName = null;
lastProperties = null;
lastCredential = null;
throwOnExecute = false;
}
@Override
public Object execute(String adapterGroupName, Properties adapterProp,
OutboundOAuthCredentialVo oAuthCredentialVo) throws Exception {
callCount++;
lastAdapterGroupName = adapterGroupName;
lastProperties = adapterProp;
lastCredential = oAuthCredentialVo;
if (throwOnExecute) {
throw new Exception("token issue failed");
}
OAuth2AccessTokenVO token = new OAuth2AccessTokenVO();
token.setAccessToken("NEW");
token.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
return token;
}
}
}
@@ -0,0 +1,136 @@
package com.eactive.eai.common.authoutbound;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.session.SessionManager;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* AccessTokenManagerByDB 스케줄러 태스크의 갱신 판정 단위테스트
*
* 캐시 조회(peek) 결과에 따라 어느 경로로 가는지를 본다.
* - 토큰 없음 : getOutboundAccessToken (분산락 안에서 발급)
* - 다음 만료 : reissueOutboundAccessToken (선제 갱신)
* - 아직 유효 : 아무것도 하지 않음
*/
class AccessTokenManagerByDBScheduleTest {
private static final String GROUP = "TESTGRP";
/** 토큰 재발급 주기(초) */
private static final int INTERVAL_SEC = 30;
private AccessTokenManagerByDB manager;
private SessionManager mockSessionManager;
private Method executeTokenTask;
@BeforeEach
void setUp() throws Exception {
manager = new AccessTokenManagerByDB();
mockSessionManager = Mockito.mock(SessionManager.class);
Field field = SessionManager.class.getDeclaredField("instance");
field.setAccessible(true);
field.set(null, mockSessionManager);
executeTokenTask = AccessTokenManagerByDB.class.getDeclaredMethod("executeTokenTask", String.class,
OutboundOAuthCredentialVo.class);
executeTokenTask.setAccessible(true);
}
@AfterEach
void tearDown() throws Exception {
Field field = AccessTokenManagerByDB.class.getDeclaredField("scheduler");
field.setAccessible(true);
((ExecutorService) field.get(manager)).shutdownNow();
}
/** 스케줄러 태스크를 한 번 실행한다. */
private void runTask() throws Exception {
OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo();
credential.setAdapterGroupName(GROUP);
credential.setUseYn("Y");
credential.setIntervalSec(INTERVAL_SEC);
executeTokenTask.invoke(manager, GROUP, credential);
}
/** 만료까지 expiresInSec 남은 캐시 토큰을 준비한다. */
private void cached(String accessToken, Long expiresInSec) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
vo.setAccessToken(accessToken);
if (expiresInSec != null) {
vo.setExpiration(new Date(System.currentTimeMillis() + expiresInSec * 1000L));
}
Mockito.when(mockSessionManager.peekOutboundAccessToken(GROUP)).thenReturn(vo);
}
@Test
@DisplayName("1. 캐시에 토큰이 없으면 분산락 경로로 발급한다")
void 토큰없음_발급() throws Exception {
Mockito.when(mockSessionManager.peekOutboundAccessToken(GROUP)).thenReturn(null);
runTask();
Mockito.verify(mockSessionManager).getOutboundAccessToken(eq(GROUP), any());
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
}
@Test
@DisplayName("2. 다음 틱 전에 만료되면 선제 갱신한다 (만료 전이어도)")
void 만료임박_선제갱신() throws Exception {
// 아직 20초 남았지만 다음 (30초 ) 전에 만료된다.
cached("OLD-TOKEN", 20L);
runTask();
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD-TOKEN"), Mockito.anyLong(), any());
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
}
@Test
@DisplayName("3. 이미 만료된 토큰도 선제 갱신 경로로 간다")
void 이미만료_갱신() throws Exception {
cached("OLD-TOKEN", -60L);
runTask();
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD-TOKEN"), Mockito.anyLong(), any());
}
@Test
@DisplayName("4. 다음 틱까지 유효하면 아무것도 하지 않는다")
void 유효하면_그대로() throws Exception {
cached("VALID-TOKEN", 600L);
runTask();
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
}
@Test
@DisplayName("5. 만료시각이 없는 토큰은 갱신 판단을 하지 않는다")
void 만료시각없음_보류() throws Exception {
cached("NO-EXPIRATION", null);
runTask();
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
}
}
@@ -0,0 +1,111 @@
package com.eactive.eai.common.authoutbound;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* AccessTokenManagerByDB 토큰 발급 이력 단위테스트
*
* 이력 기록은 private 이라 리플렉션으로 호출한다. 조회(getIssueHistories) 공개 API .
*/
class TokenIssueHistoryTest {
private static final String GROUP = "TESTGRP";
private AccessTokenManagerByDB manager;
private Method recordMethod;
@BeforeEach
void setUp() throws Exception {
manager = new AccessTokenManagerByDB();
recordMethod = AccessTokenManagerByDB.class.getDeclaredMethod("recordIssueHistory", String.class, String.class,
String.class, long.class, AccessTokenVO.class, String.class);
recordMethod.setAccessible(true);
}
private void record(String trigger, String serviceClass, AccessTokenVO token, String failReason) throws Exception {
recordMethod.invoke(manager, GROUP, trigger, serviceClass, System.currentTimeMillis(), token, failReason);
}
private OAuth2AccessTokenVO token(String accessToken) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
vo.setAccessToken(accessToken);
vo.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
return vo;
}
@Test
@DisplayName("1. 발급 성공 이력을 남기고 토큰은 마스킹한다")
void 성공이력() throws Exception {
record(TokenIssueHistory.TRIGGER_SCHEDULE, "com.example.TokenService", token("abcdefghijklmnop"), null);
List<TokenIssueHistory> histories = manager.getIssueHistories(GROUP);
assertEquals(1, histories.size());
TokenIssueHistory history = histories.get(0);
assertTrue(history.isSuccess());
assertEquals("SCHEDULE", history.getTrigger());
assertEquals("com.example.TokenService", history.getServiceClass());
assertEquals("abcdefgh***", history.getAccessTokenMasked());
assertNull(history.getFailReason());
assertFalse(history.getAccessTokenMasked().contains("ijklmnop"), "토큰 뒷부분이 남으면 안 된다");
}
@Test
@DisplayName("2. 실패 이력은 사유를 남기고 토큰은 담지 않는다")
void 실패이력() throws Exception {
record(TokenIssueHistory.TRIGGER_RETRY, "com.example.TokenService", null, "Connection refused");
TokenIssueHistory history = manager.getIssueHistories(GROUP).get(0);
assertFalse(history.isSuccess());
assertEquals("RETRY", history.getTrigger());
assertEquals("Connection refused", history.getFailReason());
assertNull(history.getAccessTokenMasked());
assertNull(history.getExpiration());
}
@Test
@DisplayName("3. 빈 토큰은 사유를 채워 실패로 남긴다")
void 빈토큰은_실패() throws Exception {
record(TokenIssueHistory.TRIGGER_SCHEDULE, "com.example.TokenService", new OAuth2AccessTokenVO(), null);
TokenIssueHistory history = manager.getIssueHistories(GROUP).get(0);
assertFalse(history.isSuccess());
assertTrue(history.getFailReason().contains("비어 있음"));
}
@Test
@DisplayName("4. 최근 10건만 보관하고 최신이 앞에 온다")
void 최근10건만_보관() throws Exception {
for (int i = 1; i <= 15; i++) {
record(TokenIssueHistory.TRIGGER_SCHEDULE, "svc-" + i, token("token-" + i), null);
}
List<TokenIssueHistory> histories = manager.getIssueHistories(GROUP);
assertEquals(10, histories.size());
assertEquals("svc-15", histories.get(0).getServiceClass(), "최신 건이 앞에 와야 한다");
assertEquals("svc-6", histories.get(9).getServiceClass(), "11번째부터는 밀려나야 한다");
}
@Test
@DisplayName("5. 이력이 없는 어댑터그룹은 빈 목록")
void 이력없음() {
assertTrue(manager.getIssueHistories("NO-SUCH-GROUP").isEmpty());
}
}
@@ -0,0 +1,319 @@
package com.eactive.eai.common.session;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import java.lang.reflect.Field;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import org.apache.ignite.Ignite;
import org.apache.ignite.IgniteCache;
import org.apache.ignite.IgniteTransactions;
import org.apache.ignite.transactions.Transaction;
import org.apache.ignite.transactions.TransactionConcurrency;
import org.apache.ignite.transactions.TransactionIsolation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* SessionManagerForIgnite.reissueOutboundAccessToken 단위테스트
*
* Ignite 캐시 자리에 ConcurrentHashMap 으로 동작하는 mock IgniteCache 리플렉션으로 주입하고,
* 분산락 자리에는 어댑터그룹별 ReentrantLock 물려 안의 판정 로직과 경합 동작을 검증한다.
*
* 노드 상호배제 자체는 Ignite lock(key) 보장하는 부분이라 여기서 검증할 없다.
* 테스트가 보는 것은 "락을 잡은 뒤 무엇을 판단하는가" 이다.
*/
class SessionManagerForIgniteReissueTest {
private static final String GROUP = "TESTGRP";
private SessionManagerForIgnite sessionManager;
/** mock IgniteCache 의 실제 저장소 */
private Map<String, AccessTokenVO> store;
/** 어댑터그룹별 락 (같은 키면 같은 인스턴스를 돌려줘야 경합 테스트가 성립한다) */
private Map<String, Lock> locks;
/** readFromPrimary 가 여는 트랜잭션 */
private Transaction transaction;
@BeforeEach
void setUp() throws Exception {
sessionManager = new SessionManagerForIgnite();
store = new ConcurrentHashMap<>();
locks = new ConcurrentHashMap<>();
@SuppressWarnings("unchecked")
IgniteCache<String, AccessTokenVO> cache = Mockito.mock(IgniteCache.class);
Mockito.when(cache.get(anyString())).thenAnswer(invocation -> store.get(invocation.getArgument(0)));
Mockito.doAnswer(invocation -> {
store.put(invocation.getArgument(0), invocation.getArgument(1));
return null;
}).when(cache).put(anyString(), any(AccessTokenVO.class));
Mockito.when(cache.lock(anyString()))
.thenAnswer(invocation -> locks.computeIfAbsent(invocation.getArgument(0),
key -> new ReentrantLock()));
injectCache(cache);
injectIgnite();
}
/**
* readFromPrimary 쓰는 Ignite 트랜잭션을 mock 으로 주입한다.
* 주입하지 않으면 NPE 폴백 경로를 타게 되어 실제 동작을 검증하지 못한다.
*/
private void injectIgnite() throws Exception {
transaction = Mockito.mock(Transaction.class);
IgniteTransactions transactions = Mockito.mock(IgniteTransactions.class);
Mockito.when(transactions.txStart(any(TransactionConcurrency.class), any(TransactionIsolation.class)))
.thenReturn(transaction);
Ignite ignite = Mockito.mock(Ignite.class);
Mockito.when(ignite.transactions()).thenReturn(transactions);
Field field = SessionManagerForIgnite.class.getDeclaredField("manager");
field.setAccessible(true);
field.set(null, ignite);
}
/** private static cacheOutBoundAccessToken 에 mock 을 주입한다. */
private void injectCache(IgniteCache<String, AccessTokenVO> cache) throws Exception {
Field field = SessionManagerForIgnite.class.getDeclaredField("cacheOutBoundAccessToken");
field.setAccessible(true);
field.set(null, cache);
}
/** 만료되지 않은 토큰 */
private OAuth2AccessTokenVO token(String accessToken) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
vo.setAccessToken(accessToken);
vo.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
return vo;
}
/** 호출 횟수를 세면서 지정한 토큰을 발급하는 supplier */
private Function<AccessTokenVO, AccessTokenVO> supplier(AtomicInteger counter, String newToken) {
return current -> {
counter.incrementAndGet();
return newToken == null ? null : token(newToken);
};
}
@Test
@DisplayName("1. 캐시가 비어 있으면 발급하고 캐시에 저장한다")
void 캐시비었을때_발급() {
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
assertEquals(1, issued.get());
assertEquals("NEW", result.getAccessToken());
assertEquals("NEW", store.get(GROUP).getAccessToken());
}
@Test
@DisplayName("2. 캐시 토큰이 거부된 토큰과 같으면 재발급한다")
void 같은토큰이면_재발급() {
store.put(GROUP, token("OLD"));
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
assertEquals(1, issued.get());
assertEquals("NEW", result.getAccessToken());
assertEquals("NEW", store.get(GROUP).getAccessToken());
}
@Test
@DisplayName("3. 다른 노드가 이미 갱신했으면 발급하지 않고 그 토큰을 쓴다")
void 이미갱신됨_발급안함() {
store.put(GROUP, token("ALREADY-NEW"));
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
assertEquals(0, issued.get(), "다른 토큰이 이미 캐시에 있으면 발급하지 않아야 한다");
assertEquals("ALREADY-NEW", result.getAccessToken());
}
@Test
@DisplayName("4. 빈 토큰이 발급되면 캐시에 저장하지 않는다")
void 빈토큰_미캐싱() {
store.put(GROUP, token("OLD"));
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, null));
assertEquals(1, issued.get());
assertNull(result);
assertEquals("OLD", store.get(GROUP).getAccessToken(), "캐시는 그대로여야 한다");
}
@Test
@DisplayName("5. 락을 얻지 못하면 발급하지 않고 기존 토큰을 반환한다")
void 락획득실패() throws Exception {
store.put(GROUP, token("OLD"));
Lock neverAcquired = Mockito.mock(Lock.class);
Mockito.when(neverAcquired.tryLock(Mockito.anyLong(), any(TimeUnit.class))).thenReturn(false);
locks.put(GROUP, neverAcquired);
AtomicInteger issued = new AtomicInteger();
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
assertEquals(0, issued.get());
assertEquals("OLD", result.getAccessToken());
Mockito.verify(neverAcquired, Mockito.never()).unlock();
}
@Test
@DisplayName("5-1. 캐시 토큰이 요구 시각까지 유효하면 값이 같아도 발급하지 않는다")
void 충분히유효하면_발급안함() {
// 다른 노드가 방금 넣어둔 토큰. 조회 시점 차이로 oldToken 값이 같게 들어와도
// 다음 틱까지 유효하면 다시 발급할 이유가 없다.
store.put(GROUP, token("SAME-TOKEN"));
AtomicInteger issued = new AtomicInteger();
long validUntil = System.currentTimeMillis() + 60_000L;
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "SAME-TOKEN", validUntil,
supplier(issued, "NEW"));
assertEquals(0, issued.get(), "요구 시각까지 유효하면 발급하지 않아야 한다");
assertEquals("SAME-TOKEN", result.getAccessToken());
}
@Test
@DisplayName("5-2. 요구 시각 전에 만료되면 발급한다")
void 요구시각전_만료면_발급() {
store.put(GROUP, token("SAME-TOKEN"));
AtomicInteger issued = new AtomicInteger();
// 캐시 토큰은 10분 만료인데 20분 뒤까지 유효해야 한다면 갱신 대상이다.
long validUntil = System.currentTimeMillis() + 1_200_000L;
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "SAME-TOKEN", validUntil,
supplier(issued, "NEW"));
assertEquals(1, issued.get());
assertEquals("NEW", result.getAccessToken());
}
@Test
@DisplayName("5-3. 캐시 조회는 비관적 트랜잭션 안에서 하고 바로 커밋한다")
void 트랜잭션으로_읽는다() {
store.put(GROUP, token("OLD"));
sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(new AtomicInteger(), "NEW"));
// 1회 + 1회
Mockito.verify(transaction, Mockito.times(2)).commit();
// 발급(HTTP) 구간까지 트랜잭션을 열어두면 장기 트랜잭션이 되어 파티션 교환을 막는다.
Mockito.verify(transaction, Mockito.never()).rollback();
}
@Test
@DisplayName("6. 경합 - 여러 스레드가 같은 토큰으로 동시에 재발급해도 발급은 한 번만 일어난다")
void 동시재발급_한번만() throws Exception {
final int threadCount = 20;
store.put(GROUP, token("OLD"));
final AtomicInteger issued = new AtomicInteger();
final Function<AccessTokenVO, AccessTokenVO> slowSupplier = current -> {
issued.incrementAndGet();
try {
// 발급에 시간이 걸리는 상황을 만들어 경합을 유도한다.
Thread.sleep(50L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return token("NEW");
};
final CountDownLatch start = new CountDownLatch(1);
final CountDownLatch done = new CountDownLatch(threadCount);
final AccessTokenVO[] results = new AccessTokenVO[threadCount];
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
try {
for (int i = 0; i < threadCount; i++) {
final int index = i;
executor.submit(() -> {
try {
start.await();
results[index] = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, slowSupplier);
} catch (Exception e) {
// 결과가 null 남아 아래 검증에서 걸린다.
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(30, TimeUnit.SECONDS), "모든 스레드가 끝나야 한다");
} finally {
executor.shutdownNow();
}
assertEquals(1, issued.get(), "동시에 들어와도 실제 발급은 한 번이어야 한다");
assertEquals("NEW", store.get(GROUP).getAccessToken());
for (int i = 0; i < threadCount; i++) {
assertEquals("NEW", results[i].getAccessToken(), "모든 스레드가 새 토큰을 받아야 한다");
}
}
@Test
@DisplayName("7. 경합 - 어댑터그룹이 다르면 서로 막지 않는다")
void 다른그룹은_독립적() throws Exception {
final AtomicInteger issued = new AtomicInteger();
final CountDownLatch bothInside = new CountDownLatch(2);
final Function<AccessTokenVO, AccessTokenVO> blockingSupplier = current -> {
issued.incrementAndGet();
bothInside.countDown();
try {
// 그룹이 같은 락을 쓰면 여기서 서로를 기다리다 타임아웃된다.
if (!bothInside.await(5, TimeUnit.SECONDS)) {
return null;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return token("NEW");
};
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
executor.submit(() -> sessionManager.reissueOutboundAccessToken("GRP-A", "OLD", 0L, blockingSupplier));
executor.submit(() -> sessionManager.reissueOutboundAccessToken("GRP-B", "OLD", 0L, blockingSupplier));
assertTrue(bothInside.await(10, TimeUnit.SECONDS), "두 그룹이 동시에 발급 구간에 들어가야 한다");
} finally {
executor.shutdownNow();
}
assertEquals(2, issued.get());
assertTrue(locks.containsKey("GRP-A") && locks.containsKey("GRP-B"), "그룹별로 락이 분리돼야 한다");
assertSame(locks.get("GRP-A"), locks.get("GRP-A"));
}
}
@@ -8,7 +8,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -22,6 +24,7 @@ import org.springframework.context.ApplicationContext;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo; import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB; import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB;
import com.eactive.eai.common.authoutbound.TokenIssueHistory;
import com.eactive.eai.common.util.ApplicationContextProvider; import com.eactive.eai.common.util.ApplicationContextProvider;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO; import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
@@ -63,6 +66,21 @@ class OAuthTokenStatusServiceTest {
return credential; return credential;
} }
/**
* 최근 순으로 정렬된 발급 이력 count (svc-0 최신).
*
* TokenIssueHistory 생성자는 package-private 이라 mock 으로 만든다.
*/
private List<TokenIssueHistory> histories(int count) {
List<TokenIssueHistory> result = new ArrayList<>();
for (int i = 0; i < count; i++) {
TokenIssueHistory history = Mockito.mock(TokenIssueHistory.class);
Mockito.when(history.getServiceClass()).thenReturn("svc-" + i);
result.add(history);
}
return result;
}
/** 만료까지 expiresInSec 남은 토큰 */ /** 만료까지 expiresInSec 남은 토큰 */
private OAuth2AccessTokenVO token(String accessToken, long expiresInSec) { private OAuth2AccessTokenVO token(String accessToken, long expiresInSec) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO(); OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
@@ -209,6 +227,54 @@ class OAuthTokenStatusServiceTest {
assertTrue(dto.getMessage().contains("재발급")); assertTrue(dto.getMessage().contains("재발급"));
} }
@Test
@DisplayName("8-3. useYn 이 N 이면 스케줄러가 발급하지 않음을 알린다")
void 사용안함경고() {
OutboundOAuthCredentialVo credential = registerCredential(GROUP);
credential.setUseYn("N");
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertTrue(dto.getMessage().contains("사용 안 함"));
assertTrue(dto.getMessage().contains("캐시에 토큰이 없습니다"), "다른 사유도 함께 표시돼야 한다");
}
@Test
@DisplayName("8-4. 어댑터그룹을 찾지 못하면 그 사유를 알린다")
void 어댑터그룹없음경고() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
// 테스트 환경에는 AdapterManager 없으므로 그룹을 찾지 못한 것으로 처리된다.
assertNull(dto.getAdapterTokenServiceClasses());
assertTrue(dto.getMessage().contains("어댑터그룹을 찾을 수 없습니다"));
}
@Test
@DisplayName("8-5. 상대 경로 토큰 URL 은 어댑터 URL 과 조합한다")
void 토큰URL조합() {
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com", "oauth2/token"));
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com/", "oauth2/token"));
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com", "/oauth2/token"));
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com/", "/oauth2/token"));
}
@Test
@DisplayName("8-6. 절대 URL 은 그대로 쓰고, 조합할 수 없으면 null")
void 토큰URL절대경로() {
assertEquals("https://token.example.com/oauth/token",
service.resolveTokenUrl("https://api.example.com", "https://token.example.com/oauth/token"));
assertNull(service.resolveTokenUrl("https://api.example.com", null));
assertNull(service.resolveTokenUrl(null, "oauth2/token"));
}
@Test @Test
@DisplayName("9. 전체 목록은 등록된 어댑터그룹 수만큼 반환한다") @DisplayName("9. 전체 목록은 등록된 어댑터그룹 수만큼 반환한다")
void 전체목록() { void 전체목록() {
@@ -227,6 +293,38 @@ class OAuthTokenStatusServiceTest {
assertFalse(list.get(1).isCached()); assertFalse(list.get(1).isCached());
} }
@Test
@DisplayName("9-1. 단건 조회는 이력 전체, 목록은 최근 1건만 담는다")
void 이력포함범위() {
Set<String> groups = new LinkedHashSet<>(Arrays.asList(GROUP));
Mockito.when(mockManager.getRegisteredAdapterGroupNames()).thenReturn(groups);
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
// histories() 안에서 mock 만들므로 when(...) 인자에 직접 넣으면 스터빙이 중첩된다.
List<TokenIssueHistory> histories = histories(3);
Mockito.when(mockManager.getIssueHistories(GROUP)).thenReturn(histories);
OAuthTokenStatusDTO single = service.getStatus(GROUP);
OAuthTokenStatusDTO fromList = service.getStatusList().get(0);
assertEquals(3, single.getIssueHistory().size(), "단건 조회는 보관 중인 이력 전체");
assertEquals(1, fromList.getIssueHistory().size(), "목록은 최근 1건만");
assertEquals("svc-0", fromList.getIssueHistory().get(0).getServiceClass(), "최신 건이어야 한다");
}
@Test
@DisplayName("9-2. 이력이 없으면 빈 목록 (null 아님)")
void 이력없으면_빈목록() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
Mockito.when(mockManager.getIssueHistories(GROUP)).thenReturn(Collections.emptyList());
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertNotNull(dto.getIssueHistory());
assertTrue(dto.getIssueHistory().isEmpty());
}
@Test @Test
@DisplayName("10. 조회가 토큰 발급을 유발하지 않는다 (peek 만 호출)") @DisplayName("10. 조회가 토큰 발급을 유발하지 않는다 (peek 만 호출)")
void 발급유발없음() { void 발급유발없음() {