Compare commits
3 Commits
0f9d38918a
...
6d3cbe1d8b
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d3cbe1d8b | |||
| 822ca52ef0 | |||
| c6053055a4 |
+5
@@ -610,6 +610,11 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
|
||||
OAuth2AccessTokenVO newaccessToken = (OAuth2AccessTokenVO) tokenManager
|
||||
.retryAccessTokenVO(vo.getAdapterGroupName(), prop, oldToken);
|
||||
|
||||
// 재발급받은 토큰으로 헤더를 다시 세팅한다. 기존에는 방금 실패한 토큰을 그대로 넣어
|
||||
// 재시도가 같은 사유로 실패했다.
|
||||
if (newaccessToken != null) {
|
||||
accessToken = newaccessToken;
|
||||
}
|
||||
setAuthHeaders(method, accessToken, authorizationHeaderName);
|
||||
|
||||
if (logger.isDebug() && "N".equals(vo.getTestCallYn())) {
|
||||
|
||||
+11
-1
@@ -41,6 +41,7 @@ import org.apache.hc.core5.http.io.entity.StringEntity;
|
||||
import org.apache.hc.core5.http.message.BasicNameValuePair;
|
||||
import org.apache.hc.core5.net.URIBuilder;
|
||||
import org.apache.hc.core5.ssl.SSLContextBuilder;
|
||||
import org.apache.hc.core5.util.TimeValue;
|
||||
import org.apache.hc.core5.util.Timeout;
|
||||
import org.springframework.security.web.util.UrlUtils;
|
||||
|
||||
@@ -155,6 +156,9 @@ public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessT
|
||||
|
||||
private static final long DEFAULT_EXPIRES_IN = 3600L;
|
||||
|
||||
/** 캐시한 HttpClient 의 유휴 커넥션 정리 주기(초) */
|
||||
private static final int IDLE_CONNECTION_EVICT_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* mTLS 여부/clientId 조합별로 CloseableHttpClient(및 내부 PoolingHttpClientConnectionManager)를
|
||||
* 1회만 생성해 재사용한다. 이 서비스 인스턴스는 HttpClientAccessTokenServiceFactoryByDB 에
|
||||
@@ -381,8 +385,11 @@ public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessT
|
||||
requestConfigBuilder.setProxy(proxy);
|
||||
}
|
||||
|
||||
// 커넥션 풀 대기 시간을 지정하지 않으면 기본 3분을 기다린다. 스케줄러의 태스크 타임아웃(30초)이
|
||||
// 먼저 걸려 요청이 인터럽트되고, 그 과정에서 커넥션이 반납되지 않아 풀이 마르는 악순환이 생긴다.
|
||||
RequestConfig requestConfig = requestConfigBuilder
|
||||
.setConnectTimeout(Timeout.ofMilliseconds(connectionTimeout))
|
||||
.setConnectionRequestTimeout(Timeout.ofMilliseconds(connectionTimeout))
|
||||
.setResponseTimeout(Timeout.ofMilliseconds(timeout)).build();
|
||||
request.setConfig(requestConfig);
|
||||
|
||||
@@ -641,7 +648,10 @@ public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessT
|
||||
adapterGroupName, useMtls, clientId, contentCompression, defaultUserAgent);
|
||||
}
|
||||
|
||||
HttpClientBuilder builder = HttpClients.custom().setConnectionManager(connectionManager);
|
||||
// HttpClient 를 캐시해 재사용하므로 죽은 커넥션이 풀에 남지 않도록 정리 설정을 건다.
|
||||
HttpClientBuilder builder = HttpClients.custom().setConnectionManager(connectionManager)
|
||||
.evictExpiredConnections()
|
||||
.evictIdleConnections(TimeValue.ofSeconds(IDLE_CONNECTION_EVICT_SECONDS));
|
||||
if (!contentCompression) {
|
||||
builder.disableContentCompression();
|
||||
}
|
||||
|
||||
@@ -33,12 +33,22 @@ public class OAuthTokenStatusDTO {
|
||||
*
|
||||
* 어댑터마다 값이 다르면 스케줄러가 쓰는 구현체와 거래 중 재발급(retryAccessTokenVO) 때
|
||||
* 쓰이는 구현체가 달라진다. 그 불일치를 확인하기 위한 항목이다.
|
||||
*
|
||||
* 어댑터그룹 자체를 찾지 못하면 null, 그룹은 있으나 이 서버에 배정된 어댑터가 없으면 빈 값이다.
|
||||
*/
|
||||
Map<String, String> adapterTokenServiceClasses;
|
||||
|
||||
/** 토큰 발급 URL */
|
||||
/** 토큰 발급 URL. DB(OutboundOAuthCredentialVo)에 설정된 원본이라 상대 경로일 수 있다. */
|
||||
String tokenUrl;
|
||||
|
||||
/**
|
||||
* 실제로 호출되는 토큰 발급 URL.
|
||||
*
|
||||
* tokenUrl 이 절대 URL 이 아니면 어댑터 속성 URL 뒤에 붙여 호출하므로(구현체들의 appendPath 처리),
|
||||
* 같은 방식으로 조합한 값이다. 어댑터 속성을 찾지 못하면 null.
|
||||
*/
|
||||
String tokenUrlResolved;
|
||||
|
||||
/** 토큰 재발급 주기(초) */
|
||||
int intervalSec;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -77,25 +78,50 @@ public class OAuthTokenStatusService {
|
||||
dto.setUseYn(credential.getUseYn());
|
||||
dto.setTokenUrl(credential.getUrl());
|
||||
dto.setIntervalSec(credential.getIntervalSec());
|
||||
dto.setTokenServiceClass(findTokenServiceClass(adapterGroupName));
|
||||
dto.setAdapterTokenServiceClasses(findAdapterTokenServiceClasses(adapterGroupName));
|
||||
// 스케줄러가 고르는 어댑터의 속성에서 발급 구현체와 기준 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) {
|
||||
dto.setMessage("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다.");
|
||||
notes.add("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다.");
|
||||
dto.setMessage(joinNotes(notes));
|
||||
return dto;
|
||||
} catch (Exception e) {
|
||||
if (logger.isWarn()) {
|
||||
logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e);
|
||||
}
|
||||
dto.setMessage("토큰 캐시 조회 실패 : " + e.getMessage());
|
||||
notes.add("토큰 캐시 조회 실패 : " + e.getMessage());
|
||||
dto.setMessage(joinNotes(notes));
|
||||
return dto;
|
||||
}
|
||||
|
||||
if (accessToken == null) {
|
||||
dto.setMessage("캐시에 토큰이 없습니다.");
|
||||
notes.add("캐시에 토큰이 없습니다.");
|
||||
dto.setMessage(joinNotes(notes));
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -120,38 +146,82 @@ public class OAuthTokenStatusService {
|
||||
// 만료시각이 없는 토큰은 isExpired() 가 false 라 재발급 대상이 되지 않는다.
|
||||
// 빈 토큰이 캐시에 들어가면 기동 후 계속 그대로 사용되므로 조회 시 눈에 띄게 알린다.
|
||||
if (StringUtils.isBlank(accessToken.getAccessToken())) {
|
||||
dto.setMessage("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다."
|
||||
notes.add("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다."
|
||||
+ " 만료시각이 없어 자동 재발급되지 않습니다.");
|
||||
} else if (expiration == null) {
|
||||
dto.setMessage("만료시각이 없어 자동 재발급되지 않습니다.");
|
||||
notes.add("만료시각이 없어 자동 재발급되지 않습니다.");
|
||||
}
|
||||
|
||||
dto.setMessage(joinNotes(notes));
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 어댑터 속성에서 토큰 발급 구현체 클래스명을 찾는다.
|
||||
* 확인이 필요한 상태 메시지를 하나로 합친다.
|
||||
*
|
||||
* @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.
|
||||
* @return 어댑터 속성. 찾지 못하면 null.
|
||||
*/
|
||||
private String findTokenServiceClass(String adapterGroupName) {
|
||||
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();
|
||||
Properties properties = AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
|
||||
return properties.getProperty(PROP_TOKEN_SERVICE_CLASS);
|
||||
return AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
|
||||
} catch (Exception e) {
|
||||
if (logger.isDebug()) {
|
||||
logger.debug("토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹에 속한 어댑터별로 토큰 발급 구현체 클래스명을 모은다.
|
||||
*
|
||||
@@ -160,14 +230,14 @@ public class OAuthTokenStatusService {
|
||||
* 서로 다른 구현체가 동작하므로 전체를 보여준다.
|
||||
*
|
||||
* @param adapterGroupName 어댑터그룹명
|
||||
* @return 어댑터명 → 구현체 클래스명. 조회 실패 시 빈 Map.
|
||||
* @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 result;
|
||||
return null;
|
||||
}
|
||||
Iterator<AdapterVO> adapters = gvo.getAdapters();
|
||||
while (adapters.hasNext()) {
|
||||
@@ -179,6 +249,7 @@ public class OAuthTokenStatusService {
|
||||
if (logger.isDebug()) {
|
||||
logger.debug("어댑터별 토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -209,6 +209,54 @@ class OAuthTokenStatusServiceTest {
|
||||
assertTrue(dto.getMessage().contains("재발급"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("8-3. useYn 이 N 이면 스케줄러가 발급하지 않음을 알린다")
|
||||
void 사용안함경고() {
|
||||
OutboundOAuthCredentialVo credential = registerCredential(GROUP);
|
||||
credential.setUseYn("N");
|
||||
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
|
||||
|
||||
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
|
||||
|
||||
assertTrue(dto.getMessage().contains("사용 안 함"));
|
||||
assertTrue(dto.getMessage().contains("캐시에 토큰이 없습니다"), "다른 사유도 함께 표시돼야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("8-4. 어댑터그룹을 찾지 못하면 그 사유를 알린다")
|
||||
void 어댑터그룹없음경고() {
|
||||
registerCredential(GROUP);
|
||||
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
|
||||
|
||||
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
|
||||
|
||||
// 테스트 환경에는 AdapterManager 가 없으므로 그룹을 찾지 못한 것으로 처리된다.
|
||||
assertNull(dto.getAdapterTokenServiceClasses());
|
||||
assertTrue(dto.getMessage().contains("어댑터그룹을 찾을 수 없습니다"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("8-5. 상대 경로 토큰 URL 은 어댑터 URL 과 조합한다")
|
||||
void 토큰URL조합() {
|
||||
assertEquals("https://api.example.com/oauth2/token",
|
||||
service.resolveTokenUrl("https://api.example.com", "oauth2/token"));
|
||||
assertEquals("https://api.example.com/oauth2/token",
|
||||
service.resolveTokenUrl("https://api.example.com/", "oauth2/token"));
|
||||
assertEquals("https://api.example.com/oauth2/token",
|
||||
service.resolveTokenUrl("https://api.example.com", "/oauth2/token"));
|
||||
assertEquals("https://api.example.com/oauth2/token",
|
||||
service.resolveTokenUrl("https://api.example.com/", "/oauth2/token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("8-6. 절대 URL 은 그대로 쓰고, 조합할 수 없으면 null")
|
||||
void 토큰URL절대경로() {
|
||||
assertEquals("https://token.example.com/oauth/token",
|
||||
service.resolveTokenUrl("https://api.example.com", "https://token.example.com/oauth/token"));
|
||||
assertNull(service.resolveTokenUrl("https://api.example.com", null));
|
||||
assertNull(service.resolveTokenUrl(null, "oauth2/token"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("9. 전체 목록은 등록된 어댑터그룹 수만큼 반환한다")
|
||||
void 전체목록() {
|
||||
|
||||
Reference in New Issue
Block a user