Search code examples
spring-bootspring-securitycors

Spring Security 6.3.3 WebFlux CORS


Setup

  • Spring Boot 3.3
  • Spring Security 6.3.3
  • Angular 18

Both server and client run on different hosts. Spring Boot application runs on http://api.example.com. Angular application runs on http://client.example.com.

Problem Using the configuration bellow when i make a request to http://api.example.com/users/info with a valid JWT token the request passes through CORS WebFilter and AUTHENTICATION WebFilter and i get a user info json object both in postman and browser(chrome i don't this that this is a problem but just in case).

When i try with an invalid JWT token, let's say an expired one, then i get Unauthorized (401) status code witch is expected CORS error on browser console which is weird(to my understanding) because the valid request passes and no erro body on browser while i get a "correct" error body in postman.

The Spring Boot logs does not refer any header missing regarding CORS. I have just to lines of log for this request:

  • HTTP GET "/users/info"
  • Completed 401 UNAUTHORIZED

Spring Boot log information

logging:
  level:
    org.springframework.web: DEBUG
    org.springframework.security: DEBUG
    io.r2dbc.spi: DEBUG

I am not an experienced Spring Boot developer so don't be hard on me on the implementation. Although corrections as appreciated.

Hope i provided all the information needed around this problem. Any suggestions? Thanks in advance.

SecurityConfiguration.java

@Configuration
@EnableWebFluxSecurity
@EnableConfigurationProperties(ResourceServiceSecurityProperties.class)
@RequiredArgsConstructor
public class SecurityConfiguration {

    private static final String[] AUTH_WHITELIST = {
        "/users/login"
    };

    private final ResourceServiceSecurityProperties resourceServiceSecurityProperties;

    private final JwtAuthFilter jwtAuthFilter;

    private final CorsFilter corsFilter;

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {

        http
                // Disabled basic authentication
                .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)

                // Disabled CSRF
                .csrf(ServerHttpSecurity.CsrfSpec::disable)

                // Rest services don't have a login form
                .formLogin(ServerHttpSecurity.FormLoginSpec::disable)

                .logout(ServerHttpSecurity.LogoutSpec::disable)

                // Disable Spring Security CORS we will use a CORS web filter.
                .cors(ServerHttpSecurity.CorsSpec::disable)

                .authorizeExchange(authorizeRequests -> authorizeRequests
                                .pathMatchers(HttpMethod.OPTIONS, "/**")
                                    .permitAll()
                                .pathMatchers(HttpMethod.POST, AUTH_WHITELIST)
                                    .permitAll()
                                .anyExchange()
                                    .authenticated()
                )

                .addFilterAt(jwtAuthFilter, SecurityWebFiltersOrder.AUTHENTICATION)
                .addFilterAt(corsFilter, SecurityWebFiltersOrder.CORS)

                // Do not allow sessions
               .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())

                .oauth2ResourceServer(oauth2ResourceServerCustomizer -> oauth2ResourceServerCustomizer
                        .jwt(Customizer.withDefaults())
                );

        return http.build();
    }

    @Bean
    public ReactiveJwtDecoder reactiveJwtDecoder() {
        return ReactiveJwtDecoders.fromIssuerLocation(resourceServiceSecurityProperties.getIssuerUri());
    }

CorsFilter.java

@Component
public class CorsFilter implements WebFilter {

    @Override
    @NonNull
    public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        ServerHttpResponse response = exchange.getResponse();

        if (request.getMethod().equals(HttpMethod.OPTIONS)) {
            response.setStatusCode(HttpStatus.ACCEPTED);
            return chain.filter(exchange);
        }

        request.mutate()
                .header("Access-Control-Allow-Origin", http://client.example.com")
                .header("Access-Control-Allow-Credentials", "true")
                .header("Access-Control-Allow-Methods", "POST, GET, PUT, OPTIONS, DELETE")
                .header("Access-Control-Max-Age", "3600")
                .header("Access-Control-Allow-Headers", "Content-Type, authorization")
                .build();

        return chain.filter(exchange.mutate().request(request).build());
    }
}

Also tried without mutating the request and the exchange.

JwtAuthFilter.java

@Component
@AllArgsConstructor
public class JwtAuthFilter implements WebFilter {

