Compare commits
5 Commits
6d3cbe1d8b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c48dd834a | |||
| 11b439aaf1 | |||
| e46678ed76 | |||
| b92518eb5a | |||
| 324d8f8f8a |
+2
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확인이 필요한 상태 메시지를 하나로 합친다.
|
||||
*
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package com.eactive.eai.common.authoutbound;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
|
||||
import com.eactive.eai.authoutbound.client.HttpClientAccessTokenServiceByDB;
|
||||
import com.eactive.eai.common.session.SessionManager;
|
||||
import com.openbanking.eai.common.token.AccessTokenVO;
|
||||
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
|
||||
|
||||
/**
|
||||
* AccessTokenManagerByDB.retryAccessTokenVO 단위테스트
|
||||
*
|
||||
* 거래 중 재발급이 SessionManager 의 분산락 경로(reissueOutboundAccessToken)를 타는지,
|
||||
* 발급 구현체를 호출자가 넘긴 어댑터 속성 기준으로 고르는지를 검증한다.
|
||||
*/
|
||||
class AccessTokenManagerByDBRetryTest {
|
||||
|
||||
private static final String GROUP = "TESTGRP";
|
||||
private static final String SERVICE_CLASS = RecordingTokenService.class.getName();
|
||||
|
||||
private AccessTokenManagerByDB manager;
|
||||
private SessionManager mockSessionManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
manager = new AccessTokenManagerByDB();
|
||||
RecordingTokenService.reset();
|
||||
|
||||
mockSessionManager = Mockito.mock(SessionManager.class);
|
||||
injectSessionManager(mockSessionManager);
|
||||
|
||||
// 분산락 구간을 흉내 낸다. supplier 를 그대로 실행해 결과를 돌려준다.
|
||||
Mockito.when(mockSessionManager.reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any()))
|
||||
.thenAnswer(invocation -> {
|
||||
Function<AccessTokenVO, AccessTokenVO> supplier = invocation.getArgument(3);
|
||||
return supplier.apply(null);
|
||||
});
|
||||
}
|
||||
|
||||
/** SessionManager 싱글턴에 mock 을 주입한다. */
|
||||
private void injectSessionManager(SessionManager sessionManager) throws Exception {
|
||||
Field field = SessionManager.class.getDeclaredField("instance");
|
||||
field.setAccessible(true);
|
||||
field.set(null, sessionManager);
|
||||
}
|
||||
|
||||
/** 인증 정보를 등록된 것으로 만든다. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private OutboundOAuthCredentialVo registerCredential(String useYn) throws Exception {
|
||||
OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo();
|
||||
credential.setAdapterGroupName(GROUP);
|
||||
credential.setUseYn(useYn);
|
||||
credential.setClientId("client-1");
|
||||
credential.setClientSecret("secret-1");
|
||||
|
||||
Field field = AccessTokenManagerByDB.class.getDeclaredField("outboundOAuthCredentialVos");
|
||||
field.setAccessible(true);
|
||||
Map<String, OutboundOAuthCredentialVo> map = (Map<String, OutboundOAuthCredentialVo>) field.get(manager);
|
||||
map.put(GROUP, credential);
|
||||
return credential;
|
||||
}
|
||||
|
||||
/** 발급 구현체를 지정한 어댑터 속성 */
|
||||
private Properties adapterProp(String serviceClass) {
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE", serviceClass);
|
||||
properties.setProperty("URL", "https://api.example.com");
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1. 재발급은 SessionManager 의 분산락 경로를 통해 일어난다")
|
||||
void 분산락경로_사용() throws Exception {
|
||||
registerCredential("Y");
|
||||
|
||||
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD");
|
||||
|
||||
assertEquals("NEW", result.getAccessToken());
|
||||
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD"), Mockito.anyLong(), any());
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2. 발급 구현체는 호출자가 넘긴 어댑터 속성으로 고른다")
|
||||
void 호출자속성으로_구현체선택() throws Exception {
|
||||
OutboundOAuthCredentialVo credential = registerCredential("Y");
|
||||
Properties prop = adapterProp(SERVICE_CLASS);
|
||||
|
||||
manager.retryAccessTokenVO(GROUP, prop, "OLD");
|
||||
|
||||
assertEquals(1, RecordingTokenService.callCount, "구현체가 한 번 실행돼야 한다");
|
||||
assertEquals(GROUP, RecordingTokenService.lastAdapterGroupName);
|
||||
assertSame(prop, RecordingTokenService.lastProperties, "호출자가 넘긴 속성이 그대로 전달돼야 한다");
|
||||
assertSame(credential, RecordingTokenService.lastCredential, "DB 인증정보가 전달돼야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3. 인증 정보가 없거나 useYn 이 N 이면 예외")
|
||||
void 미등록이면_예외() throws Exception {
|
||||
registerCredential("N");
|
||||
|
||||
assertThrows(Exception.class, () -> manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD"));
|
||||
assertThrows(Exception.class, () -> manager.retryAccessTokenVO("NO-SUCH-GROUP",
|
||||
adapterProp(SERVICE_CLASS), "OLD"));
|
||||
assertEquals(0, RecordingTokenService.callCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4. 구현체를 찾지 못하면 발급하지 않고 캐시 조회로 넘어간다")
|
||||
void 구현체없음_폴백() throws Exception {
|
||||
registerCredential("Y");
|
||||
OAuth2AccessTokenVO cached = new OAuth2AccessTokenVO();
|
||||
cached.setAccessToken("CACHED");
|
||||
cached.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
|
||||
Mockito.when(mockSessionManager.getOutboundAccessToken(eq(GROUP), any())).thenReturn(cached);
|
||||
|
||||
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp("no.such.TokenService"), "OLD");
|
||||
|
||||
assertEquals("CACHED", result.getAccessToken());
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5. 발급 중 예외가 나면 null 을 돌려줘 캐시에 남지 않게 한다")
|
||||
void 발급실패는_null() throws Exception {
|
||||
registerCredential("Y");
|
||||
RecordingTokenService.throwOnExecute = true;
|
||||
|
||||
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD");
|
||||
|
||||
assertNull(result, "실패를 빈 토큰이 아니라 null 로 알려야 캐시에 저장되지 않는다");
|
||||
}
|
||||
|
||||
/**
|
||||
* 호출 인자를 기록하는 테스트용 발급 구현체.
|
||||
*
|
||||
* HttpClientAccessTokenServiceFactoryByDB 가 클래스명으로 생성하므로 public 이어야 한다.
|
||||
*/
|
||||
public static class RecordingTokenService implements HttpClientAccessTokenServiceByDB {
|
||||
|
||||
static int callCount;
|
||||
static String lastAdapterGroupName;
|
||||
static Properties lastProperties;
|
||||
static OutboundOAuthCredentialVo lastCredential;
|
||||
static boolean throwOnExecute;
|
||||
|
||||
static void reset() {
|
||||
callCount = 0;
|
||||
lastAdapterGroupName = null;
|
||||
lastProperties = null;
|
||||
lastCredential = null;
|
||||
throwOnExecute = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(String adapterGroupName, Properties adapterProp,
|
||||
OutboundOAuthCredentialVo oAuthCredentialVo) throws Exception {
|
||||
callCount++;
|
||||
lastAdapterGroupName = adapterGroupName;
|
||||
lastProperties = adapterProp;
|
||||
lastCredential = oAuthCredentialVo;
|
||||
|
||||
if (throwOnExecute) {
|
||||
throw new Exception("token issue failed");
|
||||
}
|
||||
|
||||
OAuth2AccessTokenVO token = new OAuth2AccessTokenVO();
|
||||
token.setAccessToken("NEW");
|
||||
token.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.eactive.eai.common.authoutbound;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Date;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
|
||||
import com.eactive.eai.common.session.SessionManager;
|
||||
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
|
||||
|
||||
/**
|
||||
* AccessTokenManagerByDB 스케줄러 태스크의 갱신 판정 단위테스트
|
||||
*
|
||||
* 캐시 조회(peek) 결과에 따라 어느 경로로 가는지를 본다.
|
||||
* - 토큰 없음 : getOutboundAccessToken (분산락 안에서 발급)
|
||||
* - 다음 틱 전 만료 : reissueOutboundAccessToken (선제 갱신)
|
||||
* - 아직 유효 : 아무것도 하지 않음
|
||||
*/
|
||||
class AccessTokenManagerByDBScheduleTest {
|
||||
|
||||
private static final String GROUP = "TESTGRP";
|
||||
|
||||
/** 토큰 재발급 주기(초) */
|
||||
private static final int INTERVAL_SEC = 30;
|
||||
|
||||
private AccessTokenManagerByDB manager;
|
||||
private SessionManager mockSessionManager;
|
||||
private Method executeTokenTask;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
manager = new AccessTokenManagerByDB();
|
||||
|
||||
mockSessionManager = Mockito.mock(SessionManager.class);
|
||||
Field field = SessionManager.class.getDeclaredField("instance");
|
||||
field.setAccessible(true);
|
||||
field.set(null, mockSessionManager);
|
||||
|
||||
executeTokenTask = AccessTokenManagerByDB.class.getDeclaredMethod("executeTokenTask", String.class,
|
||||
OutboundOAuthCredentialVo.class);
|
||||
executeTokenTask.setAccessible(true);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
Field field = AccessTokenManagerByDB.class.getDeclaredField("scheduler");
|
||||
field.setAccessible(true);
|
||||
((ExecutorService) field.get(manager)).shutdownNow();
|
||||
}
|
||||
|
||||
/** 스케줄러 태스크를 한 번 실행한다. */
|
||||
private void runTask() throws Exception {
|
||||
OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo();
|
||||
credential.setAdapterGroupName(GROUP);
|
||||
credential.setUseYn("Y");
|
||||
credential.setIntervalSec(INTERVAL_SEC);
|
||||
|
||||
executeTokenTask.invoke(manager, GROUP, credential);
|
||||
}
|
||||
|
||||
/** 만료까지 expiresInSec 남은 캐시 토큰을 준비한다. */
|
||||
private void cached(String accessToken, Long expiresInSec) {
|
||||
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
|
||||
vo.setAccessToken(accessToken);
|
||||
if (expiresInSec != null) {
|
||||
vo.setExpiration(new Date(System.currentTimeMillis() + expiresInSec * 1000L));
|
||||
}
|
||||
Mockito.when(mockSessionManager.peekOutboundAccessToken(GROUP)).thenReturn(vo);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1. 캐시에 토큰이 없으면 분산락 경로로 발급한다")
|
||||
void 토큰없음_발급() throws Exception {
|
||||
Mockito.when(mockSessionManager.peekOutboundAccessToken(GROUP)).thenReturn(null);
|
||||
|
||||
runTask();
|
||||
|
||||
Mockito.verify(mockSessionManager).getOutboundAccessToken(eq(GROUP), any());
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2. 다음 틱 전에 만료되면 선제 갱신한다 (만료 전이어도)")
|
||||
void 만료임박_선제갱신() throws Exception {
|
||||
// 아직 20초 남았지만 다음 틱(30초 뒤) 전에 만료된다.
|
||||
cached("OLD-TOKEN", 20L);
|
||||
|
||||
runTask();
|
||||
|
||||
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD-TOKEN"), Mockito.anyLong(), any());
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3. 이미 만료된 토큰도 선제 갱신 경로로 간다")
|
||||
void 이미만료_갱신() throws Exception {
|
||||
cached("OLD-TOKEN", -60L);
|
||||
|
||||
runTask();
|
||||
|
||||
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD-TOKEN"), Mockito.anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4. 다음 틱까지 유효하면 아무것도 하지 않는다")
|
||||
void 유효하면_그대로() throws Exception {
|
||||
cached("VALID-TOKEN", 600L);
|
||||
|
||||
runTask();
|
||||
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5. 만료시각이 없는 토큰은 갱신 판단을 하지 않는다")
|
||||
void 만료시각없음_보류() throws Exception {
|
||||
cached("NO-EXPIRATION", null);
|
||||
|
||||
runTask();
|
||||
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), Mockito.anyLong(), any());
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.eactive.eai.common.authoutbound;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import com.openbanking.eai.common.token.AccessTokenVO;
|
||||
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
|
||||
|
||||
/**
|
||||
* AccessTokenManagerByDB 의 토큰 발급 이력 단위테스트
|
||||
*
|
||||
* 이력 기록은 private 이라 리플렉션으로 호출한다. 조회(getIssueHistories)는 공개 API 다.
|
||||
*/
|
||||
class TokenIssueHistoryTest {
|
||||
|
||||
private static final String GROUP = "TESTGRP";
|
||||
|
||||
private AccessTokenManagerByDB manager;
|
||||
private Method recordMethod;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
manager = new AccessTokenManagerByDB();
|
||||
recordMethod = AccessTokenManagerByDB.class.getDeclaredMethod("recordIssueHistory", String.class, String.class,
|
||||
String.class, long.class, AccessTokenVO.class, String.class);
|
||||
recordMethod.setAccessible(true);
|
||||
}
|
||||
|
||||
private void record(String trigger, String serviceClass, AccessTokenVO token, String failReason) throws Exception {
|
||||
recordMethod.invoke(manager, GROUP, trigger, serviceClass, System.currentTimeMillis(), token, failReason);
|
||||
}
|
||||
|
||||
private OAuth2AccessTokenVO token(String accessToken) {
|
||||
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
|
||||
vo.setAccessToken(accessToken);
|
||||
vo.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1. 발급 성공 이력을 남기고 토큰은 마스킹한다")
|
||||
void 성공이력() throws Exception {
|
||||
record(TokenIssueHistory.TRIGGER_SCHEDULE, "com.example.TokenService", token("abcdefghijklmnop"), null);
|
||||
|
||||
List<TokenIssueHistory> histories = manager.getIssueHistories(GROUP);
|
||||
|
||||
assertEquals(1, histories.size());
|
||||
TokenIssueHistory history = histories.get(0);
|
||||
assertTrue(history.isSuccess());
|
||||
assertEquals("SCHEDULE", history.getTrigger());
|
||||
assertEquals("com.example.TokenService", history.getServiceClass());
|
||||
assertEquals("abcdefgh***", history.getAccessTokenMasked());
|
||||
assertNull(history.getFailReason());
|
||||
assertFalse(history.getAccessTokenMasked().contains("ijklmnop"), "토큰 뒷부분이 남으면 안 된다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2. 실패 이력은 사유를 남기고 토큰은 담지 않는다")
|
||||
void 실패이력() throws Exception {
|
||||
record(TokenIssueHistory.TRIGGER_RETRY, "com.example.TokenService", null, "Connection refused");
|
||||
|
||||
TokenIssueHistory history = manager.getIssueHistories(GROUP).get(0);
|
||||
|
||||
assertFalse(history.isSuccess());
|
||||
assertEquals("RETRY", history.getTrigger());
|
||||
assertEquals("Connection refused", history.getFailReason());
|
||||
assertNull(history.getAccessTokenMasked());
|
||||
assertNull(history.getExpiration());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3. 빈 토큰은 사유를 채워 실패로 남긴다")
|
||||
void 빈토큰은_실패() throws Exception {
|
||||
record(TokenIssueHistory.TRIGGER_SCHEDULE, "com.example.TokenService", new OAuth2AccessTokenVO(), null);
|
||||
|
||||
TokenIssueHistory history = manager.getIssueHistories(GROUP).get(0);
|
||||
|
||||
assertFalse(history.isSuccess());
|
||||
assertTrue(history.getFailReason().contains("비어 있음"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4. 최근 10건만 보관하고 최신이 앞에 온다")
|
||||
void 최근10건만_보관() throws Exception {
|
||||
for (int i = 1; i <= 15; i++) {
|
||||
record(TokenIssueHistory.TRIGGER_SCHEDULE, "svc-" + i, token("token-" + i), null);
|
||||
}
|
||||
|
||||
List<TokenIssueHistory> histories = manager.getIssueHistories(GROUP);
|
||||
|
||||
assertEquals(10, histories.size());
|
||||
assertEquals("svc-15", histories.get(0).getServiceClass(), "최신 건이 앞에 와야 한다");
|
||||
assertEquals("svc-6", histories.get(9).getServiceClass(), "11번째부터는 밀려나야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5. 이력이 없는 어댑터그룹은 빈 목록")
|
||||
void 이력없음() {
|
||||
assertTrue(manager.getIssueHistories("NO-SUCH-GROUP").isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package com.eactive.eai.common.session;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.apache.ignite.Ignite;
|
||||
import org.apache.ignite.IgniteCache;
|
||||
import org.apache.ignite.IgniteTransactions;
|
||||
import org.apache.ignite.transactions.Transaction;
|
||||
import org.apache.ignite.transactions.TransactionConcurrency;
|
||||
import org.apache.ignite.transactions.TransactionIsolation;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import com.openbanking.eai.common.token.AccessTokenVO;
|
||||
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
|
||||
|
||||
/**
|
||||
* SessionManagerForIgnite.reissueOutboundAccessToken 단위테스트
|
||||
*
|
||||
* Ignite 캐시 자리에 ConcurrentHashMap 으로 동작하는 mock IgniteCache 를 리플렉션으로 주입하고,
|
||||
* 분산락 자리에는 어댑터그룹별 ReentrantLock 을 물려 락 안의 판정 로직과 경합 동작을 검증한다.
|
||||
*
|
||||
* 노드 간 상호배제 자체는 Ignite 의 lock(key) 가 보장하는 부분이라 여기서 검증할 수 없다.
|
||||
* 이 테스트가 보는 것은 "락을 잡은 뒤 무엇을 판단하는가" 이다.
|
||||
*/
|
||||
class SessionManagerForIgniteReissueTest {
|
||||
|
||||
private static final String GROUP = "TESTGRP";
|
||||
|
||||
private SessionManagerForIgnite sessionManager;
|
||||
|
||||
/** mock IgniteCache 의 실제 저장소 */
|
||||
private Map<String, AccessTokenVO> store;
|
||||
|
||||
/** 어댑터그룹별 락 (같은 키면 같은 인스턴스를 돌려줘야 경합 테스트가 성립한다) */
|
||||
private Map<String, Lock> locks;
|
||||
|
||||
/** readFromPrimary 가 여는 트랜잭션 */
|
||||
private Transaction transaction;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
sessionManager = new SessionManagerForIgnite();
|
||||
store = new ConcurrentHashMap<>();
|
||||
locks = new ConcurrentHashMap<>();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
IgniteCache<String, AccessTokenVO> cache = Mockito.mock(IgniteCache.class);
|
||||
|
||||
Mockito.when(cache.get(anyString())).thenAnswer(invocation -> store.get(invocation.getArgument(0)));
|
||||
Mockito.doAnswer(invocation -> {
|
||||
store.put(invocation.getArgument(0), invocation.getArgument(1));
|
||||
return null;
|
||||
}).when(cache).put(anyString(), any(AccessTokenVO.class));
|
||||
Mockito.when(cache.lock(anyString()))
|
||||
.thenAnswer(invocation -> locks.computeIfAbsent(invocation.getArgument(0),
|
||||
key -> new ReentrantLock()));
|
||||
|
||||
injectCache(cache);
|
||||
injectIgnite();
|
||||
}
|
||||
|
||||
/**
|
||||
* readFromPrimary 가 쓰는 Ignite 트랜잭션을 mock 으로 주입한다.
|
||||
* 주입하지 않으면 NPE 로 폴백 경로를 타게 되어 실제 동작을 검증하지 못한다.
|
||||
*/
|
||||
private void injectIgnite() throws Exception {
|
||||
transaction = Mockito.mock(Transaction.class);
|
||||
IgniteTransactions transactions = Mockito.mock(IgniteTransactions.class);
|
||||
Mockito.when(transactions.txStart(any(TransactionConcurrency.class), any(TransactionIsolation.class)))
|
||||
.thenReturn(transaction);
|
||||
|
||||
Ignite ignite = Mockito.mock(Ignite.class);
|
||||
Mockito.when(ignite.transactions()).thenReturn(transactions);
|
||||
|
||||
Field field = SessionManagerForIgnite.class.getDeclaredField("manager");
|
||||
field.setAccessible(true);
|
||||
field.set(null, ignite);
|
||||
}
|
||||
|
||||
/** private static cacheOutBoundAccessToken 에 mock 을 주입한다. */
|
||||
private void injectCache(IgniteCache<String, AccessTokenVO> cache) throws Exception {
|
||||
Field field = SessionManagerForIgnite.class.getDeclaredField("cacheOutBoundAccessToken");
|
||||
field.setAccessible(true);
|
||||
field.set(null, cache);
|
||||
}
|
||||
|
||||
/** 만료되지 않은 토큰 */
|
||||
private OAuth2AccessTokenVO token(String accessToken) {
|
||||
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
|
||||
vo.setAccessToken(accessToken);
|
||||
vo.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 호출 횟수를 세면서 지정한 토큰을 발급하는 supplier */
|
||||
private Function<AccessTokenVO, AccessTokenVO> supplier(AtomicInteger counter, String newToken) {
|
||||
return current -> {
|
||||
counter.incrementAndGet();
|
||||
return newToken == null ? null : token(newToken);
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1. 캐시가 비어 있으면 발급하고 캐시에 저장한다")
|
||||
void 캐시비었을때_발급() {
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(1, issued.get());
|
||||
assertEquals("NEW", result.getAccessToken());
|
||||
assertEquals("NEW", store.get(GROUP).getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2. 캐시 토큰이 거부된 토큰과 같으면 재발급한다")
|
||||
void 같은토큰이면_재발급() {
|
||||
store.put(GROUP, token("OLD"));
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(1, issued.get());
|
||||
assertEquals("NEW", result.getAccessToken());
|
||||
assertEquals("NEW", store.get(GROUP).getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3. 다른 노드가 이미 갱신했으면 발급하지 않고 그 토큰을 쓴다")
|
||||
void 이미갱신됨_발급안함() {
|
||||
store.put(GROUP, token("ALREADY-NEW"));
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(0, issued.get(), "다른 토큰이 이미 캐시에 있으면 발급하지 않아야 한다");
|
||||
assertEquals("ALREADY-NEW", result.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4. 빈 토큰이 발급되면 캐시에 저장하지 않는다")
|
||||
void 빈토큰_미캐싱() {
|
||||
store.put(GROUP, token("OLD"));
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, null));
|
||||
|
||||
assertEquals(1, issued.get());
|
||||
assertNull(result);
|
||||
assertEquals("OLD", store.get(GROUP).getAccessToken(), "캐시는 그대로여야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5. 락을 얻지 못하면 발급하지 않고 기존 토큰을 반환한다")
|
||||
void 락획득실패() throws Exception {
|
||||
store.put(GROUP, token("OLD"));
|
||||
|
||||
Lock neverAcquired = Mockito.mock(Lock.class);
|
||||
Mockito.when(neverAcquired.tryLock(Mockito.anyLong(), any(TimeUnit.class))).thenReturn(false);
|
||||
locks.put(GROUP, neverAcquired);
|
||||
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(0, issued.get());
|
||||
assertEquals("OLD", result.getAccessToken());
|
||||
Mockito.verify(neverAcquired, Mockito.never()).unlock();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5-1. 캐시 토큰이 요구 시각까지 유효하면 값이 같아도 발급하지 않는다")
|
||||
void 충분히유효하면_발급안함() {
|
||||
// 다른 노드가 방금 넣어둔 토큰. 조회 시점 차이로 oldToken 과 값이 같게 들어와도
|
||||
// 다음 틱까지 유효하면 다시 발급할 이유가 없다.
|
||||
store.put(GROUP, token("SAME-TOKEN"));
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
long validUntil = System.currentTimeMillis() + 60_000L;
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "SAME-TOKEN", validUntil,
|
||||
supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(0, issued.get(), "요구 시각까지 유효하면 발급하지 않아야 한다");
|
||||
assertEquals("SAME-TOKEN", result.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5-2. 요구 시각 전에 만료되면 발급한다")
|
||||
void 요구시각전_만료면_발급() {
|
||||
store.put(GROUP, token("SAME-TOKEN"));
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
// 캐시 토큰은 10분 뒤 만료인데 20분 뒤까지 유효해야 한다면 갱신 대상이다.
|
||||
long validUntil = System.currentTimeMillis() + 1_200_000L;
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "SAME-TOKEN", validUntil,
|
||||
supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(1, issued.get());
|
||||
assertEquals("NEW", result.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5-3. 캐시 조회는 비관적 트랜잭션 안에서 하고 바로 커밋한다")
|
||||
void 트랜잭션으로_읽는다() {
|
||||
store.put(GROUP, token("OLD"));
|
||||
|
||||
sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, supplier(new AtomicInteger(), "NEW"));
|
||||
|
||||
// 락 전 1회 + 락 안 1회
|
||||
Mockito.verify(transaction, Mockito.times(2)).commit();
|
||||
// 발급(HTTP) 구간까지 트랜잭션을 열어두면 장기 트랜잭션이 되어 파티션 맵 교환을 막는다.
|
||||
Mockito.verify(transaction, Mockito.never()).rollback();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("6. 경합 - 여러 스레드가 같은 토큰으로 동시에 재발급해도 발급은 한 번만 일어난다")
|
||||
void 동시재발급_한번만() throws Exception {
|
||||
final int threadCount = 20;
|
||||
store.put(GROUP, token("OLD"));
|
||||
|
||||
final AtomicInteger issued = new AtomicInteger();
|
||||
final Function<AccessTokenVO, AccessTokenVO> slowSupplier = current -> {
|
||||
issued.incrementAndGet();
|
||||
try {
|
||||
// 발급에 시간이 걸리는 상황을 만들어 경합을 유도한다.
|
||||
Thread.sleep(50L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return token("NEW");
|
||||
};
|
||||
|
||||
final CountDownLatch start = new CountDownLatch(1);
|
||||
final CountDownLatch done = new CountDownLatch(threadCount);
|
||||
final AccessTokenVO[] results = new AccessTokenVO[threadCount];
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
|
||||
try {
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
final int index = i;
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
start.await();
|
||||
results[index] = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", 0L, slowSupplier);
|
||||
} catch (Exception e) {
|
||||
// 결과가 null 로 남아 아래 검증에서 걸린다.
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start.countDown();
|
||||
assertTrue(done.await(30, TimeUnit.SECONDS), "모든 스레드가 끝나야 한다");
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
assertEquals(1, issued.get(), "동시에 들어와도 실제 발급은 한 번이어야 한다");
|
||||
assertEquals("NEW", store.get(GROUP).getAccessToken());
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
assertEquals("NEW", results[i].getAccessToken(), "모든 스레드가 새 토큰을 받아야 한다");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("7. 경합 - 어댑터그룹이 다르면 서로 막지 않는다")
|
||||
void 다른그룹은_독립적() throws Exception {
|
||||
final AtomicInteger issued = new AtomicInteger();
|
||||
final CountDownLatch bothInside = new CountDownLatch(2);
|
||||
final Function<AccessTokenVO, AccessTokenVO> blockingSupplier = current -> {
|
||||
issued.incrementAndGet();
|
||||
bothInside.countDown();
|
||||
try {
|
||||
// 두 그룹이 같은 락을 쓰면 여기서 서로를 기다리다 타임아웃된다.
|
||||
if (!bothInside.await(5, TimeUnit.SECONDS)) {
|
||||
return null;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return token("NEW");
|
||||
};
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
executor.submit(() -> sessionManager.reissueOutboundAccessToken("GRP-A", "OLD", 0L, blockingSupplier));
|
||||
executor.submit(() -> sessionManager.reissueOutboundAccessToken("GRP-B", "OLD", 0L, blockingSupplier));
|
||||
|
||||
assertTrue(bothInside.await(10, TimeUnit.SECONDS), "두 그룹이 동시에 발급 구간에 들어가야 한다");
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
assertEquals(2, issued.get());
|
||||
assertTrue(locks.containsKey("GRP-A") && locks.containsKey("GRP-B"), "그룹별로 락이 분리돼야 한다");
|
||||
assertSame(locks.get("GRP-A"), locks.get("GRP-A"));
|
||||
}
|
||||
}
|
||||
@@ -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 발급유발없음() {
|
||||
|
||||
Reference in New Issue
Block a user