아웃바운드 OAuth 토큰 현황 조회 API 추가

- GET /manage/oauth-token/status[/{어댑터그룹명}] 조회 전용
- SessionManager.peekOutboundAccessToken 추가 : 캐시만 조회해 상태 조회가 토큰 발급을 유발하지 않도록 함
- AccessTokenManagerByDB 에 조회 전용 getter 추가
- accessToken 은 앞 8자만 노출하고 clientSecret 은 응답에 미포함
- Ehcache 백엔드는 아웃바운드 토큰 캐시 미지원이므로 사유를 담아 응답
- 단위테스트 10건 추가
This commit is contained in:
curry772
2026-09-10 16:35:45 +09:00
parent af5695f6bf
commit 7723e830cf
8 changed files with 543 additions and 0 deletions
@@ -384,6 +384,39 @@ public class AccessTokenManagerByDB implements Lifecycle {
lifecycle.removeLifecycleListener(listener);
}
/**
* 1. 기능 : 등록된 OAuth 인증 정보의 어댑터그룹명 목록을 반환한다.
* 2. 처리 개요 : 상태 조회 API 에서 사용한다.
*
* @return 어댑터그룹명 목록
**/
public Set<String> getRegisteredAdapterGroupNames() {
return new TreeSet<>(outboundOAuthCredentialVos.keySet());
}
/**
* 1. 기능 : 어댑터그룹의 OAuth 인증 정보를 반환한다.
* 2. 처리 개요 : 상태 조회 API 에서 사용한다.
* 3. 주의사항 : clientSecret 이 들어있으므로 응답 전문에 그대로 담지 말 것.
*
* @return OAuth 인증 정보. 등록돼 있지 않으면 null.
**/
public OutboundOAuthCredentialVo getOutboundOAuthCredentialVo(String adapterGroupName) {
return outboundOAuthCredentialVos.get(adapterGroupName);
}
/**
* 1. 기능 : 캐시에 있는 토큰만 조회한다.
* 2. 처리 개요 : getAccessTokenVO() 와 달리 캐시에 없거나 만료됐어도 새로 발급하지 않는다.
* 상태 조회가 토큰 발급을 유발하면 안 되므로 조회 API 는 이 메서드를 쓴다.
*
* @return 캐시에 있는 토큰. 없으면 null.
* @exception UnsupportedOperationException 아웃바운드 토큰 캐시를 지원하지 않는 SessionManager 백엔드
**/
public AccessTokenVO peekAccessTokenVO(String adapterGroupName) {
return SessionManager.getInstance().peekOutboundAccessToken(adapterGroupName);
}
public boolean isOAuthCredentialRegistered(String adapterGroupName){
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
return outboundOAuthCredentialVo != null && "Y".equals(outboundOAuthCredentialVo.getUseYn());
@@ -214,6 +214,14 @@ public abstract class SessionManager implements Lifecycle {
//Outbound Access Token Cache
public abstract AccessTokenVO getOutboundAccessToken(String key, Function<AccessTokenVO, AccessTokenVO> getNewToken);
/**
* 캐시에 있는 토큰만 조회한다. 없거나 만료됐어도 새로 발급하지 않는다. (상태 조회용)
*
* @param key 어댑터그룹명
* @return 캐시에 있는 토큰. 없으면 null.
*/
public abstract AccessTokenVO peekOutboundAccessToken(String key);
public abstract void removeOutboundAccessToken(String key);
public abstract void clearOutboundAccessToken();
@@ -951,6 +951,11 @@ public class SessionManagerForEhcache extends SessionManager {
throw new UnsupportedOperationException();
}
@Override
public AccessTokenVO peekOutboundAccessToken(String key) {
throw new UnsupportedOperationException();
}
@Override
public void removeOutboundAccessToken(String key) {
throw new UnsupportedOperationException();
@@ -824,6 +824,14 @@ public class SessionManagerForIgnite extends SessionManager {
return convert(cacheOutBoundAccessToken);
}
@Override
public AccessTokenVO peekOutboundAccessToken(String key) {
if (cacheOutBoundAccessToken == null) {
return null;
}
return cacheOutBoundAccessToken.get(key);
}
@Override
public void putWebSocketTimeout(String key, SessionVO value) {
cacheWebSocketTimeout.put(key, value);
@@ -0,0 +1,62 @@
package com.eactive.eai.manage.oauthtoken;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 아웃바운드 OAuth 토큰 현황 조회 API. (HsmStatusController 와 동일한 응답 형태)
*
* 조회 전용이며 토큰을 발급하거나 캐시를 건드리지 않는다.
* accessToken 은 앞 8자만 남기고 마스킹하며 clientSecret 은 응답에 담지 않는다.
*
* GET /manage/oauth-token/status → 등록된 전체 어댑터그룹의 토큰 현황
* GET /manage/oauth-token/status/{adapterGroupName} → 어댑터그룹 하나의 토큰 현황
*/
@RestController
@RequestMapping("/manage/oauth-token")
public class OAuthTokenStatusController {
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
@Autowired
private OAuthTokenStatusService oAuthTokenStatusService;
@GetMapping("/status")
public ResponseEntity<?> status() {
return respond(() -> {
List<OAuthTokenStatusDTO> list = oAuthTokenStatusService.getStatusList();
Map<String, Object> data = new HashMap<>();
data.put("count", list.size());
data.put("tokens", list);
return data;
});
}
@GetMapping("/status/{adapterGroupName}")
public ResponseEntity<?> status(@PathVariable("adapterGroupName") String adapterGroupName) {
return respond(() -> oAuthTokenStatusService.getStatus(adapterGroupName));
}
private ResponseEntity<?> respond(Callable<Object> action) {
Map<String, Object> result = new HashMap<>();
try {
result.put("success", true);
result.put("data", action.call());
} catch (Exception e) {
result.put("success", false);
result.put("message", e.getMessage());
}
return ResponseEntity.ok().contentType(APPLICATION_JSON_UTF8).body(result);
}
}
@@ -0,0 +1,60 @@
package com.eactive.eai.manage.oauthtoken;
import lombok.Data;
/**
* 어댑터그룹별 아웃바운드 OAuth 토큰 현황.
*
* accessToken 값은 마스킹해서 담는다. clientSecret 은 담지 않는다.
*/
@Data
public class OAuthTokenStatusDTO {
// ---- 등록 정보 ----
/** 어댑터그룹명 */
String adapterGroupName;
/** OAuth 인증 정보 사용 여부 (Y/N) */
String useYn;
/** 토큰 발급에 사용하는 구현체 클래스명 (어댑터 속성 ADAPTER_TOKEN_ISSUING_CLIENT_TYPE) */
String tokenServiceClass;
/** 토큰 발급 URL */
String tokenUrl;
/** 토큰 재발급 주기(초) */
int intervalSec;
// ---- 캐시 상태 ----
/** 캐시에 토큰이 있는지 */
boolean cached;
/** 캐시된 토큰이 만료됐는지 */
boolean expired;
/** 만료 시각 */
String expiration;
/** 만료까지 남은 초. 이미 만료됐으면 음수. */
Long remainSec;
// ---- 토큰 정보 ----
/** 앞 8자만 남기고 마스킹한 accessToken */
String accessTokenMasked;
/** accessToken 전체 길이 */
Integer accessTokenLength;
String tokenType;
String scope;
String clientId;
/** 조회 불가 사유 등 부가 메시지 */
String message;
}
@@ -0,0 +1,157 @@
package com.eactive.eai.manage.oauthtoken;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Properties;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import com.eactive.eai.adapter.AdapterGroupVO;
import com.eactive.eai.adapter.AdapterManager;
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.util.Logger;
import com.openbanking.eai.common.token.AccessTokenVO;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* 아웃바운드 OAuth 토큰 현황 조회 서비스.
*
* 캐시에 있는 토큰만 읽는다(peek). 조회 때문에 토큰이 새로 발급되지 않도록
* AccessTokenManagerByDB.getAccessTokenVO() 가 아니라 peekAccessTokenVO() 를 쓴다.
*
* accessToken 은 앞 8자만 남기고 마스킹하며 clientSecret 은 담지 않는다.
*/
@Service
public class OAuthTokenStatusService {
private static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
/** 토큰 발급 구현체 클래스명을 담고 있는 어댑터 속성 키 */
private static final String PROP_TOKEN_SERVICE_CLASS = "ADAPTER_TOKEN_ISSUING_CLIENT_TYPE";
/** accessToken 중 노출할 앞자리 수 */
private static final int UNMASKED_LENGTH = 8;
/**
* 등록된 모든 어댑터그룹의 토큰 현황을 반환한다.
*
* @return 어댑터그룹명 순으로 정렬된 현황 목록
*/
public List<OAuthTokenStatusDTO> getStatusList() {
List<OAuthTokenStatusDTO> result = new ArrayList<OAuthTokenStatusDTO>();
for (String adapterGroupName : AccessTokenManagerByDB.getInstance().getRegisteredAdapterGroupNames()) {
result.add(getStatus(adapterGroupName));
}
return result;
}
/**
* 어댑터그룹 하나의 토큰 현황을 반환한다.
*
* @param adapterGroupName 어댑터그룹명
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
*/
public OAuthTokenStatusDTO getStatus(String adapterGroupName) {
OAuthTokenStatusDTO dto = new OAuthTokenStatusDTO();
dto.setAdapterGroupName(adapterGroupName);
AccessTokenManagerByDB manager = AccessTokenManagerByDB.getInstance();
OutboundOAuthCredentialVo credential = manager.getOutboundOAuthCredentialVo(adapterGroupName);
if (credential == null) {
dto.setMessage("등록된 OAuth 인증 정보가 없습니다.");
return dto;
}
dto.setUseYn(credential.getUseYn());
dto.setTokenUrl(credential.getUrl());
dto.setIntervalSec(credential.getIntervalSec());
dto.setTokenServiceClass(findTokenServiceClass(adapterGroupName));
AccessTokenVO accessToken;
try {
accessToken = manager.peekAccessTokenVO(adapterGroupName);
} catch (UnsupportedOperationException e) {
dto.setMessage("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다.");
return dto;
} catch (Exception e) {
if (logger.isWarn()) {
logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e);
}
dto.setMessage("토큰 캐시 조회 실패 : " + e.getMessage());
return dto;
}
if (accessToken == null) {
dto.setMessage("캐시에 토큰이 없습니다.");
return dto;
}
dto.setCached(true);
dto.setExpired(accessToken.isExpired());
dto.setAccessTokenMasked(mask(accessToken.getAccessToken()));
dto.setAccessTokenLength(accessToken.getAccessToken() == null ? 0 : accessToken.getAccessToken().length());
Date expiration = accessToken.getExpiration();
if (expiration != null) {
dto.setExpiration(new SimpleDateFormat(DATE_FORMAT).format(expiration));
dto.setRemainSec((expiration.getTime() - System.currentTimeMillis()) / 1000L);
}
if (accessToken instanceof OAuth2AccessTokenVO) {
OAuth2AccessTokenVO oauth2Token = (OAuth2AccessTokenVO) accessToken;
dto.setTokenType(oauth2Token.getTokenType());
dto.setScope(oauth2Token.getScope());
dto.setClientId(oauth2Token.getClientId());
}
return dto;
}
/**
* 어댑터 속성에서 토큰 발급 구현체 클래스명을 찾는다.
*
* @param adapterGroupName 어댑터그룹명
* @return 구현체 클래스명. 찾지 못하면 null.
*/
private String findTokenServiceClass(String adapterGroupName) {
try {
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (gvo == null || !gvo.getAdapters().hasNext()) {
return null;
}
AdapterVO avo = gvo.getAdapters().next();
Properties properties = AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
return properties.getProperty(PROP_TOKEN_SERVICE_CLASS);
} catch (Exception e) {
if (logger.isDebug()) {
logger.debug("토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName);
}
return null;
}
}
/**
* accessToken 앞 8자만 남기고 마스킹한다.
*
* @param accessToken 토큰 값
* @return 마스킹된 토큰 값
*/
private String mask(String accessToken) {
if (StringUtils.isBlank(accessToken)) {
return null;
}
if (accessToken.length() <= UNMASKED_LENGTH) {
return StringUtils.repeat('*', accessToken.length());
}
return StringUtils.substring(accessToken, 0, UNMASKED_LENGTH) + "***";
}
}
@@ -0,0 +1,210 @@
package com.eactive.eai.manage.oauthtoken;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.Date;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.context.ApplicationContext;
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
import com.eactive.eai.common.authoutbound.AccessTokenManagerByDB;
import com.eactive.eai.common.util.ApplicationContextProvider;
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
/**
* OAuthTokenStatusService 단위테스트
*
* AccessTokenManagerByDB 는 ApplicationContextProvider 를 통해 조회되므로
* mock ApplicationContext 를 리플렉션으로 주입해 격리한다.
*/
class OAuthTokenStatusServiceTest {
private static final String GROUP = "TESTGRP";
private OAuthTokenStatusService service;
private AccessTokenManagerByDB mockManager;
@BeforeEach
void setUp() throws Exception {
service = new OAuthTokenStatusService();
mockManager = Mockito.mock(AccessTokenManagerByDB.class);
ApplicationContext mockCtx = Mockito.mock(ApplicationContext.class);
Mockito.when(mockCtx.getBean(AccessTokenManagerByDB.class)).thenReturn(mockManager);
Field ctxField = ApplicationContextProvider.class.getDeclaredField("context");
ctxField.setAccessible(true);
ctxField.set(null, mockCtx);
}
/** 인증 정보가 등록된 것으로 mock 설정한다. */
private OutboundOAuthCredentialVo registerCredential(String adapterGroupName) {
OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo();
credential.setAdapterGroupName(adapterGroupName);
credential.setUseYn("Y");
credential.setUrl("https://token.example.com/oauth/token");
credential.setIntervalSec(1800);
credential.setClientId("client-1");
credential.setClientSecret("super-secret");
Mockito.when(mockManager.getOutboundOAuthCredentialVo(adapterGroupName)).thenReturn(credential);
return credential;
}
/** 만료까지 expiresInSec 남은 토큰 */
private OAuth2AccessTokenVO token(String accessToken, long expiresInSec) {
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
vo.setAccessToken(accessToken);
vo.setTokenType("Bearer");
vo.setScope("read");
vo.setClientId("client-1");
vo.setExpiration(new Date(System.currentTimeMillis() + expiresInSec * 1000L));
return vo;
}
@Test
@DisplayName("1. 등록되지 않은 어댑터그룹이면 사유만 반환한다")
void 미등록그룹() {
Mockito.when(mockManager.getOutboundOAuthCredentialVo(anyString())).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertEquals(GROUP, dto.getAdapterGroupName());
assertFalse(dto.isCached());
assertNotNull(dto.getMessage());
}
@Test
@DisplayName("2. 캐시에 토큰이 없으면 등록 정보만 반환한다")
void 캐시에토큰없음() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertFalse(dto.isCached());
assertEquals("Y", dto.getUseYn());
assertEquals(1800, dto.getIntervalSec());
assertEquals("https://token.example.com/oauth/token", dto.getTokenUrl());
assertNotNull(dto.getMessage());
}
@Test
@DisplayName("3. 토큰 캐시를 지원하지 않는 백엔드면 사유를 담아 반환한다 (Ehcache)")
void 백엔드미지원() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenThrow(new UnsupportedOperationException());
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertFalse(dto.isCached());
assertTrue(dto.getMessage().contains("지원하지 않습니다"));
}
@Test
@DisplayName("4. 캐시된 토큰의 만료/잔여시간/부가정보를 반환한다")
void 캐시된토큰() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijklmnop", 600));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertTrue(dto.isCached());
assertFalse(dto.isExpired());
assertNotNull(dto.getExpiration());
assertTrue(dto.getRemainSec() > 0 && dto.getRemainSec() <= 600);
assertEquals("Bearer", dto.getTokenType());
assertEquals("read", dto.getScope());
assertEquals("client-1", dto.getClientId());
}
@Test
@DisplayName("5. accessToken 은 앞 8자만 남기고 마스킹한다")
void 토큰마스킹() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijklmnop", 600));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertEquals("abcdefgh***", dto.getAccessTokenMasked());
assertEquals(16, dto.getAccessTokenLength());
assertFalse(dto.getAccessTokenMasked().contains("ijklmnop"), "토큰 뒷부분이 노출되면 안 된다");
}
@Test
@DisplayName("6. 8자 이하 토큰은 전부 마스킹한다")
void 짧은토큰마스킹() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abc", 600));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertEquals("***", dto.getAccessTokenMasked());
assertEquals(3, dto.getAccessTokenLength());
}
@Test
@DisplayName("7. 만료된 토큰은 expired=true, 잔여시간 음수")
void 만료된토큰() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", -60));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertTrue(dto.isCached());
assertTrue(dto.isExpired());
assertTrue(dto.getRemainSec() < 0);
}
@Test
@DisplayName("8. 응답에 clientSecret 은 담기지 않는다")
void secret비노출() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertFalse(dto.toString().contains("super-secret"), "clientSecret 이 응답에 포함되면 안 된다");
}
@Test
@DisplayName("9. 전체 목록은 등록된 어댑터그룹 수만큼 반환한다")
void 전체목록() {
Set<String> groups = new LinkedHashSet<>(Arrays.asList("GRP-A", "GRP-B"));
Mockito.when(mockManager.getRegisteredAdapterGroupNames()).thenReturn(groups);
registerCredential("GRP-A");
registerCredential("GRP-B");
Mockito.when(mockManager.peekAccessTokenVO("GRP-A")).thenReturn(token("abcdefghijkl", 600));
Mockito.when(mockManager.peekAccessTokenVO("GRP-B")).thenReturn(null);
List<OAuthTokenStatusDTO> list = service.getStatusList();
assertEquals(2, list.size());
assertEquals("GRP-A", list.get(0).getAdapterGroupName());
assertTrue(list.get(0).isCached());
assertFalse(list.get(1).isCached());
}
@Test
@DisplayName("10. 조회가 토큰 발급을 유발하지 않는다 (peek 만 호출)")
void 발급유발없음() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(token("abcdefghijkl", 600));
service.getStatus(GROUP);
Mockito.verify(mockManager).peekAccessTokenVO(GROUP);
Mockito.verify(mockManager, Mockito.never()).getAccessTokenVO(anyString());
}
}