@ -16,6 +16,12 @@
@@ -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;
@@ -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
@@ -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
@@ -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 {
@@ -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 {
@@ -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 ) ) ;
}
}
}