아웃바운드 OAuth 토큰 현황 조회 API 추가
- GET /manage/oauth-token/status[/{어댑터그룹명}] 조회 전용
- SessionManager.peekOutboundAccessToken 추가 : 캐시만 조회해 상태 조회가 토큰 발급을 유발하지 않도록 함
- AccessTokenManagerByDB 에 조회 전용 getter 추가
- accessToken 은 앞 8자만 노출하고 clientSecret 은 응답에 미포함
- Ehcache 백엔드는 아웃바운드 토큰 캐시 미지원이므로 사유를 담아 응답
- 단위테스트 10건 추가
This commit is contained in:
@@ -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) + "***";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user