    private final JwtUtil jwtUtil;

    private static final String[] AUTH_WHITELIST = {
            "/users/login"
    };

    @Override
    @NonNull
    public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) {
        if (Arrays.asList(AUTH_WHITELIST).contains(exchange.getRequest().getPath().value())) {
            return chain.filter(exchange);
        }

        if (exchange.getRequest().getMethod().equals(HttpMethod.OPTIONS)) {
            exchange.getResponse().setStatusCode(HttpStatus.ACCEPTED);
            return chain.filter(exchange);
        }

        return Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("Authorization"))
                .filter(authHeader -> authHeader.startsWith("Bearer "))
                .switchIfEmpty(
                    Mono.error(
                            new InvalidBearerTokenException("The token provided is expired, malformed, revoked, or invalid for other reasons.")
                    )
                )
                .map(authHeader -> authHeader.substring(7))
                .map(jwtUtil::isExpired)
                .flatMap(isExpired -> {
                    if (isExpired) {
                        return Mono.error(new InvalidBearerTokenException("The token provided is expired, malformed, revoked, or invalid for other reasons."));
                    }
                    return chain.filter(exchange);
                });
    }
}

ExceptionHandlerAdvice.java

@RestControllerAdvice
@AllArgsConstructor
//@Priority(0)
@Order(-2)
public class ExceptionHandlerAdvice implements ErrorWebExceptionHandler {

    private final ExceptionErrorFactory exceptionErrorFactory;

    private final ObjectMapper objectMapper;

    @ExceptionHandler(WebExchangeBindException.class)
    public ResponseEntity<ErrorDto> handleValidationExceptions(final WebExchangeBindException ex) {
        return new ResponseEntity<>(
                this.exceptionErrorFactory.createError(ex.getClass().getName(), ex),
                ex.getStatusCode());
    }

    @ExceptionHandler(InvalidBearerTokenException.class)
    public ResponseEntity<ErrorDto> handleInvalidBearerTokenException(final InvalidBearerTokenException ex) {
        return new ResponseEntity<>(
                this.exceptionErrorFactory.createError(ex.getClass().getName()),
                HttpStatus.UNAUTHORIZED
        );
    }

    @ExceptionHandler(ExpiredJwtException.class)
    public ResponseEntity<ErrorDto> handleExpiredBearerTokenException(final ExpiredJwtException ex) {
        return new ResponseEntity<>(
                this.exceptionErrorFactory.createError(ex.getClass().getName()),
                HttpStatus.UNAUTHORIZED
        );
    }

    @ExceptionHandler(UnauthorisedException.class)
    public ResponseEntity<ErrorDto> handleUnauthorizedException(UnauthorisedException ex) {
        return new ResponseEntity<>(
                this.exceptionErrorFactory.createError(ex.getClass().getName(), ex),
                HttpStatus.UNAUTHORIZED
        );
    }

    @Override
    @NonNull
    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {

        DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();

        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);

        ErrorDto errorResponse = null;

        if (ex.getClass().equals(ExpiredJwtException.class)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            errorResponse = this.exceptionErrorFactory.createError(ex.getClass().getName());
        } else if (ex.getClass().equals(InvalidBearerTokenException.class)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            errorResponse = this.exceptionErrorFactory.createError(ex.getClass().getName());
        }

        DataBuffer dataBuffer = null;
        try {
            dataBuffer = bufferFactory.wrap(objectMapper.writeValueAsBytes(errorResponse));
        } catch (Exception e) {
            dataBuffer = bufferFactory.wrap("".getBytes());
        }

        return exchange.getResponse().writeWith(Flux.just(dataBuffer));
// Also tried this
// return exchange.getResponse().writeWith(Mono.just(dataBuffer));

    }
}

I tried implement CORS filter like that with no luck. The only difference with this implementation is that i could see the preflight request (OPTIONS) on browser.

    @Bean
    CorsConfigurationSource corsConfiguration() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOrigin("http://client.example.com");
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
        configuration.addAllowedMethod("GET");
        configuration.addAllowedMethod("PUT");
        configuration.addAllowedMethod("POST");
        configuration.addAllowedMethod("DELETE");
        configuration.addAllowedMethod("OPTIONS");
        configuration.addAllowedHeader("Content-Type");
        configuration.addAllowedHeader("authorization");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public CorsWebFilter corsWebFilter() {
        return new CorsWebFilter(corsConfiguration());
    }

