diff --git a/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java b/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java new file mode 100644 index 00000000..64040eb1 --- /dev/null +++ b/src/main/java/repit/repit_api_server/global/config/RequiredSettingsCheck.java @@ -0,0 +1,96 @@ +package repit.repit_api_server.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +/** + * 빠지면 서버가 떠도 제대로 일하지 못하는 설정을 기동할 때 확인한다. 어기면 서버가 뜨지 않는다. + * + *
콜백 주소가 틀리면 분석·채팅 서버의 결과가 엉뚱한 곳으로 가거나 아예 오지 않는다. 그런데 요청은 202로 접수돼 + * 겉으로는 정상처럼 보이고, 실패는 몇 분 뒤 시간 초과로만 드러난다. 블루/그린 배포라 새 서버가 뜨지 않으면 이전 서버가 + * 그대로 남으므로, 잘못된 설정은 배포 시점에 막는 편이 낫다. 연결까지 시험하지는 않는다 — 외부 서버가 잠깐 멈췄다고 + * 기동을 막으면 안 된다. + */ +@Component +class RequiredSettingsCheck { + + // 이 프로필이 켜져 있으면 운영으로 본다. 개발용 주소(ngrok, localhost)를 콜백 주소로 받지 않는다. + static final String PRODUCTION_PROFILE = "prod"; + + RequiredSettingsCheck(@Value("${app.callback-base-url:}") String callbackBaseUrl, + @Value("${app.internal-auth.required:false}") boolean internalAuthRequired, + @Value("${app.internal-auth.token:}") String internalAuthToken, + Environment environment) { + verifyCallbackBaseUrl(callbackBaseUrl, environment.matchesProfiles(PRODUCTION_PROFILE)); + verifyInternalAuth(internalAuthRequired, internalAuthToken); + } + + /** + * 분석·채팅 서버가 결과를 보낼 이 서버의 외부 주소. 뒤에 콜백 경로를 바로 붙이므로 쿼리·조각·끝 슬래시가 없어야 한다. + * 끝 슬래시가 있으면 "//api/..."가 되어 시큐리티 방화벽이 콜백을 모두 400으로 막는다. + */ + static void verifyCallbackBaseUrl(String value, boolean production) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("CALLBACK_BASE_URL(app.callback-base-url)이 비어 있습니다. " + + "분석·채팅 서버가 결과를 보낼 이 서버의 외부 주소를 넣어 주세요. 예: https://api.repit.example"); + } + URI uri; + try { + uri = new URI(value.trim()); + } catch (URISyntaxException e) { + throw invalid(null, "주소 형식이 아닙니다"); + } + String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT); + if (!scheme.equals("http") && !scheme.equals("https")) { + throw invalid(uri.getHost(), "http 또는 https 주소여야 합니다"); + } + if (uri.getHost() == null || uri.getUserInfo() != null || uri.getRawQuery() != null + || uri.getRawFragment() != null || value.trim().endsWith("/")) { + throw invalid(uri.getHost(), "호스트가 있고 사용자 정보·쿼리·조각·끝 슬래시가 없어야 합니다"); + } + if (!production) { + return; + } + String host = uri.getHost().toLowerCase(Locale.ROOT); + if (!scheme.equals("https")) { + throw invalid(host, "운영에서는 https 주소여야 합니다"); + } + // 분석 서버는 기본 포트가 아닌 콜백 주소를 422로 거부한다(CallbackUrls.require와 같은 제한). + if (uri.getPort() != -1 && uri.getPort() != 443) { + throw invalid(host, "운영에서는 포트를 생략하거나 443이어야 합니다"); + } + if (host.contains("ngrok") || isLoopback(host)) { + throw invalid(host, "운영에서는 ngrok이나 localhost 같은 개발용 주소를 쓸 수 없습니다"); + } + } + + /** + * 내부 인증을 필수로 켰는데 인증값이 비어 있으면 막는다. 비어 있으면 콜백을 인증 없이 받는 지금 동작으로 조용히 + * 돌아가, 켰다고 믿는 동안 아무나 콜백을 보낼 수 있다. 인증값은 오류 메시지에 싣지 않는다. + */ + static void verifyInternalAuth(boolean required, String token) { + if (required && (token == null || token.isBlank())) { + throw new IllegalStateException("INTERNAL_AUTH_REQUIRED가 true인데 INTERNAL_AUTH_TOKEN(app.internal-auth.token)이 " + + "비어 있습니다. 분석·채팅 서버와 같은 값을 넣거나, 아직 헤더를 보내지 않는다면 INTERNAL_AUTH_REQUIRED를 끄세요."); + } + } + + private static boolean isLoopback(String host) { + return host.equals("localhost") || host.endsWith(".localhost") || host.startsWith("127.") + || host.equals("[::1]") || host.equals("0.0.0.0"); + } + + /** + * 주소 원문은 싣지 않는다. 막는 이유가 사용자 정보·쿼리이기도 해서, 원문을 남기면 그 안의 자격 증명이 기동 실패 로그에 + * 그대로 남는다. 어느 설정인지 짚을 수 있도록 검증 항목과 호스트만 싣는다. + */ + private static IllegalStateException invalid(String host, String reason) { + return new IllegalStateException("CALLBACK_BASE_URL(app.callback-base-url)이 올바르지 않습니다 — " + reason + + ". 호스트: " + (host == null ? "(읽을 수 없음)" : host)); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 95fb880d..9054194b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -88,7 +88,13 @@ app: # 비어 있으면 콜백을 인증 없이 받는다. 부르는 쪽이 헤더를 보내도록 배포한 뒤에 채워 강제한다. # 영상 분석 콜백은 예외다 -- 비어 있으면 401로 받지 않는다(CallbackPaths.ALWAYS_AUTHENTICATED). token: ${INTERNAL_AUTH_TOKEN:} - callback-base-url: ${CALLBACK_BASE_URL:https://wildcat-startle-rope.ngrok-free.dev} + # true면 token이 비어 있을 때 서버가 뜨지 않는다. 비어 있는 채로 인증 없이 받는 상태로 조용히 돌아가는 것을 막는다. + # 분석·채팅 서버가 헤더를 보내도록 배포를 마친 뒤 마지막에 켠다. + required: ${INTERNAL_AUTH_REQUIRED:false} + # 분석·채팅 서버가 결과를 보낼 이 서버의 외부 주소. 기본값이 없다 -- 비었거나 형식이 틀리면 서버가 뜨지 않는다. + # 끝에 슬래시를 붙이지 않는다. prod 프로필에서는 https만 받고 ngrok·localhost 같은 개발용 주소를 받지 않는다. + # 로컬 개발은 .env에 넣는다(예: ngrok 주소). 테스트는 src/test/resources/config/application.yaml이 채운다. + callback-base-url: ${CALLBACK_BASE_URL:} feedback: # 이 시간을 넘도록 분석 서버 콜백이 오지 않으면 실패로 처리하고 재요청을 허용한다. pending-timeout: 10m diff --git a/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java b/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java new file mode 100644 index 00000000..7cc6e379 --- /dev/null +++ b/src/test/java/repit/repit_api_server/global/config/RequiredSettingsCheckTest.java @@ -0,0 +1,99 @@ +package repit.repit_api_server.global.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** 빠지면 서버가 제대로 일하지 못하는 설정을 기동 시점에 막는지 본다(N-6). */ +class RequiredSettingsCheckTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withUserConfiguration(RequiredSettingsCheck.class); + + @Test + void 콜백_주소가_없거나_비면_기동하지_않는다() { + runner.run(context -> assertThat(context).hasFailed() + .getFailure().rootCause().hasMessageContaining("CALLBACK_BASE_URL")); + runner.withPropertyValues("app.callback-base-url= ") + .run(context -> assertThat(context).hasFailed()); + } + + @Test + void 형식이_틀린_콜백_주소는_받지_않는다() { + for (String url : List.of("api.repit.example", "ftp://api.repit.example", "https://", + "https://user:pw@api.repit.example", "https://api.repit.example?env=dev", + "https://api.repit.example#top", "https://api.repit.example/", "https://api repit.example")) { + assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, false)) + .as(url) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("CALLBACK_BASE_URL"); + } + } + + /** 로컬은 ngrok이나 localhost로 개발한다. 운영 프로필이 아닐 때는 받는다. */ + @Test + void 운영이_아니면_개발용_주소를_받는다() { + for (String url : List.of("https://wildcat-startle-rope.ngrok-free.dev", "http://localhost:8080", + "http://127.0.0.1:8080", "https://api.repit.example/base")) { + assertThatCode(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, false)).as(url) + .doesNotThrowAnyException(); + } + } + + @Test + void 운영에서는_https_기본_포트가_아니거나_개발용_주소면_받지_않는다() { + for (String url : List.of("https://wildcat-startle-rope.ngrok-free.dev", "https://abc.ngrok.io", + "https://localhost", "https://api.localhost", "https://127.0.0.1", "https://[::1]", + "http://api.repit.example", "https://api.repit.example:8443")) { + assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, true)).as(url) + .isInstanceOf(IllegalStateException.class); + } + // 분석 서버는 포트를 생략했거나 443인 주소만 받는다. + for (String url : List.of("https://api.repit.example", "https://api.repit.example:443")) { + assertThatCode(() -> RequiredSettingsCheck.verifyCallbackBaseUrl(url, true)).as(url) + .doesNotThrowAnyException(); + } + } + + @Test + void 운영_프로필에서_ngrok_주소면_기동하지_않는다() { + runner.withPropertyValues("spring.profiles.active=prod", + "app.callback-base-url=https://wildcat-startle-rope.ngrok-free.dev") + .run(context -> assertThat(context).hasFailed()); + runner.withPropertyValues("spring.profiles.active=prod", "app.callback-base-url=https://api.repit.example") + .run(context -> assertThat(context).hasNotFailed()); + } + + @Test + void 내부_인증을_필수로_켰는데_인증값이_없으면_기동하지_않는다() { + runner.withPropertyValues("app.callback-base-url=https://api.repit.example", + "app.internal-auth.required=true", "app.internal-auth.token= ") + .run(context -> assertThat(context).hasFailed() + .getFailure().rootCause().hasMessageContaining("INTERNAL_AUTH_TOKEN")); + } + + @Test + void 내부_인증이_필수가_아니면_인증값_없이도_기동한다() { + runner.withPropertyValues("app.callback-base-url=https://api.repit.example") + .run(context -> assertThat(context).hasNotFailed()); + runner.withPropertyValues("app.callback-base-url=https://api.repit.example", + "app.internal-auth.required=true", "app.internal-auth.token=secret-token-value") + .run(context -> assertThat(context).hasNotFailed()); + } + + /** 사용자 정보·쿼리를 막는 검사라, 원문을 오류에 실으면 그 안의 자격 증명이 기동 실패 로그에 남는다. */ + @Test + void 오류_메시지에_주소_원문을_싣지_않는다() { + assertThatThrownBy(() -> RequiredSettingsCheck.verifyCallbackBaseUrl( + "https://user:secret-password@api.repit.example?token=query-secret", false)) + .isInstanceOf(IllegalStateException.class) + .message() + .doesNotContain("secret-password", "query-secret", "user:") + .contains("api.repit.example"); + } +} diff --git a/src/test/resources/config/application.yaml b/src/test/resources/config/application.yaml new file mode 100644 index 00000000..1420a3db --- /dev/null +++ b/src/test/resources/config/application.yaml @@ -0,0 +1,4 @@ +# 테스트 전용 설정. classpath:/config/ 파일이라 기본 application.yaml 위에 겹쳐 읽힌다. +app: + # 테스트는 외부 서버를 부르지 않는다. 기본값이 없어진 콜백 주소를 명시적으로 채워 기동 검사를 통과시킨다. + callback-base-url: https://api.repit.test