spring-projects / spring-security

Spring Security
http://spring.io/projects/spring-security
Apache License 2.0
8.71k stars 5.86k forks source link

WebTestClient authentication fails with form-data credentials #10841

Open membersound opened 2 years ago

membersound commented 2 years ago

spring-boot-2.6.3

I'm migrating my MockMvc tests to WebTestClient, for having all my tests using the same underlying API.

The following example project shows that authenticating on the /login page works with MockMvc, but does not with WebTestClient. In real world, I'm testing a ldap security configuration, but the issue is reproducible even with in-memory authentication.

This is a result result of https://github.com/spring-projects/spring-boot/issues/29825 (see the issue also for a full sample project attached)

I assume this is a bug, as authentication with MockMvc works flawless, and WebTestClient does not.

Tests:

@SpringBootTest
@AutoConfigureMockMvc
public class PersonControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private WebTestClient webTestClient;

    //works
    @Test
    public void testMockMvc() throws Exception {
        SecurityMockMvcRequestBuilders.FormLoginRequestBuilder login = formLogin()
                .user("junituser")
                .password("junitpw");

         mockMvc.perform(login)
                .andExpect(authenticated().withUsername("junituser"));
    }

    //works
    @Test
    public void testMockMvcUnauthenticated() throws Exception {
        SecurityMockMvcRequestBuilders.FormLoginRequestBuilder login = formLogin()
                .user("junituser")
                .password("invalid");

        mockMvc.perform(login)
                .andExpect(unauthenticated());
    }

        //works
    @Test
    public void testRedirectToLoginPage() {
        webTestClient.get().uri("/").exchange().expectStatus().is3xxRedirection();
    }

        //works
    @Test
    public void testLoginPageAnonymous() {
        webTestClient.get().uri("/login").exchange().expectStatus().isOk();
    }

    //fails with 403 forbidden
    @Test
    public void testWebClient() {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("username", "junituser");
        formData.add("password", "junitpw");

        webTestClient.post()
                .uri("/login") //the test would fail the same executed against '/example' @RestController
                .body(BodyInserters.fromFormData(formData))
                .exchange()
                .expectStatus()
                .isOk();
    }

    //throws NPE
    @Test
    public void testWebClientCsrf() {
        MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
        formData.add("username", "junituser");
        formData.add("password", "junitpw");

        //there is no FormLoginRequestBuilder for WebTestClient?
        webTestClient.mutateWith(csrf())
                .post()
                .uri("/login")
                .body(BodyInserters.fromFormData(formData))
                .exchange()
                .expectStatus()
                .isOk();
    }
}

Source:

@RestController
public class PersonController {
    @GetMapping("/example")
    public String example() {
        return "Authorized user";
    }

    @PostMapping("/example")
    public String examplePost() {
        return "Authorized user";
    }
}

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("junituser")
                .password("{noop}junitpw")
                .roles("USER");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin().permitAll();
    }
}

@SpringBootApplication
public class MainApp {
    public static void main(String[] args) {
        SpringApplication.run(MainApp.class, args);
    }
}
sjohnr commented 2 years ago

Hi @membersound. Thanks for the sample over in spring boot issue. I was able to pull in the project and see your errors. I am able to convert the simple project you provided to a Spring WebFlux project by removing the spring-boot-starter-web dependency and changing your SecurityConfig to use webflux support instead. When I do this, your WebTestClient tests work (though they do not pass, as the assertions are expecting 200 instead of 403 and 302 respectively). However, the mockmvc tests cannot work in the same project.

All that to say, I'm uncertain whether your scenario would be expected to work currently as I believe the WebTestClient support in Spring Security is designed to work with webflux, at least in a mock server setup. I'll ask the team if your scenario is supported and get back to you.

sjohnr commented 2 years ago

Hi @membersound. Just to let you know, I've spoken with the team around this issue. A couple of takeaways:

mengelbrecht commented 2 years ago

@sjohnr currently it is possible to use e.g. csrf with WebTestClient in a servlet environment via this workaround:

val webTestClient = MockMvcWebTestClient.bindToApplicationContext(webApplicationContext)
    .apply(SecurityMockMvcConfigurers.springSecurity())
    .defaultRequest(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.csrf()))
    .configureClient()
    .build()

If an exception is thrown proactively during WebTestClient setup would that mean that this code will not work anymore and all tests would have to be reverted back to using MockMvc directly?

sjohnr commented 2 years ago

Thanks @mengelbrecht, that's definitely worth exploring. I'm less familiar with the test infrastructure, so thanks for pointing that out!