Solution

  • I think i found the solution. @Toerktumlare thanks for pointing to the right direction. As Toerktumlare pointed out my filters, CorsFilter and JwtAuthFilter are bypassing the default functionality of Spring Security. Although the CorsFilter is described in the documendation the best practice always by the documendation is the CorsConfigurationSource and CorsWebFilter beans.

    This is the final configuration that worked for me. Again feel free to point out any bad practices.

    SecurityConfiguration.java

    @Configuration
    @EnableWebFluxSecurity
    @EnableConfigurationProperties(ResourceServiceSecurityProperties.class)
    @RequiredArgsConstructor
    public class SecurityConfiguration {
    
        private static final String[] AUTH_WHITELIST = {
                "/users/login"
        };
    
        private final ResourceServiceSecurityProperties resourceServiceSecurityProperties;
    
        private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    
        @Bean
        public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
    
            http
                    // Disabled basic authentication
                    .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
    
                    // Disabled CSRF
                    .csrf(ServerHttpSecurity.CsrfSpec::disable)
    
                    // Rest services don't have a login form
                    .formLogin(ServerHttpSecurity.FormLoginSpec::disable)
    
                    .logout(ServerHttpSecurity.LogoutSpec::disable)
    
                    .authorizeExchange(authorizeRequests -> authorizeRequests
                            .pathMatchers(HttpMethod.OPTIONS, "/**")
                            .permitAll()
                            .pathMatchers(HttpMethod.POST, AUTH_WHITELIST)
                            .permitAll()
                            .anyExchange()
                            .authenticated()
                    )
    
                    // Do not allow sessions
                    .securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
    
                    .oauth2ResourceServer(oauth2ResourceServerCustomizer -> oauth2ResourceServerCustomizer
                            .jwt(Customizer.withDefaults()).authenticationEntryPoint(customAuthenticationEntryPoint)
                    );
    
            return http.build();
        }
    
        @Bean
        public ReactiveJwtDecoder reactiveJwtDecoder() {
            return ReactiveJwtDecoders.fromIssuerLocation(resourceServiceSecurityProperties.getIssuerUri());
        }
    
        @Bean
        CorsConfigurationSource corsConfiguration() {
            CorsConfiguration configuration = new CorsConfiguration();
            configuration.addAllowedOrigin("http://client.example.com");
            configuration.setAllowCredentials(true);
            configuration.setMaxAge(3600L);
            configuration.addAllowedMethod("GET");
            configuration.addAllowedMethod("PUT");
            configuration.addAllowedMethod("POST");
            configuration.addAllowedMethod("DELETE");
            configuration.addAllowedMethod("OPTIONS");
            configuration.addAllowedHeader("Content-Type");
            configuration.addAllowedHeader("authorization");
    
            UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
            source.registerCorsConfiguration("/**", configuration);
            return source;
        }
    
        @Bean
        public CorsWebFilter corsWebFilter() {
            return new CorsWebFilter(corsConfiguration());
        }
    }
    

    No changes to ExceptionHandlerAdvice.java

    Added CustomAuthenticationEntryPoint.java

    @Component
    @AllArgsConstructor
    public class CustomAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
    
        private final ExceptionErrorFactory exceptionErrorFactory;
    
        private final ObjectMapper objectMapper;
    
        @Override
        public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException exception) {
            DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
    
            ErrorDto errorResponse = this.exceptionErrorFactory.createError(exception.getClass().getName());
    
            DataBuffer dataBuffer = null;
            try {
                dataBuffer = bufferFactory.wrap(objectMapper.writeValueAsBytes(errorResponse));
            } catch (Exception e) {
                dataBuffer = bufferFactory.wrap("Error".getBytes());
            }
    
            ServerHttpResponse response = exchange.getResponse();
            response.setStatusCode(HttpStatus.UNAUTHORIZED);
            response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
            return response.writeWith(Mono.just(dataBuffer));
        }
    }
    

    When i removed the two custom filters and added the CustomAuthenticationEntryPoint everythnig worked as expected. The CORS error was lost and the browser rendered the error body successfully.