레거시 암호화 컨트롤러 제거 - 내부 API 토큰 초기화 로직 추가 - 정규식 성능 최적화
eapim-portal CI (from elink-portal-common) / build (push) Has been cancelled

This commit is contained in:
Rinjae
2026-08-13 16:52:04 +09:00
parent 3920876ea9
commit 58524562d4
@@ -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();
}
}