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);
            }
        };
    }
}