0

I'm trying to upgrade to spring boot 3 and spring security 6.

I'm running into an issue where previously, mvcMatchers would match against these URLs:

        String[] companiesEndpoints = {"/companies", "/companies/*"};
        String[] ideationEndpoint = {"/ideation", "/ideation/*"};
        String[] assessmentsEndpoints = {"/assessment", "/assessment/*", "/assessment/*/value-rating", "/assessment/*/viability-rating", "/assessment/*/customer-rating"};
        String[] teamsEndpoints = {"/teams", "/teams/*"};
        String[] userEndpoints = {"/users", "/users/*"};
        String[] projectEndpoints = {"/project", "/project/*", "/project-and-assessment"};
        String[] workspaceEndpoints = {"/workspace", "/workspace/*"};
        String[] tagEndpoints = {"/tag", "/tag/*"};

As part of the move to spring security 6, mvcMatchers are now replaced with requestMatchers - I thought I could drop in the new matches and things would keep working, but now the listed URLs before only allow some of the requests. Here are some examples:

.mvcMatchers(assessmentsEndpoints).authenticated() use to match "/assessment/2900b695-d344-4bec-b25d-524f6b22a93a/customer-rating". .requestMatchers(assessmentsEndpoints).authenticated() does not match so the API returns a 403.

This makes me think that requestMatcher is not a drop in replacement for mvcMatcher, but I'm not sure how I should be structuring this to make requestMatcher allow these requests.

I have many requests with path parameters like "/assessment/{assessmentId}/value-rating*". How should I structure my String[] endpoints to allow such URLs?

For reference, here is the full SecurityConfig class that contains the relevant code.

@Configuration
public class SecurityConfig {

    @Value(value = "${auth0.audience}")
    private String apiAudience;
    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    private String issuer;

    @Bean
    ForwardedHeaderFilter forwardedHeaderFilter() {
        return new ForwardedHeaderFilter();
    }

    @Bean
    JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder = JwtDecoders.fromOidcIssuerLocation(issuer);

        OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(apiAudience);
        OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
        OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator);

        jwtDecoder.setJwtValidator(withAudience);

        return jwtDecoder;
    }

    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList(
                "http://localhost:4200"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE"));
        configuration.setAllowCredentials(true);
        configuration.setAllowedHeaders(Arrays.asList(
                "x-requested-with",
                "content-type",
                "Accept",
                "Authorization",
                "sentry-trace",
                "baggage"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        String[] companiesEndpoints = {"/companies", "/companies/*"};
        String[] ideationEndpoint = {"/ideation", "/ideation/*"};
        String[] assessmentsEndpoints = {"/assessment", "/assessment/*", "/assessment/*/value-rating", "/assessment/*/viability-rating", "/assessment/*/customer-rating"};
        String[] teamsEndpoints = {"/teams", "/teams/*"};
        String[] userEndpoints = {"/users", "/users/*"};
        String[] projectEndpoints = {"/project", "/project/*", "/project-and-assessment"};
        String[] workspaceEndpoints = {"/workspace", "/workspace/*"};
        String[] tagEndpoints = {"/tag", "/tag/*"};


        http.authorizeHttpRequests((authorize) -> {
            try {
                authorize
                                .requestMatchers(companiesEndpoints).authenticated()
                                .requestMatchers(ideationEndpoint).authenticated()
                                .requestMatchers(assessmentsEndpoints).authenticated()
                                .requestMatchers(teamsEndpoints).authenticated()
                                .requestMatchers(userEndpoints).authenticated()
                                .requestMatchers(projectEndpoints).authenticated()
                                .requestMatchers(workspaceEndpoints).authenticated()
                                .requestMatchers(tagEndpoints).authenticated()
                                .requestMatchers(EndpointRequest.to("info")).hasAuthority("SCOPE_read:status")
                                .requestMatchers(EndpointRequest.to("health")).permitAll()
                                .and()
                                .oauth2ResourceServer((oauth2ResourceServer) ->
                                        oauth2ResourceServer.jwt(jwt -> jwt.decoder(jwtDecoder())));
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        });

        // Disable X-Frames on same origin to enable access to H2 in memory db console
        // https://stackoverflow.com/questions/26220083/h2-database-console-spring-boot-load-denied-by-x-frame-options
        http.headers().frameOptions().sameOrigin();

        return http.build();
    }
}
Alex
  • 177
  • 12

1 Answers1

1

Adding debug logging as dur suggested revealed the issue. There was no issue, spring was actually authorising these requests.

Poking the API with postman responded with 200s across the board, so the requestMatchers did just fine.

Integration tests failed though! Some quick investigation and debugging revealed a change in Spring Security 6 that I wasn't aware of - the need to add csrf protection to controller tests.

Here is a failing unit test that I was using to diagnose and debug the issue:

    @Test
    @Transactional
    void updateValueRating() throws Exception {
        ValueRating newRating = ValueRatingBuilder.aValueRating()
                .withEarnableRevenue(50)
                .withRevenueAtRisk(50)
                .build();

        mockMvc.perform(put("/assessment/2900b695-d344-4bec-b25d-524f6b22a93a/value-rating")
                        .content(mapper.writeValueAsString(newRating))
                        .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.earnableRevenue", is(50)))
                .andExpect(jsonPath("$.revenueAtRisk", is(50)))
                .andExpect(jsonPath("$.overallValueRating", is(1.5)));
    }

Adding the static print call .andDo(print())) revealed a message from Spring Security's CSRF filter: Invalid CSRF token found, which then responded with a 403 in the unit test, where poking with HTTP worked fine.

A quick google yielded https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/csrf.html - adding .with(csrf()) to the test like so, fixed the issue.

mockMvc.perform(put("/assessment/2900b695-d344-4bec-b25d-524f6b22a93a/value-rating")
                        .content(mapper.writeValueAsString(newRating))
                        .contentType(MediaType.APPLICATION_JSON)
                        .with(csrf()))

I didn't realise that this changed between Spring Security 5 and 6 so it caught be by surprise. In case anyone hits a similar issue I'm posting this answer.

Alex
  • 177
  • 12