From 324d8f8f8ae99423e9034a83f86819cdae6f5ab3 Mon Sep 17 00:00:00 2001 From: curry772 Date: Fri, 11 Sep 2026 14:07:50 +0900 Subject: [PATCH] =?UTF-8?q?OAuth=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어댑터그룹별 최근 10건을 노드 메모리에 보관. 성공뿐 아니라 실패도 남긴다 (발급 실패는 캐시에 아무것도 남지 않아 사후 추적이 어려웠음) - 기록 항목 : 시각, SCHEDULE/RETRY, 구현체, 성공여부, 소요시간, 만료시각, 실패사유 - accessToken 은 기록 시점에 앞 8자만 남겨 보관 - GET /manage/oauth-token/status/{어댑터그룹명} 에만 포함. 목록 조회에는 미포함 - 이력이 노드 로컬이므로 응답에 serverName 을 함께 담는다 - 단위테스트 6건 추가 --- .../authoutbound/AccessTokenManagerByDB.java | 99 +++++++++++++++- .../authoutbound/TokenIssueHistory.java | 61 ++++++++++ .../oauthtoken/OAuthTokenStatusDTO.java | 14 +++ .../oauthtoken/OAuthTokenStatusService.java | 35 +++++- .../authoutbound/TokenIssueHistoryTest.java | 111 ++++++++++++++++++ .../OAuthTokenStatusServiceTest.java | 18 +++ 6 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/eactive/eai/common/authoutbound/TokenIssueHistory.java create mode 100644 src/test/java/com/eactive/eai/common/authoutbound/TokenIssueHistoryTest.java diff --git a/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java b/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java index af6a344..2cd97a9 100644 --- a/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java +++ b/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java @@ -68,6 +68,15 @@ public class AccessTokenManagerByDB implements Lifecycle { */ private Map outboundOAuthCredentialVos; + /** 어댑터그룹별로 보관할 토큰 발급 이력 건수 */ + private static final int ISSUE_HISTORY_SIZE = 10; + + /** 이력에 남길 accessToken 앞자리 수 */ + private static final int UNMASKED_TOKEN_LENGTH = 8; + + /** 어댑터그룹별 토큰 발급 이력. 이 노드 메모리에만 존재하며 재기동 시 사라진다. */ + private final Map> 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 histories = issueHistories.computeIfAbsent(adapterGroupName, + key -> new ArrayDeque()); + + 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 getIssueHistories(String adapterGroupName) { + Deque histories = issueHistories.get(adapterGroupName); + if (histories == null) { + return Collections.emptyList(); + } + synchronized (histories) { + return new ArrayList<>(histories); + } + } + /** * 1. 기능 : 등록된 OAuth 인증 정보의 어댑터그룹명 목록을 반환한다. * 2. 처리 개요 : 상태 조회 API 에서 사용한다. diff --git a/src/main/java/com/eactive/eai/common/authoutbound/TokenIssueHistory.java b/src/main/java/com/eactive/eai/common/authoutbound/TokenIssueHistory.java new file mode 100644 index 0000000..4101f93 --- /dev/null +++ b/src/main/java/com/eactive/eai/common/authoutbound/TokenIssueHistory.java @@ -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); + } +} diff --git a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java index 590aa0e..6368e5a 100644 --- a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java +++ b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java @@ -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 issueHistory; } diff --git a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java index 1cdb6f4..20f217c 100644 --- a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java +++ b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java @@ -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 getStatusList() { List result = new ArrayList(); 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; + } + } + /** * 확인이 필요한 상태 메시지를 하나로 합친다. * diff --git a/src/test/java/com/eactive/eai/common/authoutbound/TokenIssueHistoryTest.java b/src/test/java/com/eactive/eai/common/authoutbound/TokenIssueHistoryTest.java new file mode 100644 index 0000000..30e3015 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/authoutbound/TokenIssueHistoryTest.java @@ -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 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 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()); + } +} diff --git a/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java b/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java index c60d2ed..db29459 100644 --- a/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java +++ b/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java @@ -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 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 발급유발없음() {