레거시 암호화 컨트롤러 제거 - 내부 API 토큰 초기화 로직 추가 - 정규식 성능 최적화
eapim-portal CI (from elink-portal-common) / build (push) Has been cancelled
eapim-portal CI (from elink-portal-common) / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package com.eactive.apim.portal.common.internal;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* 내부 API(admin → portal) 공유 토큰 관리.
|
||||
*
|
||||
* <p>포탈의 {@code /internal/**} 는 CSRF 예외 경로다. CSRF 토큰이 없는 대신 <b>커스텀 헤더</b>를 요구해
|
||||
* 브라우저발 cross-site 위조(form POST 는 커스텀 헤더를 붙일 수 없다)를 원천 차단한다.
|
||||
* 헤더명과 토큰값은 PTL_PROPERTY {@code Portal} 그룹으로 관리한다 — portal·admin 이 동일 DB 를
|
||||
* 공유하므로 별도 배포/설정 동기화 없이 같은 값을 읽는다.</p>
|
||||
*
|
||||
* <table border="1">
|
||||
* <caption>PTL_PROPERTY (group = Portal)</caption>
|
||||
* <tr><th>키</th><th>기본값</th><th>비고</th></tr>
|
||||
* <tr><td>{@code internal.api.header-name}</td><td>{@code X-Internal-Token}</td><td>요청 헤더명</td></tr>
|
||||
* <tr><td>{@code internal.api.token}</td><td>기동 시 난수 생성</td><td>256bit SecureRandom, base64url</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <h3>생성 주체</h3>
|
||||
* 토큰을 <b>생성</b>하는 쪽은 포탈 한 곳이다({@code ensureXxx}, 기동 시 1회). admin 은 읽기 전용
|
||||
* ({@code findXxx})으로만 접근해, 포탈보다 먼저 기동하더라도 엉뚱한 값을 선점 저장하지 않는다.
|
||||
*
|
||||
* <h3>토큰 교체</h3>
|
||||
* PTL_PROPERTY 값을 수정하면 즉시 반영된다(양쪽 모두 호출 시점에 조회). {@code ptl_property} 는 2차 캐시
|
||||
* 대상이므로 값 변경 후 첫 호출이 실패하면 각 인스턴스 재기동으로 캐시를 비운다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InternalApiTokenService {
|
||||
|
||||
public static final String PROP_GROUP = "Portal";
|
||||
public static final String PROP_HEADER_NAME = "internal.api.header-name";
|
||||
public static final String PROP_TOKEN = "internal.api.token";
|
||||
|
||||
/** 헤더명 기본값 — 프로퍼티가 없을 때 portal·admin 이 동일하게 사용한다. */
|
||||
public static final String DEFAULT_HEADER_NAME = "X-Internal-Token";
|
||||
|
||||
static final String HEADER_NAME_DESCRIPTION =
|
||||
"내부 API(admin → portal /internal/**) 인증 헤더명. CSRF 예외 경로의 위조 방지용";
|
||||
static final String TOKEN_DESCRIPTION =
|
||||
"내부 API(admin → portal /internal/**) 공유 토큰. 최초 기동 시 자동 생성되며, 교체 시 값만 바꾸면 됨";
|
||||
|
||||
/** 토큰 엔트로피(바이트). base64url 인코딩 시 43자. */
|
||||
private static final int TOKEN_BYTES = 32;
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
/**
|
||||
* 이 JVM 이 생성한 토큰. DB 저장이 실패해도(그룹 미존재 등) 호출마다 값이 달라지지 않도록 붙잡아 둔다.
|
||||
* — 값이 흔들리면 "가끔 되고 가끔 안 되는" 형태로 증상이 숨는다.
|
||||
*/
|
||||
private final AtomicReference<String> generatedToken = new AtomicReference<>();
|
||||
|
||||
/** 헤더명 조회 — 없으면 기본값으로 생성한다. (포탈 전용) */
|
||||
public String ensureHeaderName() {
|
||||
String headerName = getOrCreate(PROP_HEADER_NAME, DEFAULT_HEADER_NAME, HEADER_NAME_DESCRIPTION);
|
||||
return isBlank(headerName) ? DEFAULT_HEADER_NAME : headerName.trim();
|
||||
}
|
||||
|
||||
/** 토큰 조회 — 없으면 난수를 생성해 저장한다. (포탈 전용) */
|
||||
public String ensureToken() {
|
||||
String token = getOrCreate(PROP_TOKEN, localToken(), TOKEN_DESCRIPTION);
|
||||
return isBlank(token) ? null : token.trim();
|
||||
}
|
||||
|
||||
/** 헤더명 조회(읽기 전용) — 없으면 기본값. 생성하지 않는다. (admin 용) */
|
||||
public String findHeaderName() {
|
||||
String headerName = read(PROP_HEADER_NAME);
|
||||
return isBlank(headerName) ? DEFAULT_HEADER_NAME : headerName.trim();
|
||||
}
|
||||
|
||||
/** 토큰 조회(읽기 전용) — 없으면 {@code null}. 생성하지 않는다. (admin 용) */
|
||||
public String findToken() {
|
||||
String token = read(PROP_TOKEN);
|
||||
return isBlank(token) ? null : token.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청이 제시한 토큰이 유효한지 검사한다. 값 비교는 타이밍 공격을 피해 상수 시간으로 수행한다.
|
||||
* 기대 토큰을 확보하지 못하면 <b>거부</b>한다(fail-closed).
|
||||
*/
|
||||
public boolean matches(String presented) {
|
||||
String expected = ensureToken();
|
||||
if (expected == null) {
|
||||
log.error("내부 API 토큰({}/{})을 확보하지 못해 요청을 거부합니다.", PROP_GROUP, PROP_TOKEN);
|
||||
return false;
|
||||
}
|
||||
if (isBlank(presented)) {
|
||||
return false;
|
||||
}
|
||||
return MessageDigest.isEqual(
|
||||
presented.trim().getBytes(StandardCharsets.UTF_8),
|
||||
expected.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** 기동 시 1회 호출 — 헤더명/토큰 프로퍼티를 미리 만들어 둔다. (포탈 전용) */
|
||||
public void initialize() {
|
||||
String headerName = ensureHeaderName();
|
||||
String token = ensureToken();
|
||||
if (token == null) {
|
||||
log.error("내부 API 토큰 초기화 실패 - PTL_PROPERTY 그룹 '{}' 존재 여부를 확인하세요.", PROP_GROUP);
|
||||
return;
|
||||
}
|
||||
// 토큰 값은 로그에 남기지 않는다.
|
||||
log.info("내부 API 토큰 초기화 완료 - header: {}, tokenLength: {}", headerName, token.length());
|
||||
}
|
||||
|
||||
private String getOrCreate(String propertyName, String defaultValue, String description) {
|
||||
try {
|
||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, propertyName, defaultValue, description);
|
||||
} catch (Exception e) {
|
||||
log.warn("내부 API 프로퍼티 조회 실패 - {}/{}", PROP_GROUP, propertyName, e);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private String read(String propertyName) {
|
||||
try {
|
||||
Map<String, String> properties = portalPropertyService.getPortalPropertiesAsMap(PROP_GROUP);
|
||||
return properties.get(propertyName);
|
||||
} catch (Exception e) {
|
||||
log.warn("내부 API 프로퍼티 조회 실패 - {}/{}", PROP_GROUP, propertyName, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** JVM 당 1회만 생성되는 난수 토큰 (DB 에 값이 없을 때의 기본값). */
|
||||
private String localToken() {
|
||||
String token = generatedToken.get();
|
||||
if (token != null) {
|
||||
return token;
|
||||
}
|
||||
byte[] bytes = new byte[TOKEN_BYTES];
|
||||
secureRandom.nextBytes(bytes);
|
||||
String candidate = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
return generatedToken.compareAndSet(null, candidate) ? candidate : generatedToken.get();
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user