Compare commits

...

5 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
13 changed files with 1249 additions and 58 deletions
@@ -78,7 +78,6 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import com.openbanking.eai.common.token.AccessTokenManager;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
@@ -604,8 +603,8 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
+ "]");
}
// 토큰 재발급
AccessTokenManager tokenManager = AccessTokenManager.getInstance();
// 토큰 재발급. 전송 전 조회(:353)와 같은 DB 기반 매니저를 써야 Ignite 캐시가 갱신된다.
AccessTokenManagerByDB tokenManager = AccessTokenManagerByDB.getInstance();
String oldToken = accessToken == null ? null : accessToken.getAccessToken();
OAuth2AccessTokenVO newaccessToken = (OAuth2AccessTokenVO) tokenManager
.retryAccessTokenVO(vo.getAdapterGroupName(), prop, oldToken);
@@ -68,6 +68,15 @@ public class AccessTokenManagerByDB implements Lifecycle {
*/
private Map<String, OutboundOAuthCredentialVo> outboundOAuthCredentialVos;
/** 어댑터그룹별로 보관할 토큰 발급 이력 건수 */
private static final int ISSUE_HISTORY_SIZE = 10;
/** 이력에 남길 accessToken 앞자리 수 */
private static final int UNMASKED_TOKEN_LENGTH = 8;
/** 어댑터그룹별 토큰 발급 이력. 이 노드 메모리에만 존재하며 재기동 시 사라진다. */
private final Map<String, Deque<TokenIssueHistory>> issueHistories = new ConcurrentHashMap<>();
@Autowired
OutboundOAuthCredentialDao outboundOAuthCredentialDao;
@@ -238,33 +247,38 @@ public class AccessTokenManagerByDB implements Lifecycle {
try {
logger.debug("Executing token issuance for adapter group: {}", adapterGroupName);
SessionManager.getInstance().getOutboundAccessToken(adapterGroupName, new Function<AccessTokenVO, AccessTokenVO>() {
SessionManager sessionManager = SessionManager.getInstance();
@Override
public AccessTokenVO apply(AccessTokenVO accessToken) {
long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000);
// // 토큰이 없거나, 다음 스케줄 시간 전에 만료될 경우 재발급
if (accessToken == null) {
logger.debug("Token not exists for adapter group: {}", adapterGroupName);
return issueToken(credential);
} else if(accessToken.getExpiration() != null) {
// 토큰이 있고 만료시간이 설정 된 경우
if(accessToken.getExpiration().before(new Date(intervalTime))){
// 다음 스케줄 전에 토큰이 만료되는 경우 재발급
logger.debug("Token expired : {}, expiration date: {}", adapterGroupName, accessToken.getExpiration());
return issueToken(credential);
} else {
// 토큰이 아직 유효한 경우
logger.debug("Token still valid until next schedule for adapter group: {}, expiration date: {}", adapterGroupName, accessToken.getExpiration());
return accessToken;
}
} else {
//토큰이 있지만 만료 시간이 없는 경우
logger.debug("Token exists but expiration time is null for adapter group: {}", adapterGroupName);
return accessToken;
}
}
});
// 발급을 유발하지 않는 조회로 먼저 상태를 본다.
// getOutboundAccessToken 은 "없거나 이미 만료" 일 때만 발급 함수를 부르기 때문에,
// 만료 임박 판정을 그 안에 두면 도달하지 못한다.
AccessTokenVO cached = sessionManager.peekOutboundAccessToken(adapterGroupName);
long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000L);
if (cached == null) {
logger.debug("Token not exists for adapter group: {}", adapterGroupName);
sessionManager.getOutboundAccessToken(adapterGroupName,
token -> issueToken(credential));
} else if (cached.getExpiration() == null) {
// 만료시각이 없는 토큰은 갱신 시점을 판단할 수 없다.
logger.debug("Token exists but expiration time is null for adapter group: {}",
adapterGroupName);
} else if (cached.getExpiration().before(new Date(intervalTime))) {
// 다음 스케줄 전에 만료되므로 미리 갱신한다.
// 여러 노드가 동시에 들어와도 분산락 안에서 다시 확인해 한 번만 발급된다.
// 다른 노드가 이미 넣어둔 토큰이 다음 틱까지 유효하면 발급하지 않는다.
logger.debug("Token expires before next schedule : {}, expiration date: {}",
adapterGroupName, cached.getExpiration());
sessionManager.reissueOutboundAccessToken(adapterGroupName, cached.getAccessToken(),
intervalTime, token -> issueToken(credential));
} else {
logger.debug(
"Token still valid until next schedule for adapter group: {}, expiration date: {}",
adapterGroupName, cached.getExpiration());
}
logger.debug("Token issuance completed for adapter group: {}", adapterGroupName);
} catch (Exception e) {
@@ -315,7 +329,19 @@ public class AccessTokenManagerByDB implements Lifecycle {
HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
if(service != null) {
logger.debug("Executing token service of type: {} for adapter group: {}", type, adapterGroupName);
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties, outboundOAuthCredentialVo);
long startTime = System.currentTimeMillis();
try {
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
} catch (Exception e) {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, type, startTime, null,
toFailReason(e));
throw e;
}
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, type, startTime,
accessToken, null);
// 발급에 실패했는데도 빈 토큰을 반환하는 구현체가 있다. 그대로 캐시되면
// 만료시각이 없어 재발급 대상이 되지 않으므로 실패로 처리한다.
@@ -328,9 +354,13 @@ public class AccessTokenManagerByDB implements Lifecycle {
return accessToken;
} else {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, type,
System.currentTimeMillis(), null, "토큰 발급 구현체를 찾을 수 없음");
logger.warn("Token service not found for type: {} and adapter group: {}", type, adapterGroupName);
}
} else {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_SCHEDULE, null,
System.currentTimeMillis(), null, "어댑터 설정을 찾을 수 없음");
logger.warn("No valid adapter configuration found for adapter group: {}", adapterGroupName);
}
@@ -392,6 +422,78 @@ public class AccessTokenManagerByDB implements Lifecycle {
lifecycle.removeLifecycleListener(listener);
}
/**
* 1. 기능 : 토큰 발급 이력을 남긴다.
* 2. 처리 개요 : 어댑터그룹별로 최근 ISSUE_HISTORY_SIZE 건만 노드 메모리에 보관한다.
* 성공뿐 아니라 실패도 남긴다. 실패는 캐시에 아무것도 남지 않아 사후 추적이 어렵기 때문이다.
* 3. 주의사항 : accessToken 은 앞 UNMASKED_TOKEN_LENGTH 자만 남겨 보관한다.
*
* @param adapterGroupName 어댑터그룹명
* @param trigger SCHEDULE / RETRY
* @param serviceClass 사용한 발급 구현체 클래스명
* @param startTime 발급 시도 시각
* @param accessToken 발급된 토큰. 실패 시 null.
* @param failReason 실패 사유. 성공 시 null.
**/
private void recordIssueHistory(String adapterGroupName, String trigger, String serviceClass, long startTime,
AccessTokenVO accessToken, String failReason) {
String reason = failReason;
String maskedToken = null;
Date expiration = null;
if (reason == null) {
if (accessToken == null || StringUtils.isBlank(accessToken.getAccessToken())) {
reason = "발급된 토큰이 비어 있음";
} else {
maskedToken = maskToken(accessToken.getAccessToken());
expiration = accessToken.getExpiration();
}
}
TokenIssueHistory history = new TokenIssueHistory(startTime, trigger, serviceClass, maskedToken, expiration,
reason);
Deque<TokenIssueHistory> histories = issueHistories.computeIfAbsent(adapterGroupName,
key -> new ArrayDeque<TokenIssueHistory>());
synchronized (histories) {
histories.addFirst(history);
while (histories.size() > ISSUE_HISTORY_SIZE) {
histories.removeLast();
}
}
}
/** 예외 메시지가 비어 있는 경우를 대비해 클래스명이라도 이력에 남긴다. */
private String toFailReason(Exception e) {
return StringUtils.defaultIfBlank(e.getMessage(), e.getClass().getName());
}
/** accessToken 앞자리만 남기고 마스킹한다. */
private String maskToken(String accessToken) {
if (accessToken.length() <= UNMASKED_TOKEN_LENGTH) {
return StringUtils.repeat('*', accessToken.length());
}
return StringUtils.substring(accessToken, 0, UNMASKED_TOKEN_LENGTH) + "***";
}
/**
* 1. 기능 : 어댑터그룹의 토큰 발급 이력을 최근 순으로 반환한다.
* 2. 처리 개요 : 상태 조회 API 에서 사용한다. 이 노드에서 일어난 발급만 담긴다.
*
* @param adapterGroupName 어댑터그룹명
* @return 최근 순 이력 목록. 없으면 빈 목록.
**/
public List<TokenIssueHistory> getIssueHistories(String adapterGroupName) {
Deque<TokenIssueHistory> histories = issueHistories.get(adapterGroupName);
if (histories == null) {
return Collections.emptyList();
}
synchronized (histories) {
return new ArrayList<>(histories);
}
}
/**
* 1. 기능 : 등록된 OAuth 인증 정보의 어댑터그룹명 목록을 반환한다.
* 2. 처리 개요 : 상태 조회 API 에서 사용한다.
@@ -505,29 +607,50 @@ public class AccessTokenManagerByDB implements Lifecycle {
* 3. 주의사항
*
**/
public synchronized AccessTokenVO retryAccessTokenVO(String adapterGroupName, Properties properties,
String oldToken) throws Exception {
public AccessTokenVO retryAccessTokenVO(String adapterGroupName, Properties properties, String oldToken)
throws Exception {
AccessTokenVO accessToken = getAccessTokenVO(adapterGroupName);
// 동시 요청이 발생할 경우 synchronized 처리를 했기때문에 토큰을 다시 한번 체크한다.
if (accessToken == null || accessToken.isExpired()
|| StringUtils.equals(accessToken.getAccessToken(), oldToken)) {
if (isOAuthCredentialRegistered(adapterGroupName) == false) {
throw new Exception(
"There is no OAuthCredentialRegistered information, or whether to use it is 'N'.");
}
String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE");
HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
if (service != null) {
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
}
if (isOAuthCredentialRegistered(adapterGroupName) == false) {
throw new Exception("There is no OAuthCredentialRegistered information, or whether to use it is 'N'.");
}
return accessToken;
String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE");
final HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
if (service == null) {
logger.warn("Token service not found for type: {} and adapter group: {}", type, adapterGroupName);
return getAccessTokenVO(adapterGroupName);
}
final OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
// 클러스터 전역에서 한 번만 발급되도록 분산락 안에서 처리한다. 발급 결과는 캐시에 반영되므로
// 뒤따르는 거래는 재발급하지 않는다. 구현체는 호출자가 넘긴 어댑터 속성 기준으로 고른다.
// 거부된 토큰을 바꾸는 것이 목적이므로 만료 기준 검사는 하지 않고 oldToken 비교만 한다.
// (만료 전이어도 상대 기관이 거부한 상황이다)
return SessionManager.getInstance().reissueOutboundAccessToken(adapterGroupName, oldToken, 0L,
new Function<AccessTokenVO, AccessTokenVO>() {
@Override
public AccessTokenVO apply(AccessTokenVO currentToken) {
long startTime = System.currentTimeMillis();
try {
logger.info("Reissuing token for adapter group: {}, type: {}", adapterGroupName, type);
AccessTokenVO issued = (AccessTokenVO) service.execute(adapterGroupName, properties,
outboundOAuthCredentialVo);
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_RETRY, type, startTime,
issued, null);
return issued;
} catch (Exception e) {
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_RETRY, type, startTime,
null, toFailReason(e));
logger.error("Token reissue failed for adapter group: {}", adapterGroupName, e);
return null;
}
}
});
}
}
@@ -0,0 +1,61 @@
package com.eactive.eai.common.authoutbound;
import java.text.SimpleDateFormat;
import java.util.Date;
import lombok.Getter;
/**
* 아웃바운드 OAuth 토큰 발급 이력 한 건.
*
* 진단 목적이므로 성공뿐 아니라 실패도 남긴다. accessToken 은 마스킹된 값만 담는다.
* 이 인스턴스는 노드 메모리에만 존재하며 재기동 시 사라진다.
*/
@Getter
public class TokenIssueHistory {
/** 스케줄러의 주기 발급 */
public static final String TRIGGER_SCHEDULE = "SCHEDULE";
/** 거래 중 재발급 */
public static final String TRIGGER_RETRY = "RETRY";
private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
/** 발급을 시도한 시각 */
private final String issuedAt;
/** SCHEDULE | RETRY */
private final String trigger;
/** 사용한 발급 구현체 클래스명 */
private final String serviceClass;
private final boolean success;
/** 발급에 걸린 시간(ms) */
private final long elapsedMs;
/** 앞 8자만 남긴 accessToken. 실패 시 null. */
private final String accessTokenMasked;
/** 발급된 토큰의 만료 시각. 실패 시 null. */
private final String expiration;
/** 실패 사유. 성공 시 null. */
private final String failReason;
TokenIssueHistory(long startTime, String trigger, String serviceClass, String accessTokenMasked, Date expiration,
String failReason) {
SimpleDateFormat formatter = new SimpleDateFormat(DATE_FORMAT);
this.issuedAt = formatter.format(new Date(startTime));
this.trigger = trigger;
this.serviceClass = serviceClass;
this.elapsedMs = System.currentTimeMillis() - startTime;
this.failReason = failReason;
this.success = failReason == null;
this.accessTokenMasked = accessTokenMasked;
this.expiration = expiration == null ? null : formatter.format(expiration);
}
}
@@ -222,6 +222,22 @@ public abstract class SessionManager implements Lifecycle {
*/
public abstract AccessTokenVO peekOutboundAccessToken(String key);
/**
* 사용 중이던 토큰이 거부된 경우 재발급한다.
*
* 분산락 안에서 캐시의 토큰이 oldToken 과 같은지 확인한 뒤에만 발급하므로, 여러 노드가 동시에
* 재발급을 시도해도 실제 발급은 한 번만 일어난다. 발급에 성공하면 캐시에 반영한다.
*
* @param key 어댑터그룹명
* @param oldToken 거부된(또는 갱신 대상인) accessToken 값
* @param validUntilTime 이 시각까지 유효한 토큰이 캐시에 있으면 발급하지 않는다.
* 0 이하면 만료 기준 검사를 하지 않고 oldToken 비교만 한다.
* @param tokenSupplier 실제 발급 처리
* @return 재발급된 토큰. 다른 노드가 이미 갱신했다면 그 토큰.
*/
public abstract AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, long validUntilTime,
Function<AccessTokenVO, AccessTokenVO> tokenSupplier);
public abstract void removeOutboundAccessToken(String key);
public abstract void clearOutboundAccessToken();
@@ -956,6 +956,12 @@ public class SessionManagerForEhcache extends SessionManager {
throw new UnsupportedOperationException();
}
@Override
public AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, long validUntilTime,
Function<AccessTokenVO, AccessTokenVO> tokenSupplier) {
throw new UnsupportedOperationException();
}
@Override
public void removeOutboundAccessToken(String key) {
throw new UnsupportedOperationException();
@@ -1,6 +1,7 @@
package com.eactive.eai.common.session;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
@@ -44,6 +45,9 @@ import org.apache.ignite.spi.discovery.tcp.TcpDiscoverySpi;
import org.apache.ignite.spi.discovery.tcp.ipfinder.kubernetes.TcpDiscoveryKubernetesIpFinder;
import org.apache.ignite.spi.discovery.tcp.ipfinder.multicast.TcpDiscoveryMulticastIpFinder;
import org.apache.ignite.spi.discovery.tcp.ipfinder.vm.TcpDiscoveryVmIpFinder;
import org.apache.ignite.transactions.Transaction;
import org.apache.ignite.transactions.TransactionConcurrency;
import org.apache.ignite.transactions.TransactionIsolation;
import com.eactive.eai.adapter.socket2.common.Env;
import com.eactive.eai.authserver.service.BearerTokenInfo;
@@ -72,7 +76,12 @@ import ch.qos.logback.classic.Level;
public class SessionManagerForIgnite extends SessionManager {
private static final String DISTRIBUTED_TOKEN_LOCK = "DISTRIBUTED_TOKEN_LOCK";
/** 아웃바운드 토큰 분산락 대기 시간(초). 스케줄러 경로에서 사용한다. */
private static final int TOKEN_LOCK_WAIT_SECONDS = 60;
/** 거래 중 재발급은 거래 스레드를 잡으므로 스케줄러 경로보다 짧게 기다린다. */
private static final int REISSUE_LOCK_WAIT_SECONDS = 10;
// evictMaster Instance - single socket 관련
private static IgniteCache<String, String> evictMasterCache = null;
// Socket Session cache 정보
@@ -831,6 +840,112 @@ public class SessionManagerForIgnite extends SessionManager {
}
return cacheOutBoundAccessToken.get(key);
}
/**
* 아웃바운드 토큰을 primary 노드 기준으로 읽는다.
*
* 일반 get() 은 near 캐시 사본을 돌려줄 수 있어, 다른 노드가 방금 교체한 토큰을 놓칠 수 있다.
* 비관적 트랜잭션 안에서 읽으면 primary 의 값을 보장받는다. 트랜잭션은 읽기 직후 바로 닫는다.
* (발급 HTTP 호출 구간까지 열어두면 장기 트랜잭션이 되어 파티션 맵 교환을 막는다)
*
* @param key 어댑터그룹명
* @return 캐시에 있는 토큰. 없으면 null.
*/
private AccessTokenVO readFromPrimary(String key) {
try (Transaction tx = manager.transactions().txStart(TransactionConcurrency.PESSIMISTIC,
TransactionIsolation.REPEATABLE_READ)) {
AccessTokenVO token = cacheOutBoundAccessToken.get(key);
tx.commit();
return token;
} catch (Throwable e) {
logger.error("occuring exception in readFromPrimary. key=" + key, e);
return cacheOutBoundAccessToken.get(key);
}
}
/** 토큰으로 쓸 수 있는 값인지 (빈 토큰은 발급 실패로 본다) */
private boolean isUsableToken(AccessTokenVO token) {
return token != null && StringUtils.isNotBlank(token.getAccessToken());
}
/**
* 캐시의 토큰이 이미 갱신된 것이라 다시 발급할 필요가 없는지 판단한다.
*
* 값이 바뀌었는지(oldToken 비교)와, 요구하는 시각까지 유효한지를 함께 본다.
* 값 비교만 하면 서로 다른 시점에 조회한 노드들이 각자 발급할 수 있다.
*
* @param token 캐시에 있는 토큰
* @param oldToken 갱신 대상으로 보고 들어온 accessToken 값
* @param validUntilTime 이 시각까지 유효하면 갱신 불필요. 0 이하면 만료 기준 검사 생략.
* @return 갱신이 필요 없으면 true
*/
private boolean isAlreadyReissued(AccessTokenVO token, String oldToken, long validUntilTime) {
if (!isUsableToken(token)) {
return false;
}
if (validUntilTime > 0 && token.getExpiration() != null
&& !token.getExpiration().before(new Date(validUntilTime))) {
// 다른 노드가 넣어둔 토큰이 요구 시각까지 유효하다.
return true;
}
return !StringUtils.equals(token.getAccessToken(), oldToken);
}
@Override
public AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, long validUntilTime,
Function<AccessTokenVO, AccessTokenVO> tokenSupplier) {
AccessTokenVO token = readFromPrimary(key);
// 락을 잡기 전에 먼저 확인한다. 다른 노드가 이미 갱신했으면 그 토큰을 쓴다.
if (isAlreadyReissued(token, oldToken, validUntilTime)) {
return token;
}
// 어댑터그룹별로 락을 잡는다. 거래 스레드에서 호출되므로 대기 시간을 짧게 둔다.
Lock lock = cacheOutBoundAccessToken.lock(key);
boolean acquired = false;
try {
acquired = lock.tryLock(REISSUE_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
if (!acquired) {
logger.warn("reissueOutboundAccessToken lock timeout. key=" + key);
return token;
}
// 락 획득 사이에 다른 노드가 갱신했을 수 있으므로 다시 확인한다.
// near 캐시 사본은 무효화가 늦을 수 있어 그냥 get() 하면 이미 교체된 옛 토큰을 읽는다.
// 짧은 비관적 트랜잭션으로 읽어 primary 의 값을 보장받는다. (발급 구간까지 끌고 가지 않는다)
token = readFromPrimary(key);
if (isAlreadyReissued(token, oldToken, validUntilTime)) {
logger.debug("reissueOutboundAccessToken already reissued by another node. key=" + key);
return token;
}
AccessTokenVO newToken = tokenSupplier.apply(token);
if (isUsableToken(newToken)) {
cacheOutBoundAccessToken.put(key, newToken);
return newToken;
}
logger.warn("reissueOutboundAccessToken got empty token. not cached. key=" + key);
return newToken;
} catch (Throwable e) {
logger.error("occuring exception in reissueOutboundAccessToken. key=" + key, e);
return token;
} finally {
try {
if (acquired) {
lock.unlock();
}
} catch (Throwable e) {
logger.error("occuring exception in reissueOutboundAccessToken unlock fail.", e);
}
}
}
@Override
public void putWebSocketTimeout(String key, SessionVO value) {
@@ -917,13 +1032,14 @@ public class SessionManagerForIgnite extends SessionManager {
// 2. 만료되었거나 없다면 락 획득 시도
if (token == null || token.isExpired()) {
Lock lock = cacheOutBoundAccessToken.lock(DISTRIBUTED_TOKEN_LOCK); // Ignite 분산 락
// 어댑터그룹별로 락을 잡는다. 전역 락 하나를 쓰면 모든 그룹의 토큰 처리가 직렬화된다.
Lock lock = cacheOutBoundAccessToken.lock(key); // Ignite 분산 락
boolean acquired = false;
try {
logger.debug("calling func getOutboundAccessToken = distributed ignite trylock");
acquired = lock.tryLock(60, TimeUnit.SECONDS);
acquired = lock.tryLock(TOKEN_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
if ( acquired ) {
logger.debug("calling func getOutboundAccessToken = distributed ignite trylock success=>"+acquired);
@@ -972,13 +1088,13 @@ public class SessionManagerForIgnite extends SessionManager {
@Override
public void removeOutboundAccessToken(String key) {
Lock lock = cacheOutBoundAccessToken.lock(DISTRIBUTED_TOKEN_LOCK); // Ignite 분산 락
Lock lock = cacheOutBoundAccessToken.lock(key); // Ignite 분산 락 (어댑터그룹별)
boolean acquired = false;
try {
logger.debug("calling func removeOutboundAccessToken = distributed ignite trylock");
acquired = lock.tryLock(60, TimeUnit.SECONDS);
acquired = lock.tryLock(TOKEN_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
if( acquired ) {
if (logger.isDebug()) {
@@ -1,7 +1,10 @@
package com.eactive.eai.manage.oauthtoken;
import java.util.List;
import java.util.Map;
import com.eactive.eai.common.authoutbound.TokenIssueHistory;
import lombok.Data;
/**
@@ -82,4 +85,15 @@ public class OAuthTokenStatusDTO {
/** 조회 불가 사유 등 부가 메시지 */
String message;
/** 이 응답을 만든 서버. 발급 이력이 노드 로컬이라 어느 노드인지 함께 알려준다. */
String serverName;
/**
* 토큰 발급 이력 (최근 순).
*
* 이 노드에서 일어난 발급만 담기며 재기동 시 사라진다.
* 단건 조회는 보관 중인 전체(최대 10건), 목록 조회는 최근 1건만 담는다.
*/
List<TokenIssueHistory> issueHistory;
}
@@ -19,6 +19,8 @@ import com.eactive.eai.adapter.AdapterPropManager;
import com.eactive.eai.adapter.AdapterVO;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB;
import com.eactive.eai.common.authoutbound.TokenIssueHistory;
import com.eactive.eai.common.server.EAIServerManager;
import com.eactive.eai.common.util.Logger;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
@@ -44,6 +46,9 @@ public class OAuthTokenStatusService {
/** accessToken 중 노출할 앞자리 수 */
private static final int UNMASKED_LENGTH = 8;
/** 목록 조회에 담을 발급 이력 건수 */
private static final int LIST_HISTORY_SIZE = 1;
/**
* 등록된 모든 어댑터그룹의 토큰 현황을 반환한다.
*
@@ -52,22 +57,38 @@ public class OAuthTokenStatusService {
public List<OAuthTokenStatusDTO> getStatusList() {
List<OAuthTokenStatusDTO> result = new ArrayList<OAuthTokenStatusDTO>();
for (String adapterGroupName : AccessTokenManagerByDB.getInstance().getRegisteredAdapterGroupNames()) {
result.add(getStatus(adapterGroupName));
// 목록에는 최근 1건만 담는다. 전체를 담으면 그룹 수만큼 응답이 커진다.
result.add(getStatus(adapterGroupName, LIST_HISTORY_SIZE));
}
return result;
}
/**
* 어댑터그룹 하나의 토큰 현황을 발급 이력 전체와 함께 반환한다.
*
* @param adapterGroupName 어댑터그룹명
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
*/
public OAuthTokenStatusDTO getStatus(String adapterGroupName) {
return getStatus(adapterGroupName, Integer.MAX_VALUE);
}
/**
* 어댑터그룹 하나의 토큰 현황을 반환한다.
*
* @param adapterGroupName 어댑터그룹명
* @param historyLimit 담을 발급 이력 건수 (최근 순)
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
*/
public OAuthTokenStatusDTO getStatus(String adapterGroupName) {
private OAuthTokenStatusDTO getStatus(String adapterGroupName, int historyLimit) {
OAuthTokenStatusDTO dto = new OAuthTokenStatusDTO();
dto.setAdapterGroupName(adapterGroupName);
AccessTokenManagerByDB manager = AccessTokenManagerByDB.getInstance();
// 이력은 노드 로컬이므로 어느 노드가 응답했는지 함께 담는다.
dto.setServerName(findLocalServerName());
dto.setIssueHistory(latest(manager.getIssueHistories(adapterGroupName), historyLimit));
OutboundOAuthCredentialVo credential = manager.getOutboundOAuthCredentialVo(adapterGroupName);
if (credential == null) {
@@ -157,6 +178,33 @@ public class OAuthTokenStatusService {
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;
}
}
/**
* 확인이 필요한 상태 메시지를 하나로 합친다.
*
@@ -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 java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
@@ -22,6 +24,7 @@ import org.springframework.context.ApplicationContext;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB;
import com.eactive.eai.common.authoutbound.TokenIssueHistory;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
@@ -63,6 +66,21 @@ class OAuthTokenStatusServiceTest {
return credential;
}
/**
* 최근 순으로 정렬된 발급 이력 count 건 (svc-0 이 최신).
*
* TokenIssueHistory 생성자는 package-private 이라 mock 으로 만든다.
*/
private List<TokenIssueHistory> histories(int count) {
List<TokenIssueHistory> result = new ArrayList<>();
for (int i = 0; i < count; i++) {
TokenIssueHistory history = Mockito.mock(TokenIssueHistory.class);
Mockito.when(history.getServiceClass()).thenReturn("svc-" + i);
result.add(history);
}
return result;
}
/** 만료까지 expiresInSec 남은 토큰 */
private OAuth2AccessTokenVO token(String accessToken, long expiresInSec) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
@@ -275,6 +293,38 @@ class OAuthTokenStatusServiceTest {
assertFalse(list.get(1).isCached());
}
@Test
@DisplayName("9-1. 단건 조회는 이력 전체, 목록은 최근 1건만 담는다")
void 이력포함범위() {
Set<String> groups = new LinkedHashSet<>(Arrays.asList(GROUP));
Mockito.when(mockManager.getRegisteredAdapterGroupNames()).thenReturn(groups);
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
// histories() 안에서 mock 을 만들므로 when(...) 인자에 직접 넣으면 스터빙이 중첩된다.
List<TokenIssueHistory> histories = histories(3);
Mockito.when(mockManager.getIssueHistories(GROUP)).thenReturn(histories);
OAuthTokenStatusDTO single = service.getStatus(GROUP);
OAuthTokenStatusDTO fromList = service.getStatusList().get(0);
assertEquals(3, single.getIssueHistory().size(), "단건 조회는 보관 중인 이력 전체");
assertEquals(1, fromList.getIssueHistory().size(), "목록은 최근 1건만");
assertEquals("svc-0", fromList.getIssueHistory().get(0).getServiceClass(), "최신 건이어야 한다");
}
@Test
@DisplayName("9-2. 이력이 없으면 빈 목록 (null 아님)")
void 이력없으면_빈목록() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
Mockito.when(mockManager.getIssueHistories(GROUP)).thenReturn(Collections.emptyList());
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertNotNull(dto.getIssueHistory());
assertTrue(dto.getIssueHistory().isEmpty());
}
@Test
@DisplayName("10. 조회가 토큰 발급을 유발하지 않는다 (peek 만 호출)")
void 발급유발없음() {