아웃바운드 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();
@@ -823,6 +823,14 @@ public class SessionManagerForIgnite extends SessionManager {
public CacheInfoVO getOutboundAccessTokenCache() {
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) {
@@ -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) + "***";
}
}