1c48dd834a
- 목록에서 issueHistory 가 null 로만 나와 "값이 없는 것"과 "안 실린 것"이 구분되지 않던 문제 - 목록은 최근 1건, 단건 조회는 보관 중인 전체(최대 10건)를 담는다 - 이력이 없으면 null 대신 빈 목록을 반환하고, serverName 도 함께 담는다 - 단위테스트 2건 추가
321 lines
12 KiB
Java
321 lines
12 KiB
Java
package com.eactive.eai.manage.oauthtoken;
|
|
|
|
import java.text.SimpleDateFormat;
|
|
import java.util.ArrayList;
|
|
import java.util.Date;
|
|
import java.util.Iterator;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Properties;
|
|
|
|
import org.apache.commons.lang3.StringUtils;
|
|
import org.springframework.security.web.util.UrlUtils;
|
|
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.authoutbound.TokenIssueHistory;
|
|
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;
|
|
|
|
/**
|
|
* 아웃바운드 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;
|
|
|
|
/** 목록 조회에 담을 발급 이력 건수 */
|
|
private static final int LIST_HISTORY_SIZE = 1;
|
|
|
|
/**
|
|
* 등록된 모든 어댑터그룹의 토큰 현황을 반환한다.
|
|
*
|
|
* @return 어댑터그룹명 순으로 정렬된 현황 목록
|
|
*/
|
|
public List<OAuthTokenStatusDTO> getStatusList() {
|
|
List<OAuthTokenStatusDTO> result = new ArrayList<OAuthTokenStatusDTO>();
|
|
for (String adapterGroupName : AccessTokenManagerByDB.getInstance().getRegisteredAdapterGroupNames()) {
|
|
// 목록에는 최근 1건만 담는다. 전체를 담으면 그룹 수만큼 응답이 커진다.
|
|
result.add(getStatus(adapterGroupName, LIST_HISTORY_SIZE));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 어댑터그룹 하나의 토큰 현황을 발급 이력 전체와 함께 반환한다.
|
|
*
|
|
* @param adapterGroupName 어댑터그룹명
|
|
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
|
|
*/
|
|
public OAuthTokenStatusDTO getStatus(String adapterGroupName) {
|
|
return getStatus(adapterGroupName, Integer.MAX_VALUE);
|
|
}
|
|
|
|
/**
|
|
* 어댑터그룹 하나의 토큰 현황을 반환한다.
|
|
*
|
|
* @param adapterGroupName 어댑터그룹명
|
|
* @param historyLimit 담을 발급 이력 건수 (최근 순)
|
|
* @return 토큰 현황. 등록돼 있지 않으면 message 만 채워서 반환한다.
|
|
*/
|
|
private OAuthTokenStatusDTO getStatus(String adapterGroupName, int historyLimit) {
|
|
OAuthTokenStatusDTO dto = new OAuthTokenStatusDTO();
|
|
dto.setAdapterGroupName(adapterGroupName);
|
|
|
|
AccessTokenManagerByDB manager = AccessTokenManagerByDB.getInstance();
|
|
|
|
// 이력은 노드 로컬이므로 어느 노드가 응답했는지 함께 담는다.
|
|
dto.setServerName(findLocalServerName());
|
|
dto.setIssueHistory(latest(manager.getIssueHistories(adapterGroupName), historyLimit));
|
|
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());
|
|
// 스케줄러가 고르는 어댑터의 속성에서 발급 구현체와 기준 URL 을 함께 읽는다.
|
|
Properties schedulerAdapterProp = findSchedulerAdapterProperties(adapterGroupName);
|
|
if (schedulerAdapterProp != null) {
|
|
dto.setTokenServiceClass(schedulerAdapterProp.getProperty(PROP_TOKEN_SERVICE_CLASS));
|
|
dto.setTokenUrlResolved(resolveTokenUrl(schedulerAdapterProp.getProperty("URL"), credential.getUrl()));
|
|
}
|
|
|
|
Map<String, String> adapterClasses = findAdapterTokenServiceClasses(adapterGroupName);
|
|
dto.setAdapterTokenServiceClasses(adapterClasses);
|
|
|
|
// 확인이 필요한 상태는 모아서 message 하나로 돌려준다.
|
|
List<String> notes = new ArrayList<String>();
|
|
|
|
// init() 은 useYn 과 무관하게 인증 정보를 모두 등록하지만 startToken() 은 Y 일 때만 스케줄을 건다.
|
|
if (!"Y".equalsIgnoreCase(credential.getUseYn())) {
|
|
notes.add("OAuth 인증 정보가 사용 안 함(useYn=" + credential.getUseYn()
|
|
+ ") 상태라 스케줄러가 토큰을 발급하지 않습니다.");
|
|
}
|
|
|
|
if (adapterClasses == null) {
|
|
notes.add("어댑터그룹을 찾을 수 없습니다. 그룹명이 정확한지, 그룹 사용여부가 '사용' 인지 확인하세요.");
|
|
} else if (adapterClasses.isEmpty()) {
|
|
notes.add("어댑터그룹에 이 서버로 배정된 어댑터가 없어 토큰을 발급할 수 없습니다.");
|
|
}
|
|
|
|
AccessTokenVO accessToken;
|
|
try {
|
|
accessToken = manager.peekAccessTokenVO(adapterGroupName);
|
|
} catch (UnsupportedOperationException e) {
|
|
notes.add("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다.");
|
|
dto.setMessage(joinNotes(notes));
|
|
return dto;
|
|
} catch (Exception e) {
|
|
if (logger.isWarn()) {
|
|
logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e);
|
|
}
|
|
notes.add("토큰 캐시 조회 실패 : " + e.getMessage());
|
|
dto.setMessage(joinNotes(notes));
|
|
return dto;
|
|
}
|
|
|
|
if (accessToken == null) {
|
|
notes.add("캐시에 토큰이 없습니다.");
|
|
dto.setMessage(joinNotes(notes));
|
|
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());
|
|
}
|
|
|
|
// 만료시각이 없는 토큰은 isExpired() 가 false 라 재발급 대상이 되지 않는다.
|
|
// 빈 토큰이 캐시에 들어가면 기동 후 계속 그대로 사용되므로 조회 시 눈에 띄게 알린다.
|
|
if (StringUtils.isBlank(accessToken.getAccessToken())) {
|
|
notes.add("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다."
|
|
+ " 만료시각이 없어 자동 재발급되지 않습니다.");
|
|
} else if (expiration == null) {
|
|
notes.add("만료시각이 없어 자동 재발급되지 않습니다.");
|
|
}
|
|
|
|
dto.setMessage(joinNotes(notes));
|
|
|
|
return dto;
|
|
}
|
|
|
|
/**
|
|
* 발급 이력에서 최근 건만 잘라낸다. 이력은 이미 최근 순으로 정렬돼 있다.
|
|
*
|
|
* @param histories 발급 이력
|
|
* @param limit 담을 건수
|
|
* @return 최근 limit 건
|
|
*/
|
|
private List<TokenIssueHistory> latest(List<TokenIssueHistory> histories, int limit) {
|
|
if (histories.size() <= limit) {
|
|
return histories;
|
|
}
|
|
return new ArrayList<TokenIssueHistory>(histories.subList(0, limit));
|
|
}
|
|
|
|
/**
|
|
* 이 응답을 만든 서버명을 찾는다. 발급 이력이 노드 로컬이라 함께 알려준다.
|
|
*
|
|
* @return 서버명. 조회하지 못하면 null.
|
|
*/
|
|
private String findLocalServerName() {
|
|
try {
|
|
return EAIServerManager.getInstance().getLocalServerName();
|
|
} catch (Exception e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 확인이 필요한 상태 메시지를 하나로 합친다.
|
|
*
|
|
* @param notes 메시지 목록
|
|
* @return 합친 메시지. 없으면 null.
|
|
*/
|
|
private String joinNotes(List<String> notes) {
|
|
if (notes.isEmpty()) {
|
|
return null;
|
|
}
|
|
return StringUtils.join(notes, " ");
|
|
}
|
|
|
|
/**
|
|
* 스케줄러가 토큰 발급에 사용하는 어댑터의 속성을 찾는다.
|
|
*
|
|
* AccessTokenManagerByDB.issueToken() 과 동일하게 getAdapters().next() 로 어댑터 하나를 고른다.
|
|
*
|
|
* @param adapterGroupName 어댑터그룹명
|
|
* @return 어댑터 속성. 찾지 못하면 null.
|
|
*/
|
|
private Properties findSchedulerAdapterProperties(String adapterGroupName) {
|
|
try {
|
|
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
|
|
if (gvo == null || !gvo.getAdapters().hasNext()) {
|
|
return null;
|
|
}
|
|
AdapterVO avo = gvo.getAdapters().next();
|
|
return AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
|
|
} catch (Exception e) {
|
|
if (logger.isDebug()) {
|
|
logger.debug("어댑터 속성 조회 실패. adapterGroupName : " + adapterGroupName);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 실제로 호출되는 토큰 발급 URL 을 만든다.
|
|
*
|
|
* 구현체들과 동일하게, 절대 URL 이 아니면 어댑터 속성 URL 뒤에 이어 붙인다.
|
|
*
|
|
* @param adapterUrl 어댑터 속성의 URL
|
|
* @param tokenUrl DB 에 설정된 토큰 발급 URL
|
|
* @return 조합된 URL. 만들 수 없으면 null.
|
|
*/
|
|
String resolveTokenUrl(String adapterUrl, String tokenUrl) {
|
|
if (StringUtils.isBlank(tokenUrl)) {
|
|
return null;
|
|
}
|
|
if (UrlUtils.isAbsoluteUrl(tokenUrl)) {
|
|
return tokenUrl;
|
|
}
|
|
if (StringUtils.isBlank(adapterUrl)) {
|
|
return null;
|
|
}
|
|
if (!adapterUrl.endsWith("/") && !tokenUrl.startsWith("/")) {
|
|
return adapterUrl + "/" + tokenUrl;
|
|
}
|
|
if (adapterUrl.endsWith("/") && tokenUrl.startsWith("/")) {
|
|
return adapterUrl + tokenUrl.substring(1);
|
|
}
|
|
return adapterUrl + tokenUrl;
|
|
}
|
|
|
|
/**
|
|
* 그룹에 속한 어댑터별로 토큰 발급 구현체 클래스명을 모은다.
|
|
*
|
|
* 스케줄러(issueToken)는 getAdapters().next() 로 어댑터 하나만 골라 쓰고, 거래 중 재발급
|
|
* (retryAccessTokenVO)은 호출한 어댑터 자신의 속성을 쓴다. 어댑터마다 설정이 다르면
|
|
* 서로 다른 구현체가 동작하므로 전체를 보여준다.
|
|
*
|
|
* @param adapterGroupName 어댑터그룹명
|
|
* @return 어댑터명 → 구현체 클래스명. 어댑터그룹 자체를 찾지 못하면 null, 어댑터가 없으면 빈 Map.
|
|
*/
|
|
private Map<String, String> findAdapterTokenServiceClasses(String adapterGroupName) {
|
|
Map<String, String> result = new LinkedHashMap<String, String>();
|
|
try {
|
|
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
|
|
if (gvo == null) {
|
|
return null;
|
|
}
|
|
Iterator<AdapterVO> adapters = gvo.getAdapters();
|
|
while (adapters.hasNext()) {
|
|
AdapterVO avo = adapters.next();
|
|
Properties properties = AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
|
|
result.put(avo.getName(), properties.getProperty(PROP_TOKEN_SERVICE_CLASS));
|
|
}
|
|
} catch (Exception e) {
|
|
if (logger.isDebug()) {
|
|
logger.debug("어댑터별 토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName);
|
|
}
|
|
return null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 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) + "***";
|
|
}
|
|
}
|