아웃바운드 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);
|
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){
|
public boolean isOAuthCredentialRegistered(String adapterGroupName){
|
||||||
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
|
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
|
||||||
return outboundOAuthCredentialVo != null && "Y".equals(outboundOAuthCredentialVo.getUseYn());
|
return outboundOAuthCredentialVo != null && "Y".equals(outboundOAuthCredentialVo.getUseYn());
|
||||||
|
|||||||
@@ -214,6 +214,14 @@ public abstract class SessionManager implements Lifecycle {
|
|||||||
//Outbound Access Token Cache
|
//Outbound Access Token Cache
|
||||||
public abstract AccessTokenVO getOutboundAccessToken(String key, Function<AccessTokenVO, AccessTokenVO> getNewToken);
|
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 removeOutboundAccessToken(String key);
|
||||||
|
|
||||||
public abstract void clearOutboundAccessToken();
|
public abstract void clearOutboundAccessToken();
|
||||||
|
|||||||
@@ -951,6 +951,11 @@ public class SessionManagerForEhcache extends SessionManager {
|
|||||||
throw new UnsupportedOperationException();
|
throw new UnsupportedOperationException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessTokenVO peekOutboundAccessToken(String key) {
|
||||||
|
throw new UnsupportedOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void removeOutboundAccessToken(String key) {
|
public void removeOutboundAccessToken(String key) {
|
||||||
throw new UnsupportedOperationException();
|
throw new UnsupportedOperationException();
|
||||||
|
|||||||
@@ -824,6 +824,14 @@ public class SessionManagerForIgnite extends SessionManager {
|
|||||||
return convert(cacheOutBoundAccessToken);
|
return convert(cacheOutBoundAccessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public AccessTokenVO peekOutboundAccessToken(String key) {
|
||||||
|
if (cacheOutBoundAccessToken == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return cacheOutBoundAccessToken.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putWebSocketTimeout(String key, SessionVO value) {
|
public void putWebSocketTimeout(String key, SessionVO value) {
|
||||||
cacheWebSocketTimeout.put(key, 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user