|
|
@@ -16,6 +16,12 @@
|
|
|
|
|
|
package org.springframework.security.config.annotation.web.configurers;
|
|
|
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Collection;
|
|
|
+import java.util.List;
|
|
|
+import java.util.function.Supplier;
|
|
|
+
|
|
|
+import org.jspecify.annotations.Nullable;
|
|
|
import org.junit.jupiter.api.Test;
|
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
|
|
|
@@ -23,6 +29,10 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|
|
import org.springframework.context.annotation.Bean;
|
|
|
import org.springframework.context.annotation.Configuration;
|
|
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
|
+import org.springframework.security.authorization.AuthorityAuthorizationDecision;
|
|
|
+import org.springframework.security.authorization.AuthorizationManager;
|
|
|
+import org.springframework.security.authorization.AuthorizationResult;
|
|
|
+import org.springframework.security.config.Customizer;
|
|
|
import org.springframework.security.config.ObjectPostProcessor;
|
|
|
import org.springframework.security.config.annotation.SecurityContextChangedListenerConfig;
|
|
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
|
@@ -31,22 +41,32 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur
|
|
|
import org.springframework.security.config.test.SpringTestContext;
|
|
|
import org.springframework.security.config.test.SpringTestContextExtension;
|
|
|
import org.springframework.security.config.users.AuthenticationTestConfiguration;
|
|
|
+import org.springframework.security.core.Authentication;
|
|
|
+import org.springframework.security.core.GrantedAuthority;
|
|
|
+import org.springframework.security.core.authority.AuthorityUtils;
|
|
|
import org.springframework.security.core.context.SecurityContextChangedListener;
|
|
|
import org.springframework.security.core.context.SecurityContextHolderStrategy;
|
|
|
import org.springframework.security.core.userdetails.PasswordEncodedUser;
|
|
|
+import org.springframework.security.core.userdetails.UserDetails;
|
|
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
|
|
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
|
|
|
+import org.springframework.security.crypto.password.PasswordEncoder;
|
|
|
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
|
|
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders;
|
|
|
+import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
|
|
|
import org.springframework.security.web.PortMapper;
|
|
|
import org.springframework.security.web.SecurityFilterChain;
|
|
|
import org.springframework.security.web.access.ExceptionTranslationFilter;
|
|
|
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
|
|
|
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
|
|
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
|
|
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
|
|
|
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
|
|
|
import org.springframework.security.web.savedrequest.RequestCache;
|
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
|
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
|
|
|
|
|
+import static org.hamcrest.Matchers.containsString;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
|
import static org.mockito.BDDMockito.given;
|
|
|
import static org.mockito.Mockito.atLeastOnce;
|
|
|
@@ -60,6 +80,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMock
|
|
|
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
|
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
|
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
@@ -378,6 +399,61 @@ public class FormLoginConfigurerTests {
|
|
|
verify(ObjectPostProcessorConfig.objectPostProcessor).postProcess(any(ExceptionTranslationFilter.class));
|
|
|
}
|
|
|
|
|
|
+ @Test
|
|
|
+ void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception {
|
|
|
+ this.spring.register(MfaDslConfig.class).autowire();
|
|
|
+ UserDetails user = PasswordEncodedUser.user();
|
|
|
+ this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user)))
|
|
|
+ .andExpect(status().is3xxRedirection())
|
|
|
+ .andExpect(redirectedUrl("http://localhost/login"));
|
|
|
+ this.mockMvc
|
|
|
+ .perform(post("/ott/generate").param("username", "user")
|
|
|
+ .with(SecurityMockMvcRequestPostProcessors.user(user))
|
|
|
+ .with(SecurityMockMvcRequestPostProcessors.csrf()))
|
|
|
+ .andExpect(status().is3xxRedirection())
|
|
|
+ .andExpect(redirectedUrl("/ott/sent"));
|
|
|
+ this.mockMvc
|
|
|
+ .perform(post("/login").param("username", user.getUsername())
|
|
|
+ .param("password", user.getPassword())
|
|
|
+ .with(SecurityMockMvcRequestPostProcessors.csrf()))
|
|
|
+ .andExpect(status().is3xxRedirection())
|
|
|
+ .andExpect(redirectedUrl("/"));
|
|
|
+ user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_OTT").build();
|
|
|
+ this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user)))
|
|
|
+ .andExpect(status().is3xxRedirection())
|
|
|
+ .andExpect(redirectedUrl("http://localhost/login"));
|
|
|
+ user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build();
|
|
|
+ this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user)))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(content().string(containsString("/ott/generate")));
|
|
|
+ user = PasswordEncodedUser.withUserDetails(user)
|
|
|
+ .authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT")
|
|
|
+ .build();
|
|
|
+ this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user)))
|
|
|
+ .andExpect(status().isNotFound());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception {
|
|
|
+ this.spring.register(MfaDslX509Config.class).autowire();
|
|
|
+ this.mockMvc.perform(get("/")).andExpect(status().isForbidden());
|
|
|
+ this.mockMvc.perform(get("/login")).andExpect(status().isOk());
|
|
|
+ this.mockMvc.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer")))
|
|
|
+ .andExpect(status().is3xxRedirection())
|
|
|
+ .andExpect(redirectedUrl("http://localhost/login"));
|
|
|
+ UserDetails user = PasswordEncodedUser.withUsername("rod")
|
|
|
+ .password("password")
|
|
|
+ .authorities("AUTHN_FORM")
|
|
|
+ .build();
|
|
|
+ this.mockMvc
|
|
|
+ .perform(post("/login").param("username", user.getUsername())
|
|
|
+ .param("password", user.getPassword())
|
|
|
+ .with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))
|
|
|
+ .with(SecurityMockMvcRequestPostProcessors.csrf()))
|
|
|
+ .andExpect(status().is3xxRedirection())
|
|
|
+ .andExpect(redirectedUrl("/"));
|
|
|
+ }
|
|
|
+
|
|
|
@Configuration
|
|
|
@EnableWebSecurity
|
|
|
static class RequestCacheConfig {
|
|
|
@@ -714,4 +790,90 @@ public class FormLoginConfigurerTests {
|
|
|
|
|
|
}
|
|
|
|
|
|
+ @Configuration
|
|
|
+ @EnableWebSecurity
|
|
|
+ static class MfaDslConfig {
|
|
|
+
|
|
|
+ @Bean
|
|
|
+ SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
|
|
+ // @formatter:off
|
|
|
+ http
|
|
|
+ .formLogin(Customizer.withDefaults())
|
|
|
+ .oneTimeTokenLogin(Customizer.withDefaults())
|
|
|
+ .authorizeHttpRequests((authorize) -> authorize
|
|
|
+ .requestMatchers("/profile").access(
|
|
|
+ new HasAllAuthoritiesAuthorizationManager<>("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT")
|
|
|
+ )
|
|
|
+ .anyRequest().access(new HasAllAuthoritiesAuthorizationManager<>("FACTOR_PASSWORD", "FACTOR_OTT"))
|
|
|
+ );
|
|
|
+ return http.build();
|
|
|
+ // @formatter:on
|
|
|
+ }
|
|
|
+
|
|
|
+ @Bean
|
|
|
+ UserDetailsService users() {
|
|
|
+ return new InMemoryUserDetailsManager(PasswordEncodedUser.user());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Bean
|
|
|
+ PasswordEncoder encoder() {
|
|
|
+ return NoOpPasswordEncoder.getInstance();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Bean
|
|
|
+ OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
|
|
|
+ return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ @Configuration
|
|
|
+ @EnableWebSecurity
|
|
|
+ static class MfaDslX509Config {
|
|
|
+
|
|
|
+ @Bean
|
|
|
+ SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
|
|
+ // @formatter:off
|
|
|
+ http
|
|
|
+ .formLogin(Customizer.withDefaults())
|
|
|
+ .x509(Customizer.withDefaults())
|
|
|
+ .authorizeHttpRequests((authorize) -> authorize
|
|
|
+ .anyRequest().access(
|
|
|
+ new HasAllAuthoritiesAuthorizationManager<>("FACTOR_X509", "FACTOR_PASSWORD")
|
|
|
+ )
|
|
|
+ );
|
|
|
+ return http.build();
|
|
|
+ // @formatter:on
|
|
|
+ }
|
|
|
+
|
|
|
+ @Bean
|
|
|
+ UserDetailsService users() {
|
|
|
+ return new InMemoryUserDetailsManager(
|
|
|
+ PasswordEncodedUser.withUsername("rod").password("{noop}password").build());
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ private static final class HasAllAuthoritiesAuthorizationManager<C> implements AuthorizationManager<C> {
|
|
|
+
|
|
|
+ private final Collection<String> authorities;
|
|
|
+
|
|
|
+ private HasAllAuthoritiesAuthorizationManager(String... authorities) {
|
|
|
+ this.authorities = List.of(authorities);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public @Nullable AuthorizationResult authorize(Supplier<Authentication> authentication, C object) {
|
|
|
+ List<String> authorities = authentication.get()
|
|
|
+ .getAuthorities()
|
|
|
+ .stream()
|
|
|
+ .map(GrantedAuthority::getAuthority)
|
|
|
+ .toList();
|
|
|
+ List<String> needed = new ArrayList<>(this.authorities);
|
|
|
+ needed.removeIf(authorities::contains);
|
|
|
+ return new AuthorityAuthorizationDecision(needed.isEmpty(), AuthorityUtils.createAuthorityList(needed));
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
}
|