|
16 | 16 |
|
17 | 17 | package org.springframework.security.config.annotation.web.configurers; |
18 | 18 |
|
| 19 | +import java.util.ArrayList; |
| 20 | +import java.util.Collection; |
| 21 | +import java.util.List; |
| 22 | +import java.util.function.Supplier; |
| 23 | + |
| 24 | +import org.jspecify.annotations.Nullable; |
19 | 25 | import org.junit.jupiter.api.Test; |
20 | 26 | import org.junit.jupiter.api.extension.ExtendWith; |
21 | 27 |
|
22 | 28 | import org.springframework.beans.factory.annotation.Autowired; |
23 | 29 | import org.springframework.context.annotation.Bean; |
24 | 30 | import org.springframework.context.annotation.Configuration; |
25 | 31 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
| 32 | +import org.springframework.security.authorization.AuthorityAuthorizationDecision; |
| 33 | +import org.springframework.security.authorization.AuthorizationManager; |
| 34 | +import org.springframework.security.authorization.AuthorizationResult; |
| 35 | +import org.springframework.security.config.Customizer; |
26 | 36 | import org.springframework.security.config.ObjectPostProcessor; |
27 | 37 | import org.springframework.security.config.annotation.SecurityContextChangedListenerConfig; |
28 | 38 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; |
|
31 | 41 | import org.springframework.security.config.test.SpringTestContext; |
32 | 42 | import org.springframework.security.config.test.SpringTestContextExtension; |
33 | 43 | import org.springframework.security.config.users.AuthenticationTestConfiguration; |
| 44 | +import org.springframework.security.core.Authentication; |
| 45 | +import org.springframework.security.core.GrantedAuthority; |
| 46 | +import org.springframework.security.core.authority.AuthorityUtils; |
34 | 47 | import org.springframework.security.core.context.SecurityContextChangedListener; |
35 | 48 | import org.springframework.security.core.context.SecurityContextHolderStrategy; |
36 | 49 | import org.springframework.security.core.userdetails.PasswordEncodedUser; |
| 50 | +import org.springframework.security.core.userdetails.UserDetails; |
37 | 51 | import org.springframework.security.core.userdetails.UserDetailsService; |
| 52 | +import org.springframework.security.crypto.password.NoOpPasswordEncoder; |
| 53 | +import org.springframework.security.crypto.password.PasswordEncoder; |
38 | 54 | import org.springframework.security.provisioning.InMemoryUserDetailsManager; |
39 | 55 | import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders; |
| 56 | +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; |
40 | 57 | import org.springframework.security.web.PortMapper; |
41 | 58 | import org.springframework.security.web.SecurityFilterChain; |
42 | 59 | import org.springframework.security.web.access.ExceptionTranslationFilter; |
43 | 60 | import org.springframework.security.web.authentication.AuthenticationFailureHandler; |
44 | 61 | import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; |
45 | 62 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; |
| 63 | +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; |
| 64 | +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; |
46 | 65 | import org.springframework.security.web.savedrequest.RequestCache; |
47 | 66 | import org.springframework.test.web.servlet.MockMvc; |
48 | 67 | import org.springframework.web.servlet.config.annotation.EnableWebMvc; |
49 | 68 |
|
| 69 | +import static org.hamcrest.Matchers.containsString; |
50 | 70 | import static org.mockito.ArgumentMatchers.any; |
51 | 71 | import static org.mockito.BDDMockito.given; |
52 | 72 | import static org.mockito.Mockito.atLeastOnce; |
|
60 | 80 | import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; |
61 | 81 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
62 | 82 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
| 83 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; |
63 | 84 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; |
64 | 85 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; |
65 | 86 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
@@ -378,6 +399,61 @@ public void configureWhenRegisteringObjectPostProcessorThenInvokedOnExceptionTra |
378 | 399 | verify(ObjectPostProcessorConfig.objectPostProcessor).postProcess(any(ExceptionTranslationFilter.class)); |
379 | 400 | } |
380 | 401 |
|
| 402 | + @Test |
| 403 | + void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { |
| 404 | + this.spring.register(MfaDslConfig.class).autowire(); |
| 405 | + UserDetails user = PasswordEncodedUser.user(); |
| 406 | + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) |
| 407 | + .andExpect(status().is3xxRedirection()) |
| 408 | + .andExpect(redirectedUrl("http://localhost/login")); |
| 409 | + this.mockMvc |
| 410 | + .perform(post("/ott/generate").param("username", "user") |
| 411 | + .with(SecurityMockMvcRequestPostProcessors.user(user)) |
| 412 | + .with(SecurityMockMvcRequestPostProcessors.csrf())) |
| 413 | + .andExpect(status().is3xxRedirection()) |
| 414 | + .andExpect(redirectedUrl("/ott/sent")); |
| 415 | + this.mockMvc |
| 416 | + .perform(post("/login").param("username", user.getUsername()) |
| 417 | + .param("password", user.getPassword()) |
| 418 | + .with(SecurityMockMvcRequestPostProcessors.csrf())) |
| 419 | + .andExpect(status().is3xxRedirection()) |
| 420 | + .andExpect(redirectedUrl("/")); |
| 421 | + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_OTT").build(); |
| 422 | + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) |
| 423 | + .andExpect(status().is3xxRedirection()) |
| 424 | + .andExpect(redirectedUrl("http://localhost/login")); |
| 425 | + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build(); |
| 426 | + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) |
| 427 | + .andExpect(status().isOk()) |
| 428 | + .andExpect(content().string(containsString("/ott/generate"))); |
| 429 | + user = PasswordEncodedUser.withUserDetails(user) |
| 430 | + .authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") |
| 431 | + .build(); |
| 432 | + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) |
| 433 | + .andExpect(status().isNotFound()); |
| 434 | + } |
| 435 | + |
| 436 | + @Test |
| 437 | + void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception { |
| 438 | + this.spring.register(MfaDslX509Config.class).autowire(); |
| 439 | + this.mockMvc.perform(get("/")).andExpect(status().isForbidden()); |
| 440 | + this.mockMvc.perform(get("/login")).andExpect(status().isOk()); |
| 441 | + this.mockMvc.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) |
| 442 | + .andExpect(status().is3xxRedirection()) |
| 443 | + .andExpect(redirectedUrl("http://localhost/login")); |
| 444 | + UserDetails user = PasswordEncodedUser.withUsername("rod") |
| 445 | + .password("password") |
| 446 | + .authorities("AUTHN_FORM") |
| 447 | + .build(); |
| 448 | + this.mockMvc |
| 449 | + .perform(post("/login").param("username", user.getUsername()) |
| 450 | + .param("password", user.getPassword()) |
| 451 | + .with(SecurityMockMvcRequestPostProcessors.x509("rod.cer")) |
| 452 | + .with(SecurityMockMvcRequestPostProcessors.csrf())) |
| 453 | + .andExpect(status().is3xxRedirection()) |
| 454 | + .andExpect(redirectedUrl("/")); |
| 455 | + } |
| 456 | + |
381 | 457 | @Configuration |
382 | 458 | @EnableWebSecurity |
383 | 459 | static class RequestCacheConfig { |
@@ -714,4 +790,90 @@ public <O> O postProcess(O object) { |
714 | 790 |
|
715 | 791 | } |
716 | 792 |
|
| 793 | + @Configuration |
| 794 | + @EnableWebSecurity |
| 795 | + static class MfaDslConfig { |
| 796 | + |
| 797 | + @Bean |
| 798 | + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { |
| 799 | + // @formatter:off |
| 800 | + http |
| 801 | + .formLogin(Customizer.withDefaults()) |
| 802 | + .oneTimeTokenLogin(Customizer.withDefaults()) |
| 803 | + .authorizeHttpRequests((authorize) -> authorize |
| 804 | + .requestMatchers("/profile").access( |
| 805 | + new HasAllAuthoritiesAuthorizationManager<>("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") |
| 806 | + ) |
| 807 | + .anyRequest().access(new HasAllAuthoritiesAuthorizationManager<>("FACTOR_PASSWORD", "FACTOR_OTT")) |
| 808 | + ); |
| 809 | + return http.build(); |
| 810 | + // @formatter:on |
| 811 | + } |
| 812 | + |
| 813 | + @Bean |
| 814 | + UserDetailsService users() { |
| 815 | + return new InMemoryUserDetailsManager(PasswordEncodedUser.user()); |
| 816 | + } |
| 817 | + |
| 818 | + @Bean |
| 819 | + PasswordEncoder encoder() { |
| 820 | + return NoOpPasswordEncoder.getInstance(); |
| 821 | + } |
| 822 | + |
| 823 | + @Bean |
| 824 | + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { |
| 825 | + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); |
| 826 | + } |
| 827 | + |
| 828 | + } |
| 829 | + |
| 830 | + @Configuration |
| 831 | + @EnableWebSecurity |
| 832 | + static class MfaDslX509Config { |
| 833 | + |
| 834 | + @Bean |
| 835 | + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { |
| 836 | + // @formatter:off |
| 837 | + http |
| 838 | + .formLogin(Customizer.withDefaults()) |
| 839 | + .x509(Customizer.withDefaults()) |
| 840 | + .authorizeHttpRequests((authorize) -> authorize |
| 841 | + .anyRequest().access( |
| 842 | + new HasAllAuthoritiesAuthorizationManager<>("FACTOR_X509", "FACTOR_PASSWORD") |
| 843 | + ) |
| 844 | + ); |
| 845 | + return http.build(); |
| 846 | + // @formatter:on |
| 847 | + } |
| 848 | + |
| 849 | + @Bean |
| 850 | + UserDetailsService users() { |
| 851 | + return new InMemoryUserDetailsManager( |
| 852 | + PasswordEncodedUser.withUsername("rod").password("{noop}password").build()); |
| 853 | + } |
| 854 | + |
| 855 | + } |
| 856 | + |
| 857 | + private static final class HasAllAuthoritiesAuthorizationManager<C> implements AuthorizationManager<C> { |
| 858 | + |
| 859 | + private final Collection<String> authorities; |
| 860 | + |
| 861 | + private HasAllAuthoritiesAuthorizationManager(String... authorities) { |
| 862 | + this.authorities = List.of(authorities); |
| 863 | + } |
| 864 | + |
| 865 | + @Override |
| 866 | + public @Nullable AuthorizationResult authorize(Supplier<Authentication> authentication, C object) { |
| 867 | + List<String> authorities = authentication.get() |
| 868 | + .getAuthorities() |
| 869 | + .stream() |
| 870 | + .map(GrantedAuthority::getAuthority) |
| 871 | + .toList(); |
| 872 | + List<String> needed = new ArrayList<>(this.authorities); |
| 873 | + needed.removeIf(authorities::contains); |
| 874 | + return new AuthorityAuthorizationDecision(needed.isEmpty(), AuthorityUtils.createAuthorityList(needed)); |
| 875 | + } |
| 876 | + |
| 877 | + } |
| 878 | + |
717 | 879 | } |
0 commit comments