OAuth 토큰 발급 이력 조회 기능 추가
- 어댑터그룹별 최근 10건을 노드 메모리에 보관. 성공뿐 아니라 실패도 남긴다
(발급 실패는 캐시에 아무것도 남지 않아 사후 추적이 어려웠음)
- 기록 항목 : 시각, SCHEDULE/RETRY, 구현체, 성공여부, 소요시간, 만료시각, 실패사유
- accessToken 은 기록 시점에 앞 8자만 남겨 보관
- GET /manage/oauth-token/status/{어댑터그룹명} 에만 포함. 목록 조회에는 미포함
- 이력이 노드 로컬이므로 응답에 serverName 을 함께 담는다
- 단위테스트 6건 추가
This commit is contained in:
@@ -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 발급유발없음() {
|
||||
|
||||
Reference in New Issue
Block a user