SecurityConfig.java

package com.taxonomy.security.config;

import com.taxonomy.security.webdav.WebDavApplicationCredentialFilter;
import com.taxonomy.shared.config.RateLimitFilter;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.ObjectPostProcessor;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;
import org.springframework.security.web.util.matcher.RequestMatcher;

import java.util.Optional;

/** Spring Security configuration for form-login mode. */
@Configuration
@EnableMethodSecurity
@Profile("!keycloak")
public class SecurityConfig {

    private final AuthorizationRulesConfigurer authRules;
    private final PasswordChangeRequiredFilter passwordChangeRequiredFilter;
    private final WebDavApplicationCredentialFilter webDavCredentialFilter;
    private final RateLimitFilter rateLimitFilter;
    private final LoginRateLimitFilter loginRateLimitFilter;

    @Autowired
    public SecurityConfig(
            AuthorizationRulesConfigurer authRules,
            PasswordChangeRequiredFilter passwordChangeRequiredFilter,
            WebDavApplicationCredentialFilter webDavCredentialFilter,
            RateLimitFilter rateLimitFilter,
            Optional<LoginRateLimitFilter> loginRateLimitFilter) {
        this.authRules = authRules;
        this.passwordChangeRequiredFilter = passwordChangeRequiredFilter;
        this.webDavCredentialFilter = webDavCredentialFilter;
        this.rateLimitFilter = rateLimitFilter;
        this.loginRateLimitFilter = loginRateLimitFilter.orElse(null);
    }

    /** Backward-compatible constructor for focused configuration tests. */
    SecurityConfig(
            AuthorizationRulesConfigurer authRules,
            PasswordChangeRequiredFilter passwordChangeRequiredFilter) {
        this(authRules, passwordChangeRequiredFilter, null, null, Optional.empty());
    }

    /** Backward-compatible constructor for tests of the WebDAV filter boundary. */
    SecurityConfig(
            AuthorizationRulesConfigurer authRules,
            PasswordChangeRequiredFilter passwordChangeRequiredFilter,
            WebDavApplicationCredentialFilter webDavCredentialFilter) {
        this(authRules, passwordChangeRequiredFilter,
                webDavCredentialFilter, null, Optional.empty());
    }

    /** Backward-compatible constructor for focused LLM-quota tests. */
    SecurityConfig(
            AuthorizationRulesConfigurer authRules,
            PasswordChangeRequiredFilter passwordChangeRequiredFilter,
            WebDavApplicationCredentialFilter webDavCredentialFilter,
            RateLimitFilter rateLimitFilter) {
        this(authRules, passwordChangeRequiredFilter,
                webDavCredentialFilter, rateLimitFilter, Optional.empty());
    }

    @Bean(name = "securityFilterChain")
    SecurityFilterChain trackedSecurityFilterChain(HttpSecurity http, SessionRegistry registry) throws Exception {
        BrowserSessionConfiguration.configure(http, registry);
        return securityFilterChain(http);
    }

    /** Existing security rules, also used by focused protocol/filter configuration tests. */
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        RequestMatcher statelessProtocolClient = SecurityConfig::isStatelessProtocolClient;

        http
            .authorizeHttpRequests(auth -> authRules.configure(auth))
            .csrf(csrf -> csrf.ignoringRequestMatchers(statelessProtocolClient))
            .headers(headers -> headers
                .withObjectPostProcessor(eagerHeaderWriter())
                .contentTypeOptions(Customizer.withDefaults())
                .frameOptions(frame -> frame.sameOrigin())
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000))
                .referrerPolicy(referrer -> referrer
                    .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)))
            .formLogin(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults())
            .logout(Customizer.withDefaults());

        if (webDavCredentialFilter != null) {
            http.addFilterBefore(webDavCredentialFilter, BasicAuthenticationFilter.class);
        }
        if (loginRateLimitFilter != null) {
            // Restore a trusted session first, then wrap the authoritative form-login
            // and HTTP-Basic authentication filters exactly once.
            http.addFilterAfter(loginRateLimitFilter, SecurityContextHolderFilter.class);
        }
        http.addFilterAfter(passwordChangeRequiredFilter, BasicAuthenticationFilter.class);
        if (rateLimitFilter != null) {
            // Authorization must succeed before a request may consume quota or
            // allocate per-principal state.
            http.addFilterAfter(rateLimitFilter, AuthorizationFilter.class);
        }
        return http.build();
    }

    private static ObjectPostProcessor<HeaderWriterFilter> eagerHeaderWriter() {
        return new ObjectPostProcessor<>() {
            @Override
            public <O extends HeaderWriterFilter> O postProcess(O filter) {
                filter.setShouldWriteHeadersEagerly(true);
                return filter;
            }
        };
    }

    /** Explicit Basic/Bearer protocol calls are stateless; browser sessions retain CSRF. */
    static boolean isStatelessProtocolClient(HttpServletRequest request) {
        String uri = protocolPath(request);
        if (!uri.startsWith("/api/")
                && !uri.equals("/dav/templates")
                && !uri.startsWith("/dav/templates/")) {
            return false;
        }
        String authorization = request.getHeader("Authorization");
        return authorization != null
                && (authorization.regionMatches(true, 0, "Basic ", 0, 6)
                || authorization.regionMatches(true, 0, "Bearer ", 0, 7));
    }

    static String protocolPath(HttpServletRequest request) {
        String uri = request.getRequestURI();
        String context = request.getContextPath();
        return context == null || context.isBlank() || !uri.startsWith(context)
                ? uri : uri.substring(context.length());
    }
}