diff --git a/src/main/java/com/eactive/apim/portal/common/internal/InternalApiTokenService.java b/src/main/java/com/eactive/apim/portal/common/internal/InternalApiTokenService.java new file mode 100644 index 0000000..cd7f516 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/common/internal/InternalApiTokenService.java @@ -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) 공유 토큰 관리. + * + *

포탈의 {@code /internal/**} 는 CSRF 예외 경로다. CSRF 토큰이 없는 대신 커스텀 헤더를 요구해 + * 브라우저발 cross-site 위조(form POST 는 커스텀 헤더를 붙일 수 없다)를 원천 차단한다. + * 헤더명과 토큰값은 PTL_PROPERTY {@code Portal} 그룹으로 관리한다 — portal·admin 이 동일 DB 를 + * 공유하므로 별도 배포/설정 동기화 없이 같은 값을 읽는다.

+ * + * + * + * + * + * + *
PTL_PROPERTY (group = Portal)
기본값비고
{@code internal.api.header-name}{@code X-Internal-Token}요청 헤더명
{@code internal.api.token}기동 시 난수 생성256bit SecureRandom, base64url
+ * + *

생성 주체

+ * 토큰을 생성하는 쪽은 포탈 한 곳이다({@code ensureXxx}, 기동 시 1회). admin 은 읽기 전용 + * ({@code findXxx})으로만 접근해, 포탈보다 먼저 기동하더라도 엉뚱한 값을 선점 저장하지 않는다. + * + *

토큰 교체

+ * 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 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(); + } + + /** + * 요청이 제시한 토큰이 유효한지 검사한다. 값 비교는 타이밍 공격을 피해 상수 시간으로 수행한다. + * 기대 토큰을 확보하지 못하면 거부한다(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 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(); + } +}