|
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