KeycloakSecurityConfig.java

package com.taxonomy.security.config;

import com.taxonomy.security.keycloak.KeycloakAuthenticationEntryPoint;
import com.taxonomy.security.keycloak.KeycloakJwtAuthConverter;
import com.taxonomy.security.keycloak.KeycloakLogoutHandler;
import com.taxonomy.security.keycloak.KeycloakOidcUserService;
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.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.header.HeaderWriterFilter;
import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;

/** Spring Security configuration for Keycloak/OIDC mode. */
@Configuration
@EnableMethodSecurity
@Profile("keycloak")
public class KeycloakSecurityConfig {

    private final AuthorizationRulesConfigurer authRules;
    private final KeycloakJwtAuthConverter jwtAuthConverter;
    private final KeycloakOidcUserService oidcUserService;
    private final KeycloakLogoutHandler logoutHandler;
    private final KeycloakAuthenticationEntryPoint authenticationEntryPoint;
    private final WebDavApplicationCredentialFilter webDavCredentialFilter;
    private final RateLimitFilter rateLimitFilter;

    @Autowired
    public KeycloakSecurityConfig(
            AuthorizationRulesConfigurer authRules,
            KeycloakJwtAuthConverter jwtAuthConverter,
            KeycloakOidcUserService oidcUserService,
            KeycloakLogoutHandler logoutHandler,
            KeycloakAuthenticationEntryPoint authenticationEntryPoint,
            WebDavApplicationCredentialFilter webDavCredentialFilter,
            RateLimitFilter rateLimitFilter) {
        this.authRules = authRules;
        this.jwtAuthConverter = jwtAuthConverter;
        this.oidcUserService = oidcUserService;
        this.logoutHandler = logoutHandler;
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.webDavCredentialFilter = webDavCredentialFilter;
        this.rateLimitFilter = rateLimitFilter;
    }

    /** Backward-compatible constructor for focused security configuration tests. */
    public KeycloakSecurityConfig(
            AuthorizationRulesConfigurer authRules,
            KeycloakJwtAuthConverter jwtAuthConverter,
            KeycloakOidcUserService oidcUserService,
            KeycloakLogoutHandler logoutHandler,
            KeycloakAuthenticationEntryPoint authenticationEntryPoint) {
        this(authRules, jwtAuthConverter, oidcUserService, logoutHandler,
                authenticationEntryPoint, null, null);
    }

    /** Backward-compatible constructor for WebDAV security tests. */
    public KeycloakSecurityConfig(
            AuthorizationRulesConfigurer authRules,
            KeycloakJwtAuthConverter jwtAuthConverter,
            KeycloakOidcUserService oidcUserService,
            KeycloakLogoutHandler logoutHandler,
            KeycloakAuthenticationEntryPoint authenticationEntryPoint,
            WebDavApplicationCredentialFilter webDavCredentialFilter) {
        this(authRules, jwtAuthConverter, oidcUserService, logoutHandler,
                authenticationEntryPoint, webDavCredentialFilter, null);
    }

    @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 csrfExempt = new OrRequestMatcher(
                KeycloakSecurityConfig::isStatelessBearerProtocolClient,
                KeycloakSecurityConfig::isStatelessWebDavApplicationClient,
                KeycloakSecurityConfig::isOAuth2Callback);

        http
            .authorizeHttpRequests(auth -> authRules.configure(auth))
            .csrf(csrf -> csrf.ignoringRequestMatchers(csrfExempt))
            .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)))
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo.oidcUserService(oidcUserService)))
            .oauth2ResourceServer(oauth2 -> oauth2
                .authenticationEntryPoint(authenticationEntryPoint)
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter)))
            .logout(logout -> logout.logoutSuccessHandler(logoutHandler));

        if (webDavCredentialFilter != null) {
            http.addFilterBefore(
                    webDavCredentialFilter, BearerTokenAuthenticationFilter.class);
        }
        if (rateLimitFilter != null) {
            // OIDC/Bearer authentication and request authorization must both
            // succeed before a request may consume quota.
            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;
            }
        };
    }

    private static boolean isStatelessBearerProtocolClient(HttpServletRequest request) {
        String uri = SecurityConfig.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, "Bearer ", 0, 7);
    }

    private static boolean isStatelessWebDavApplicationClient(
            HttpServletRequest request) {
        String uri = SecurityConfig.protocolPath(request);
        if (!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);
    }

    private static boolean isOAuth2Callback(HttpServletRequest request) {
        return SecurityConfig.protocolPath(request).startsWith("/login/oauth2/code/");
    }
}