OAuth 토큰 발급 이력 조회 기능 추가

- 어댑터그룹별 최근 10건을 노드 메모리에 보관. 성공뿐 아니라 실패도 남긴다
  (발급 실패는 캐시에 아무것도 남지 않아 사후 추적이 어려웠음)
- 기록 항목 : 시각, SCHEDULE/RETRY, 구현체, 성공여부, 소요시간, 만료시각, 실패사유
- accessToken 은 기록 시점에 앞 8자만 남겨 보관
- GET /manage/oauth-token/status/{어댑터그룹명} 에만 포함. 목록 조회에는 미포함
- 이력이 노드 로컬이므로 응답에 serverName 을 함께 담는다
- 단위테스트 6건 추가
This commit is contained in:
curry772
2026-09-11 14:07:50 +09:00
parent 6d3cbe1d8b
commit 324d8f8f8a
6 changed files with 335 additions and 3 deletions
@@ -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;
@@ -315,7 +324,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 +349,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 +417,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 에서 사용한다.
@@ -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);
}
}
@@ -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건).
*
* 이 노드에서 일어난 발급만 담기며 재기동 시 사라진다.
* 목록 조회에는 담지 않고 어댑터그룹 단건 조회에서만 채운다.
*/
List<TokenIssueHistory> issueHistory;
}
@@ -19,6 +19,7 @@ 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.server.EAIServerManager;
import com.eactive.eai.common.util.Logger;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
@@ -52,22 +53,39 @@ public class OAuthTokenStatusService {
public List<OAuthTokenStatusDTO> getStatusList() {
List<OAuthTokenStatusDTO> result = new ArrayList<OAuthTokenStatusDTO>();
for (String adapterGroupName : AccessTokenManagerByDB.getInstance().getRegisteredAdapterGroupNames()) {
result.add(getStatus(adapterGroupName));
// 목록에는 발급 이력을 담지 않는다. 그룹이 많으면 응답이 지나치게 커진다.
result.add(getStatus(adapterGroupName, false));
}
return result;
}
/**
* 어댑터그룹 하나의 토큰 현황을 발급 이력과 함께 반환한다.
*
* @param adapterGroupName 어댑터그룹명
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
*/
public OAuthTokenStatusDTO getStatus(String adapterGroupName) {
return getStatus(adapterGroupName, true);
}
/**
* 어댑터그룹 하나의 토큰 현황을 반환한다.
*
* @param adapterGroupName 어댑터그룹명
* @param includeHistory 토큰 발급 이력 포함 여부
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
*/
public OAuthTokenStatusDTO getStatus(String adapterGroupName) {
private OAuthTokenStatusDTO getStatus(String adapterGroupName, boolean includeHistory) {
OAuthTokenStatusDTO dto = new OAuthTokenStatusDTO();
dto.setAdapterGroupName(adapterGroupName);
AccessTokenManagerByDB manager = AccessTokenManagerByDB.getInstance();
if (includeHistory) {
dto.setServerName(findLocalServerName());
dto.setIssueHistory(manager.getIssueHistories(adapterGroupName));
}
OutboundOAuthCredentialVo credential = manager.getOutboundOAuthCredentialVo(adapterGroupName);
if (credential == null) {
@@ -157,6 +175,19 @@ public class OAuthTokenStatusService {
return dto;
}
/**
* 이 응답을 만든 서버명을 찾는다. 발급 이력이 노드 로컬이라 함께 알려준다.
*
* @return 서버명. 조회하지 못하면 null.
*/
private String findLocalServerName() {
try {
return EAIServerManager.getInstance().getLocalServerName();
} catch (Exception e) {
return null;
}
}
/**
* 확인이 필요한 상태 메시지를 하나로 합친다.
*
@@ -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());
}
}
@@ -9,6 +9,7 @@ import static org.mockito.ArgumentMatchers.anyString;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
@@ -275,6 +276,23 @@ class OAuthTokenStatusServiceTest {
assertFalse(list.get(1).isCached());
}
@Test
@DisplayName("9-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));
Mockito.when(mockManager.getIssueHistories(GROUP)).thenReturn(Collections.emptyList());
OAuthTokenStatusDTO single = service.getStatus(GROUP);
OAuthTokenStatusDTO fromList = service.getStatusList().get(0);
assertNotNull(single.getIssueHistory(), "단건 조회에는 이력이 있어야 한다");
assertNull(fromList.getIssueHistory(), "목록에는 이력을 담지 않는다");
Mockito.verify(mockManager, Mockito.times(1)).getIssueHistories(GROUP);
}
@Test
@DisplayName("10. 조회가 토큰 발급을 유발하지 않는다 (peek 만 호출)")
void 발급유발없음() {