BaseWebSecurityConfig.java

package net.hostsharing.hsadminng.config;

import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
import io.swagger.v3.oas.annotations.security.SecurityScheme;
import lombok.SneakyThrows;
import lombok.val;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import org.springframework.lang.NonNull;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoders;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import jakarta.servlet.http.HttpServletResponse;
import javax.crypto.spec.SecretKeySpec;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

import static net.hostsharing.hsadminng.config.JwtFakeBearer.RSA_KEY;

@EnableWebSecurity
// securitySchemes should work in OpenAPI yaml, but the Spring templates seem not to support it
@SecurityScheme(
        name = "bearerAuth",
        type = SecuritySchemeType.HTTP,
        scheme = "bearer",
        bearerFormat = "JWT"
)
public abstract class BaseWebSecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(
            final HttpSecurity http,
            final ObjectProvider<JdbcTemplate> jdbcTemplate) throws Exception {
        // @WebMvcTest slices have no DataSource, thus no JdbcTemplate and no API-key authentication;
        // in the real application the JdbcTemplate is always available
        jdbcTemplate.ifAvailable(jdbc -> {
            http.addFilterBefore(new ApiKeyAuthenticationFilter(jdbc), BearerTokenAuthenticationFilter.class);
            // after the Bearer filter: scopes are only enforced if the surviving authentication is
            // the one synthesized from an API-key (requests with both auth headers get rejected before)
            http.addFilterAfter(new ApiKeyScopeEnforcementFilter(), BearerTokenAuthenticationFilter.class);
        });
        return http
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(
                                // only list endpoints implemented in libraries here
                                "/swagger-ui/**",
                                "/v3/api-docs/**",
                                "/actuator/**", // reachable via management port
                                "/fake-jwt/**",
                                "/doc/**" // bundled documentation, all of it is in public repository anyway
                                // otherwise use @PreAuthorize annotation at the controller class / endpoint method level
                        ).permitAll()
                        .requestMatchers("/api/**").permitAll() // controlled at method level
                        .anyRequest().denyAll()
                )
                .oauth2ResourceServer(oauth ->
                        oauth.jwt(Customizer.withDefaults()))
                .addFilterBefore(new AssumedRolesHeaderFilter(), BasicAuthenticationFilter.class)
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint((request, response, authException) ->
                                // For unknown reason Spring security returns 403 FORBIDDEN for a BadCredentialsException.
                                // But it should return 401 UNAUTHORIZED.
                                response.sendError(HttpServletResponse.SC_UNAUTHORIZED)
                        )
                )
                .build();
    }

    @Bean("jwtDecoder")
    @Profile("!fake-jwt")
    public JwtDecoder standardJwtDecoder(
            @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") final String issuerUri,
            @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") final String jwkSetUri,
            @Value("${spring.security.oauth2.resourceserver.jwt.hmac-secret:}") final String hmacSecret,
            @Value("${spring.security.oauth2.resourceserver.jwt.audiences:}") final String audiences) {
        // the issuer is mandatory, so that every decoder validates the "iss" claim (a token minted
        // under the same signing key but by a different issuer cannot be replayed against us)
        if (!StringUtils.hasText(issuerUri)) {
            throw new IllegalStateException(
                    "spring.security.oauth2.resourceserver.jwt.issuer-uri (HSADMINNG_JWT_ISSUER) must be configured.");
        }
        if (issuerUri.contains("/fake-jwt")) {
            throw new IllegalStateException("You are using a fake-jwt issuer URL (\"" + issuerUri + "\") but the 'fake-jwt' profile is not active. " +
                    "Either activate the 'fake-jwt' profile or set a real JWT issuer URL in HSADMINNG_JWT_ISSUER.");
        }
        if (StringUtils.hasText(hmacSecret)) {
            val decoder = NimbusJwtDecoder.withSecretKey(new SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA512"))
                    .macAlgorithm(MacAlgorithm.HS512)
                    .build();
            return withTokenValidators(decoder, issuerUri, audiences);
        }
        if (StringUtils.hasText(jwkSetUri)) {
            if (jwkSetUri.contains("/fake-jwt")) {
                throw new IllegalStateException("You are using a fake-jwt JWK set URI (\"" + jwkSetUri + "\") but the 'fake-jwt' profile is not active. " +
                        "Either activate the 'fake-jwt' profile or set a real JWT JWK set URI in spring.security.oauth2.resourceserver.jwt.jwk-set-uri.");
            }
            return withTokenValidators(NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(), issuerUri, audiences);
        }
        return withTokenValidators(JwtDecoders.fromIssuerLocation(issuerUri), issuerUri, audiences);
    }

    // The issuer is always validated (issuer-uri is mandatory). The audience ("aud") claim is only
    // validated if audiences (HSADMINNG_JWT_AUDIENCE) is configured, because the real HS Keycloak
    // access tokens currently carry no "aud" claim at all; once they do, configure the expected value.
    private static NimbusJwtDecoder withTokenValidators(
            final NimbusJwtDecoder decoder, final String issuerUri, final String audiences) {
        final var validators = new ArrayList<OAuth2TokenValidator<Jwt>>();
        validators.add(new JwtTimestampValidator());
        if (StringUtils.hasText(issuerUri)) {
            validators.add(new JwtIssuerValidator(issuerUri));
        }
        if (StringUtils.hasText(audiences)) {
            val expectedAudiences = Arrays.stream(audiences.split(","))
                    .map(String::trim).filter(StringUtils::hasText).toList();
            validators.add(new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
                    aud -> aud != null && expectedAudiences.stream().anyMatch(aud::contains)));
        }
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
        return decoder;
    }

    @Bean("jwtDecoder")
    @Profile("fake-jwt")
    @SneakyThrows
    public JwtDecoder fakeJwtDecoder() {
        // For fake-jwt profile, use the same RSA key as JwtFakeBearer
        return NimbusJwtDecoder.withPublicKey(RSA_KEY.toRSAPublicKey()).build();
    }

    @Bean
    public WebMvcConfigurer corsConfigurer(
            @Value("${hsadminng.cors.allowed-origins:*}") final String corsAllowedOrigins) {

        return new WebMvcConfigurer() {

            @Override
            public void addCorsMappings(@NonNull final CorsRegistry registry) {
                val allowedOrigins = (corsAllowedOrigins != null && !corsAllowedOrigins.isEmpty())
                        ? corsAllowedOrigins.split(",")
                        : new String[]{"*"};
                registry.addMapping("/api/ping")
                        .allowedOrigins("*")
                        .allowedMethods("GET");
                registry.addMapping("/api/**").allowedOrigins(allowedOrigins)
                        .allowedMethods("GET", "PUT", "POST", "PATCH", "DELETE");
                // browser-frontends log in at the fake-jwt token endpoint directly, without a dev-server-proxy
                registry.addMapping("/fake-jwt/**").allowedOrigins(allowedOrigins)
                        .allowedMethods("POST");
            }
        };
    }
}