Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 75 additions & 17 deletions src/main/java/com/fowoco/server/common/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
);
}
}
3 changes: 3 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> response = get(null);

assertThat(response.statusCode()).isEqualTo(401);
}

@Test
void rejectsWrongPassword() throws Exception {
HttpResponse<String> response = get(basicAuthHeader("prometheus", "wrong-password"));

assertThat(response.statusCode()).isEqualTo(401);
}

@Test
void acceptsTheConfiguredScrapeCredential() throws Exception {
HttpResponse<String> 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<String> 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());
}
}
Loading