Search code examples
spring-bootposthttp-status-code-403rest-assured

Rest Assured tests using @WithMockUser work for GET requests, but not POST (403 Error)


In my Spring Boot 2 app, I'm using Rest Assured to test one of my controllers. This controller is mapped at the class level to be /routing-status and has 3 endpoints: a GET all, a GET by ID, and a POST.

The Rest Assured tests I've got for the GET endpoints work just fine, but for whatever reason, the POST test keeps resulting in a 403 error. (java.lang.AssertionError: 1 expectation failed. Expected status code <201> but was <403>.) I can't figure out why, considering the security is the same for the whole controller and my test uses the same mock user config for each endpoint.

Perhaps it is some other issue with the security settings that only affects POSTs (or at the very least does NOT affect GETs)? Below is my current security config, though I had been messing with the endpoint wildcards (to no avail) to see if not having the trailing slash would work.

httpSecurity
            .csrf().csrfTokenRepository(csrfTokenRepository)
            .and().cors()
            .and().addFilter(createCasAuthFilter())
            .headers().frameOptions().disable()
            .and().exceptionHandling().authenticationEntryPoint(noRedirectAuthenticationEntryPoint)
            .and().authorizeRequests()
            .antMatchers(HttpMethod.OPTIONS).permitAll()
            .antMatchers("/login/redirect").authenticated()
            .antMatchers("/login/**", "/status/**", "/error/**", "/smoke-test/**", LOGOUT_PATH).permitAll()
        // BELOW IS THE CONTROLLER ENDPOINT IN QUESTION    
            .antMatchers("/routing-status/**").hasRole("ROUTING_STATUS_ADMIN")
        // ABOVE IS THE CONTROLLER ENDPOINT IN QUESTION
            .antMatchers(HttpMethod.GET, "/user/**").authenticated()
            .antMatchers("/hello-user**", "/exit-user**", "/roles-map**").authenticated()
            .anyRequest().denyAll().and()
            .logout()
            .logoutRequestMatcher(new AntPathRequestMatcher(LOGOUT_PATH))
            .logoutSuccessUrl(casUrl + LOGOUT_PATH)
            .and().authorizeRequests();

Below is my failing POST Rest Assured test. The GET requests all use the exact same @WithMockUser annotation with the same constant values, and they all hit the same root endpoint /routing-status (though in the case of the GET by ID, an additional /{ID} is passed). I tried including the trailing slash and leaving the trailing slash off for the POST URL, but no luck.

private static final String ADMIN_ROLE = "ROUTING_STATUS_ADMIN";


/* Other fields and methods omitted */

@Test
@WithMockUser(username = TEST_USERNAME, roles = {ADMIN_ROLE})
public void testInsertRoutingStatus() {
    RoutingStatus status = createRoutingStatus();

    RoutingStatus result = given().mockMvc(mockMvc).contentType(ContentType.JSON).log().all()
            .and().body(status)
            .when().post("/routing-status/")
            .then().log().all().statusCode(201)
            .and().body(matchesJsonSchemaInClasspath("json-schemas/routing-status.json"))
            .extract().as(RoutingStatus.class);
}

For good measure, here are the relevant parts of the controller:

@Controller
@RequestMapping(value = "/routing-status", produces = MediaType.APPLICATION_JSON_VALUE)
public class RoutingStatusController {
    /* Other properties and endpoints omitted */

    @PostMapping(value = "")
    public ResponseEntity<RoutingStatus> insertRoutingStatus(
        @Valid @RequestBody final RoutingStatus routingStatus, final Principal principal) {

        return new ResponseEntity<>(routingStatusService.saveNewRoutingStatus(routingStatus, principal.getName()),
            HttpStatus.CREATED);
    }
}

When debug logging is turned on for spring security (using the logging.level.org.springframework.security=DEBUG property), the output right before the error is (timestamps, log level [DEBUG], fully qualified class names [all spring boot/security packages], and a few other irrelevant details were removed for brevity):

HttpSessionSecurityContextRepository:174 - No HttpSession currently exists
HttpSessionSecurityContextRepository:116 - No SecurityContext was available from the HttpSession: null. A new one will be created.
HttpSessionSecurityContextRepository$SaveToSessionResponseWrapper:423 - HttpSession being created as SecurityContext is non-default
HttpSessionSecurityContextRepository$SaveToSessionResponseWrapper:377 - SecurityContext 'SecurityContextImpl@6ea1035d: Authentication: UsernamePasswordAuthenticationToken@6ea1035d: Principal: User@e85371e3: Username: TEST_USERNAME; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_ROUTING_STATUS_ADMIN; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_ROUTING_STATUS_ADMIN' stored to HttpSession: 'MockHttpSession@294ab038
FilterChainProxy$VirtualFilterChain:328 - /routing-status/ at position 1 of 13 in additional filter chain; firing Filter: 'WebAsyncManagerIntegrationFilter'
FilterChainProxy$VirtualFilterChain:328 - /routing-status/ at position 2 of 13 in additional filter chain; firing Filter: 'SecurityContextPersistenceFilter'
HttpSessionSecurityContextRepository:207 - Obtained a valid SecurityContext from SPRING_SECURITY_CONTEXT: 'SecurityContextImpl@6ea1035d: Authentication: UsernamePasswordAuthenticationToken@6ea1035d: Principal: User@e85371e3: Username: TEST_USERNAME; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_ROUTING_STATUS_ADMIN; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_ROUTING_STATUS_ADMIN'
FilterChainProxy$VirtualFilterChain:328 - /routing-status/ at position 3 of 13 in additional filter chain; firing Filter: 'HeaderWriterFilter'
FilterChainProxy$VirtualFilterChain:328 - /routing-status/ at position 4 of 13 in additional filter chain; firing Filter: 'CorsFilter'
FilterChainProxy$VirtualFilterChain:328 - /routing-status/ at position 5 of 13 in additional filter chain; firing Filter: 'CsrfFilter'
CsrfFilter:110 - Invalid CSRF token found for http://localhost/routing-status/
HstsHeaderWriter:129 - Not injecting HSTS header since it did not match the requestMatcher HstsHeaderWriter$SecureRequestMatcher@247415be
SecurityContextPersistenceFilter:119 - SecurityContextHolder now cleared, as request processing completed
403 Forbidden

So it seems like perhaps csrf could be an issue? However csrf works fine when the application is running, and I'm not sure why it's checking http://localhost/routing-status. When running locally, there's a port number and a context path inbetween localhost and /routing-status.


Solution

  • The reason I got a 403 is due to the fact that there was no CSRF token in the test. I'm not super familiar with CSRF, but based on what I've found so far, it didn't affect the GET requests because CSRF only affects write requests like POST and PUT. I'm still working on adding CSRF steps to the RestAssured tests, but at least I now know the cause of the behavior. I intend to follow the instructions on Rest Assured's github page to fully solve the issue: https://github.com/rest-assured/rest-assured/wiki/usage#csrf