SecurityAndCorsConfig.java
package com.licensis.notaire.config;
import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.beans.factory.annotation.Value;
import java.util.Arrays;
import static org.springframework.security.config.Customizer.withDefaults;
/**
* Spring Security and CORS configuration for the application.
* Provides centralized configuration for security settings including
* Actuator endpoint protection with configurable credentials for Prometheus scraping.
*/
@Configuration
@EnableWebSecurity
public class SecurityAndCorsConfig {
private static final String PRODUCTION_ENVIRONMENT = "production";
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityAndCorsConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Value("${cors.allowed-origins:http://localhost:3000,http://localhost:8080}")
private String[] allowedOrigins;
@Value("${cors.allowed-methods:GET,POST,PUT,DELETE,PATCH,OPTIONS}")
private String[] allowedMethods;
@Value("${cors.max-age:3600}")
private long maxAge;
@Value("${cors.allowed-headers:Content-Type,Authorization}")
private String[] allowedHeaders;
@Value("${actuator.security.username}")
private String actuatorUsername;
@Value("${actuator.security.password}")
private String actuatorPassword;
@Value("${app.environment:development}")
private String environment;
/**
* Password encoder bean using BCrypt
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
/**
* User details service for actuator and admin users.
* Stores password as BCrypt hash for the admin user.
* Credentials come from actuator.security.username/password (bound to the
* ACTUATOR_USER/ACTUATOR_PASSWORD env vars, see .env.example) rather than
* being hardcoded, so they can be changed without a code change (issue #557).
* This overrides Spring Boot auto-configuration from spring.security.user.* properties.
*/
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
var admin = User.builder()
.username(actuatorUsername)
.password(passwordEncoder.encode(actuatorPassword))
.roles("ACTUATOR", "ADMIN")
.build();
return new InMemoryUserDetailsManager(admin);
}
/**
* Security filter chain for Actuator endpoints.
* Protects Prometheus metrics and health endpoints with HTTP Basic auth.
* Credentials: actuator.security.username / actuator.security.password.
*/
@Bean
@Order(1)
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/actuator/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/actuator/info").permitAll()
.anyRequest().authenticated()
)
.httpBasic(withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.cors(withDefaults());
return http.build();
}
/**
* Authentication entry point for unauthenticated API requests.
* Returns a plain 401 instead of Spring Security's default redirect/challenge
* behavior, since the API is consumed by a JS client, not a browser login form.
*/
@Bean
public AuthenticationEntryPoint apiAuthenticationEntryPoint() {
return (request, response, authException) ->
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
}
/**
* Security filter chain for API endpoints.
* JWT filter authenticates requests that carry a valid Bearer token.
* Only the login endpoint (and CORS preflight) is reachable without one;
* every other /api/** request must present a valid token.
*/
@Bean
@Order(2)
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http,
AuthenticationEntryPoint apiAuthenticationEntryPoint) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/v1/usuarios/login").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex.authenticationEntryPoint(apiAuthenticationEntryPoint))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.cors(withDefaults());
return http.build();
}
/**
* Security filter chain for all other endpoints (Swagger, etc.)
* Swagger UI and the OpenAPI spec are publicly reachable in every environment
* except production, where they are denied to avoid exposing the API surface
* and a live "Try it out" console to anonymous visitors (issue #671).
*/
@Bean
@Order(3)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> {
var swaggerPaths = auth.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html");
if (isProduction()) {
swaggerPaths.denyAll();
} else {
swaggerPaths.permitAll();
}
auth.anyRequest().permitAll();
})
.httpBasic(withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.cors(withDefaults());
return http.build();
}
private boolean isProduction() {
return PRODUCTION_ENVIRONMENT.equalsIgnoreCase(environment);
}
/**
* Refuses to start in production with a wildcard CORS configuration.
* A wildcard {@code Access-Control-Allow-Headers} (including {@code Authorization})
* combined with an unrestricted origin lets any site read authenticated
* responses cross-origin (issue #673).
*/
@PostConstruct
public void validateProductionCorsConfig() {
if (!isProduction()) {
return;
}
if (Arrays.asList(allowedHeaders).contains("*")) {
throw new IllegalStateException(
"cors.allowed-headers no puede ser '*' en producción. "
+ "Configurá una lista explícita de headers permitidos.");
}
if (Arrays.asList(allowedOrigins).contains("*")) {
throw new IllegalStateException(
"cors.allowed-origins no puede ser '*' en producción. "
+ "Configurá los orígenes del frontend explícitamente.");
}
}
/**
* CORS configuration
*/
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins(allowedOrigins)
.allowedMethods(allowedMethods)
.allowCredentials(true)
.maxAge(maxAge)
.allowedHeaders(allowedHeaders);
registry.addMapping("/actuator/**")
.allowedOrigins(allowedOrigins)
.allowedMethods(allowedMethods)
.allowCredentials(true)
.maxAge(maxAge)
.allowedHeaders(allowedHeaders);
}
};
}
}