From 89a0421582d7b90f45fb0c72d09b11c7062222f9 Mon Sep 17 00:00:00 2001 From: BcKmini <151009045+BcKmini@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:14:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20/actuator/prometheus=EC=97=90=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A0=88=EC=9D=B4=ED=95=91=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20Basic=20Auth=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fowoco/infra#34에서 붙인 클러스터 내부 Prometheus가 /actuator/prometheus를 스크레이핑할 수 있도록, observability 프로필이 아닌 환경(prod 포함)에서도 그 경로만 여는 두 번째 SecurityFilterChain을 추가한다. permitAll이 아니라 app.observability.prometheus-scrape-password로 설정한 계정의 Basic Auth로만 접근 가능 — /actuator/**가 이미 public ingress로 나가 있어서 permitAll은 내부 지표를 인터넷에 그대로 노출시키기 때문이다. 비밀번호를 설정하지 않으면(기본값) 기존과 동일하게 전부 거부 — 로컬/테스트 환경은 지금처럼 observability 프로필의 permitAll 체인을 그대로 쓴다. Closes #177 Co-Authored-By: Claude Sonnet 5 --- .../server/common/config/SecurityConfig.java | 92 +++++++++++++++---- src/main/resources/application.yaml | 3 + .../PrometheusScrapeAuthIntegrationTest.java | 69 ++++++++++++++ 3 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 src/test/java/com/fowoco/server/common/config/PrometheusScrapeAuthIntegrationTest.java diff --git a/src/main/java/com/fowoco/server/common/config/SecurityConfig.java b/src/main/java/com/fowoco/server/common/config/SecurityConfig.java index dbd4e708..50e6b57a 100644 --- a/src/main/java/com/fowoco/server/common/config/SecurityConfig.java +++ b/src/main/java/com/fowoco/server/common/config/SecurityConfig.java @@ -4,6 +4,7 @@ import com.fowoco.server.common.error.ErrorCode; import jakarta.servlet.DispatcherType; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -15,7 +16,9 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.AuthenticationEntryPoint; @@ -52,8 +55,55 @@ public SecurityFilterChain prometheusSecurityFilterChain(HttpSecurity http) thro return http.build(); } + /** + * observability 프로필이 아닌 환경(특히 prod)에서 /actuator/prometheus에 붙는 체인. + * prod에서 permitAll로 열면 /actuator/**가 이미 public ingress로 노출돼 있어 내부 + * 지표가 인증 없이 인터넷에 공개된다 — 그래서 이 체인은 스크레이핑 전용 Basic Auth + * 계정 하나만 허용한다. app.observability.prometheus-scrape-password가 비어 있으면 + * (기본값) 그 계정 자체를 안 만들고 전부 거부한다 — "설정 안 하면 막힘"이 기본. + */ @Bean @Order(2) + @Profile("!(observability & !prod)") + public SecurityFilterChain prometheusScrapeAuthSecurityFilterChain( + HttpSecurity http, + PasswordEncoder passwordEncoder, + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver exceptionResolver, + @Value("${app.observability.prometheus-scrape-password:}") String scrapePassword + ) throws Exception { + http.securityMatcher("/actuator/prometheus"); + if (scrapePassword.isBlank()) { + // 계정 자체가 없으니 인증을 시도해도 항상 거부 — applicationSecurityFilterChain과 + // 같은 401 응답 형태(AUTHENTICATION_REQUIRED)로 통일한다. + http + .authorizeHttpRequests(authorize -> authorize.anyRequest().denyAll()) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(authenticationEntryPoint(exceptionResolver)) + .accessDeniedHandler(accessDeniedHandler(exceptionResolver)) + ); + } else { + UserDetailsService scrapeUserDetailsService = new InMemoryUserDetailsManager( + User.withUsername("prometheus") + .password(passwordEncoder.encode(scrapePassword)) + .roles("PROMETHEUS_SCRAPE") + .build() + ); + http + .userDetailsService(scrapeUserDetailsService) + .authorizeHttpRequests(authorize -> authorize.anyRequest().hasRole("PROMETHEUS_SCRAPE")) + .httpBasic(Customizer.withDefaults()); + } + http + .csrf(csrf -> csrf.disable()) + .requestCache(cache -> cache.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(form -> form.disable()) + .logout(logout -> logout.disable()); + return http.build(); + } + + @Bean + @Order(3) @Profile("local") public SecurityFilterChain h2ConsoleSecurityFilterChain(HttpSecurity http) throws Exception { http @@ -65,28 +115,14 @@ public SecurityFilterChain h2ConsoleSecurityFilterChain(HttpSecurity http) throw } @Bean - @Order(3) + @Order(4) public SecurityFilterChain applicationSecurityFilterChain( HttpSecurity http, @Qualifier("handlerExceptionResolver") HandlerExceptionResolver exceptionResolver, JwtAuthenticationConverter jwtAuthenticationConverter ) throws Exception { - AuthenticationEntryPoint authenticationEntryPoint = (request, response, exception) -> { - response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer"); - exceptionResolver.resolveException( - request, - response, - null, - new ApiException(ErrorCode.AUTHENTICATION_REQUIRED) - ); - }; - AccessDeniedHandler accessDeniedHandler = (request, response, exception) -> - exceptionResolver.resolveException( - request, - response, - null, - new ApiException(ErrorCode.ACCESS_DENIED) - ); + AuthenticationEntryPoint authenticationEntryPoint = authenticationEntryPoint(exceptionResolver); + AccessDeniedHandler accessDeniedHandler = accessDeniedHandler(exceptionResolver); http .authorizeHttpRequests(authorize -> authorize @@ -136,4 +172,26 @@ public SecurityFilterChain applicationSecurityFilterChain( .logout(logout -> logout.disable()); return http.build(); } + + private static AuthenticationEntryPoint authenticationEntryPoint(HandlerExceptionResolver exceptionResolver) { + return (request, response, exception) -> { + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer"); + exceptionResolver.resolveException( + request, + response, + null, + new ApiException(ErrorCode.AUTHENTICATION_REQUIRED) + ); + }; + } + + private static AccessDeniedHandler accessDeniedHandler(HandlerExceptionResolver exceptionResolver) { + return (request, response, exception) -> + exceptionResolver.resolveException( + request, + response, + null, + new ApiException(ErrorCode.ACCESS_DENIED) + ); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8131912e..9a1897c0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -134,6 +134,9 @@ app: local-path: ${FILE_STORAGE_LOCAL_PATH:./data/files} cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173} + observability: + # 비어 있으면(기본값) /actuator/prometheus는 SecurityConfig에서 전부 거부된다. + prometheus-scrape-password: ${PROMETHEUS_SCRAPE_PASSWORD:} demo-seed: enabled: ${DEMO_SEED_ENABLED:false} company-id: ${DEMO_SEED_COMPANY_ID:90000000-0000-0000-0000-000000000001} diff --git a/src/test/java/com/fowoco/server/common/config/PrometheusScrapeAuthIntegrationTest.java b/src/test/java/com/fowoco/server/common/config/PrometheusScrapeAuthIntegrationTest.java new file mode 100644 index 00000000..bcf01be5 --- /dev/null +++ b/src/test/java/com/fowoco/server/common/config/PrometheusScrapeAuthIntegrationTest.java @@ -0,0 +1,69 @@ +package com.fowoco.server.common.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Base64; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.boot.micrometer.metrics.test.autoconfigure.AutoConfigureMetrics; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +/** + * app.observability.prometheus-scrape-password가 설정된 prod류 환경에서 + * /actuator/prometheus가 그 계정의 Basic Auth로만 열리는지 검증한다. + * 값이 비어있을 때(기본값)의 전체 거부는 {@link com.fowoco.server.ServerApplicationTests}에서 이미 검증. + */ +@ActiveProfiles("test") +@TestPropertySource(properties = "app.observability.prometheus-scrape-password=test-scrape-secret") +@AutoConfigureMetrics +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class PrometheusScrapeAuthIntegrationTest { + + @LocalServerPort + private int port; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @Test + void rejectsRequestsWithoutCredentials() throws Exception { + HttpResponse response = get(null); + + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + void rejectsWrongPassword() throws Exception { + HttpResponse response = get(basicAuthHeader("prometheus", "wrong-password")); + + assertThat(response.statusCode()).isEqualTo(401); + } + + @Test + void acceptsTheConfiguredScrapeCredential() throws Exception { + HttpResponse response = get(basicAuthHeader("prometheus", "test-scrape-secret")); + + assertThat(response.statusCode()).isEqualTo(200); + } + + private String basicAuthHeader(String username, String password) { + String raw = username + ":" + password; + return "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + private HttpResponse get(String authorizationHeader) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder( + URI.create("http://localhost:" + port + "/actuator/prometheus") + ); + if (authorizationHeader != null) { + builder.header("Authorization", authorizationHeader); + } + return httpClient.send(builder.GET().build(), HttpResponse.BodyHandlers.ofString()); + } +}