diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurer.java index 43d913f54a..dd73b14bf6 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurer.java @@ -68,6 +68,7 @@ public final class DefaultLoginPageConfigurer> @Override public void init(H http) { + this.loginPageGeneratingFilter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy()); this.loginPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs); this.logoutPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs); http.setSharedObject(DefaultLoginPageGeneratingFilter.class, this.loginPageGeneratingFilter); diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java index 6b6a2f1f7c..dd093e8141 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java @@ -17,15 +17,18 @@ package org.springframework.security.config.annotation.web.configurers; import java.util.LinkedHashMap; +import java.util.function.Consumer; import org.jspecify.annotations.Nullable; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandlerImpl; +import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler; import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; @@ -77,6 +80,8 @@ public final class ExceptionHandlingConfigurer> private LinkedHashMap defaultDeniedHandlerMappings = new LinkedHashMap<>(); + private DelegatingMissingAuthorityAccessDeniedHandler.@Nullable Builder missingAuthoritiesHandlerBuilder; + /** * Creates a new instance * @see HttpSecurity#exceptionHandling(Customizer) @@ -127,6 +132,43 @@ public final class ExceptionHandlingConfigurer> return this; } + /** + * Sets a default {@link AuthenticationEntryPoint} to be used which prefers being + * invoked for the provided missing {@link GrantedAuthority}. + * @param entryPoint the {@link AuthenticationEntryPoint} to use for the given + * {@code authority} + * @param authority the authority + * @return the {@link ExceptionHandlingConfigurer} for further customizations + * @since 7.0 + */ + public ExceptionHandlingConfigurer defaultDeniedHandlerForMissingAuthority(AuthenticationEntryPoint entryPoint, + String authority) { + if (this.missingAuthoritiesHandlerBuilder == null) { + this.missingAuthoritiesHandlerBuilder = DelegatingMissingAuthorityAccessDeniedHandler.builder(); + } + this.missingAuthoritiesHandlerBuilder.addEntryPointFor(entryPoint, authority); + return this; + } + + /** + * Sets a default {@link AuthenticationEntryPoint} to be used which prefers being + * invoked for the provided missing {@link GrantedAuthority}. + * @param entryPoint a consumer of a + * {@link DelegatingAuthenticationEntryPoint.Builder} to use for the given + * {@code authority} + * @param authority the authority + * @return the {@link ExceptionHandlingConfigurer} for further customizations + * @since 7.0 + */ + public ExceptionHandlingConfigurer defaultDeniedHandlerForMissingAuthority( + Consumer entryPoint, String authority) { + if (this.missingAuthoritiesHandlerBuilder == null) { + this.missingAuthoritiesHandlerBuilder = DelegatingMissingAuthorityAccessDeniedHandler.builder(); + } + this.missingAuthoritiesHandlerBuilder.addEntryPointFor(entryPoint, authority); + return this; + } + /** * Sets the {@link AuthenticationEntryPoint} to be used. * @@ -229,6 +271,17 @@ public final class ExceptionHandlingConfigurer> } private AccessDeniedHandler createDefaultDeniedHandler(H http) { + AccessDeniedHandler defaults = createDefaultAccessDeniedHandler(http); + if (this.missingAuthoritiesHandlerBuilder == null) { + return defaults; + } + DelegatingMissingAuthorityAccessDeniedHandler deniedHandler = this.missingAuthoritiesHandlerBuilder.build(); + deniedHandler.setRequestCache(getRequestCache(http)); + deniedHandler.setDefaultAccessDeniedHandler(defaults); + return deniedHandler; + } + + private AccessDeniedHandler createDefaultAccessDeniedHandler(H http) { if (this.defaultDeniedHandlerMappings.isEmpty()) { return new AccessDeniedHandlerImpl(); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java index 03cf95b390..c78152454a 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java @@ -231,6 +231,13 @@ public final class FormLoginConfigurer> extends public void init(H http) throws Exception { super.init(http); initDefaultLoginFilter(http); + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint(); + RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http); + exceptions.defaultDeniedHandlerForMissingAuthority((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher), + "FACTOR_PASSWORD"); + } } @Override diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java index e2f2a8ec59..701fc415a6 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java @@ -192,8 +192,10 @@ public final class HttpBasicConfigurer> if (exceptionHandling == null) { return; } - exceptionHandling.defaultAuthenticationEntryPointFor(postProcess(this.authenticationEntryPoint), - preferredMatcher); + AuthenticationEntryPoint entryPoint = postProcess(this.authenticationEntryPoint); + exceptionHandling.defaultAuthenticationEntryPointFor(entryPoint, preferredMatcher); + exceptionHandling.defaultDeniedHandlerForMissingAuthority( + (ep) -> ep.addEntryPointFor(entryPoint, preferredMatcher), "FACTOR_PASSWORD"); } private void registerDefaultLogoutSuccessHandler(B http, RequestMatcher preferredMatcher) { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java index 7ec3279efb..39f2768d86 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java @@ -27,11 +27,14 @@ import org.springframework.http.converter.HttpMessageConverter; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultResourcesFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity; import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter; import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter; @@ -150,6 +153,16 @@ public class WebAuthnConfigurer> return this; } + @Override + public void init(H http) throws Exception { + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + AuthenticationEntryPoint entryPoint = new LoginUrlAuthenticationEntryPoint("/login"); + exceptions.defaultDeniedHandlerForMissingAuthority( + (ep) -> ep.addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE), "FACTOR_WEBAUTHN"); + } + } + @Override public void configure(H http) throws Exception { UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class) diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java index 79a2265962..868fef50ae 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java @@ -184,7 +184,9 @@ public final class X509Configurer> .setSharedObject(AuthenticationEntryPoint.class, new Http403ForbiddenEntryPoint()); ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); if (exceptions != null) { - exceptions.defaultAuthenticationEntryPointFor(new Http403ForbiddenEntryPoint(), AnyRequestMatcher.INSTANCE); + AuthenticationEntryPoint forbidden = new Http403ForbiddenEntryPoint(); + exceptions.defaultDeniedHandlerForMissingAuthority( + (ep) -> ep.addEntryPointFor(forbidden, AnyRequestMatcher.INSTANCE), "FACTOR_X509"); } } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java index 494f75109a..ecf695bcc0 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java @@ -40,6 +40,7 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer; import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer; import org.springframework.security.context.DelegatingApplicationListener; import org.springframework.security.core.Authentication; @@ -556,11 +557,18 @@ public final class OAuth2LoginConfigurer> RequestMatcher loginUrlMatcher = new AndRequestMatcher(notXRequestedWith, new NegatedRequestMatcher(defaultLoginPageMatcher), formLoginNotEnabled); // @formatter:off - return DelegatingAuthenticationEntryPoint.builder() + AuthenticationEntryPoint loginEntryPoint = DelegatingAuthenticationEntryPoint.builder() .addEntryPointFor(loginUrlEntryPoint, loginUrlMatcher) .defaultEntryPoint(getAuthenticationEntryPoint()) .build(); // @formatter:on + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http); + exceptions.defaultDeniedHandlerForMissingAuthority( + (ep) -> ep.addEntryPointFor(loginEntryPoint, requestMatcher), "FACTOR_AUTHORIZATION_CODE"); + } + return loginEntryPoint; } private RequestMatcher getFormLoginNotEnabledRequestMatcher(B http) { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java index 331730f1db..5c9b56198d 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java @@ -327,6 +327,8 @@ public final class OAuth2ResourceServerConfigurer ep.addEntryPointFor(this.authenticationEntryPoint, preferredMatcher), "FACTOR_BEARER"); } } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java index 4f01a17e5e..c193caf971 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java @@ -35,8 +35,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @@ -134,6 +136,13 @@ public final class OneTimeTokenLoginConfigurer> AuthenticationProvider authenticationProvider = getAuthenticationProvider(); http.authenticationProvider(postProcess(authenticationProvider)); intiDefaultLoginFilter(http); + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint(); + RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http); + exceptions.defaultDeniedHandlerForMissingAuthority((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher), + "FACTOR_OTT"); + } } private void intiDefaultLoginFilter(H http) { diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java index fbbb3f73a7..be783ef0f6 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java @@ -33,6 +33,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest; import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider; @@ -343,11 +344,18 @@ public final class Saml2LoginConfigurer> RequestMatcher loginUrlMatcher = new AndRequestMatcher(notXRequestedWith, new NegatedRequestMatcher(defaultLoginPageMatcher)); // @formatter:off - return DelegatingAuthenticationEntryPoint.builder() + AuthenticationEntryPoint loginEntryPoint = DelegatingAuthenticationEntryPoint.builder() .addEntryPointFor(loginUrlEntryPoint, loginUrlMatcher) .defaultEntryPoint(getAuthenticationEntryPoint()) .build(); // @formatter:on + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http); + exceptions.defaultDeniedHandlerForMissingAuthority( + (ep) -> ep.addEntryPointFor(loginEntryPoint, requestMatcher), "FACTOR_SAML_RESPONSE"); + } + return loginEntryPoint; } private void setAuthenticationRequestRepository(B http, diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java index cb8a6005b6..e2bd96ea93 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java @@ -22,9 +22,18 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authorization.AllAuthoritiesAuthorizationManager; +import org.springframework.security.authorization.AuthenticatedAuthorizationManager; +import org.springframework.security.authorization.AuthorityAuthorizationManager; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationManagers; +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.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; @@ -34,17 +43,25 @@ import org.springframework.security.config.users.AuthenticationTestConfiguration 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.User; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; 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.access.intercept.RequestAuthorizationContext; 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.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import static org.mockito.ArgumentMatchers.any; @@ -57,6 +74,7 @@ import static org.springframework.security.config.Customizer.withDefaults; import static org.springframework.security.config.annotation.SecurityContextChangedListenerArgumentMatchers.setAuthentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.logout; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; 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; @@ -378,6 +396,62 @@ public class FormLoginConfigurerTests { verify(ObjectPostProcessorConfig.objectPostProcessor).postProcess(any(ExceptionTranslationFilter.class)); } + @Test + void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { + this.spring.register(MfaDslConfig.class, UserConfig.class).autowire(); + UserDetails user = PasswordEncodedUser.user(); + this.mockMvc.perform(get("/profile").with(user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=password")); + this.mockMvc + .perform(post("/ott/generate").param("username", "rod") + .with(user(user)) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/ott/sent")); + this.mockMvc + .perform(post("/login").param("username", "rod") + .param("password", "password") + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")); + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_OTT").build(); + this.mockMvc.perform(get("/profile").with(user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=password")); + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build(); + this.mockMvc.perform(get("/profile").with(user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + user = PasswordEncodedUser.withUserDetails(user) + .authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") + .build(); + this.mockMvc.perform(get("/profile").with(user(user))).andExpect(status().isNotFound()); + } + + @Test + void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception { + this.spring.register(MfaDslX509Config.class, UserConfig.class, BasicMfaController.class).autowire(); + this.mockMvc.perform(get("/profile")).andExpect(status().is3xxRedirection()); + this.mockMvc.perform(get("/profile").with(user(User.withUsername("rod").authorities("profile:read").build()))) + .andExpect(status().isForbidden()); + this.mockMvc.perform(get("/login")).andExpect(status().isOk()); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=password")); + this.mockMvc + .perform(post("/login").param("username", "rod") + .param("password", "password") + .with(SecurityMockMvcRequestPostProcessors.x509("rod.cer")) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")); + UserDetails authorized = PasswordEncodedUser.withUsername("rod") + .authorities("profile:read", "FACTOR_X509", "FACTOR_PASSWORD") + .build(); + this.mockMvc.perform(get("/profile").with(user(authorized))).andExpect(status().isOk()); + } + @Configuration @EnableWebSecurity static class RequestCacheConfig { @@ -714,4 +788,107 @@ public class FormLoginConfigurerTests { } + @Configuration + @EnableWebSecurity + static class MfaDslConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http, + AuthorizationManagerFactory authz) throws Exception { + // @formatter:off + http + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()) + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/profile").access(authz.hasAuthority("profile:read")) + .anyRequest().access(authz.authenticated()) + ); + return http.build(); + // @formatter:on + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } + + @Bean + AuthorizationManagerFactory authz() { + return new AuthorizationManagerFactory<>("FACTOR_PASSWORD", "FACTOR_OTT"); + } + + } + + @Configuration + @EnableWebSecurity + @EnableMethodSecurity + static class MfaDslX509Config { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http, + AuthorizationManagerFactory authz) throws Exception { + // @formatter:off + http + .x509(Customizer.withDefaults()) + .formLogin(Customizer.withDefaults()) + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().access(authz.authenticated()) + ); + return http.build(); + // @formatter:on + } + + @Bean + AuthorizationManagerFactory authz() { + return new AuthorizationManagerFactory<>("FACTOR_X509", "FACTOR_PASSWORD"); + } + + } + + @Configuration + static class UserConfig { + + @Bean + UserDetails rod() { + return PasswordEncodedUser.withUsername("rod").password("password").build(); + } + + @Bean + UserDetailsService users(UserDetails user) { + return new InMemoryUserDetailsManager(user); + } + + } + + @RestController + static class BasicMfaController { + + @GetMapping("/profile") + @PreAuthorize("@authz.hasAuthority('profile:read')") + String profile() { + return "profile"; + } + + } + + public static class AuthorizationManagerFactory { + + private final AuthorizationManager authorities; + + AuthorizationManagerFactory(String... authorities) { + this.authorities = AllAuthoritiesAuthorizationManager.hasAllAuthorities(authorities); + } + + public AuthorizationManager authenticated() { + AuthenticatedAuthorizationManager authenticated = AuthenticatedAuthorizationManager.authenticated(); + return AuthorizationManagers.allOf(new AuthorizationDecision(false), this.authorities, authenticated); + } + + public AuthorizationManager hasAuthority(String authority) { + AuthorityAuthorizationManager authorized = AuthorityAuthorizationManager.hasAuthority(authority); + return AuthorizationManagers.allOf(new AuthorizationDecision(false), this.authorities, authorized); + } + + } + } diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index ca28e4700e..6fc89dfcf3 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -49,6 +49,7 @@ ***** xref:servlet/authentication/passwords/password-encoder.adoc[PasswordEncoder] ***** xref:servlet/authentication/passwords/dao-authentication-provider.adoc[DaoAuthenticationProvider] ***** xref:servlet/authentication/passwords/ldap.adoc[LDAP] +*** xref:servlet/authentication/adaptive.adoc[Multifactor Authentication] *** xref:servlet/authentication/persistence.adoc[Persistence] *** xref:servlet/authentication/passkeys.adoc[Passkeys] *** xref:servlet/authentication/onetimetoken.adoc[One-Time Token] diff --git a/docs/modules/ROOT/pages/servlet/authentication/adaptive.adoc b/docs/modules/ROOT/pages/servlet/authentication/adaptive.adoc new file mode 100644 index 0000000000..ef44b909c0 --- /dev/null +++ b/docs/modules/ROOT/pages/servlet/authentication/adaptive.adoc @@ -0,0 +1,101 @@ += Adaptive Authentication + +Since authentication needs can vary from person-to-person and even from one login attempt to the next, Spring Security supports adapting authentication requirements to each situation. + +Some of the most common applications of this principal are: + +1. *Re-authentication* - Users need to provide authentication again in order to enter an area of elevated security +2. *Multi-factor Authentication* - Users need more than one authentication mechanism to pass in order to access secured resources +3. *Authorizing More Scopes* - Users are allowed to consent to a subset of scopes from an OAuth 2.0 Authorization Server. +Then, if later on a scope that they did not grant is needed, consent can be re-requested for just that scope. +4. *Opting-in to Stronger Authentication Mechanisms* - Users may not be ready yet to start using MFA, but the application wants to allow the subset of security-minded users to opt-in. +5. *Requiring Additional Steps for Suspicious Logins* - The application may notice that the user's IP address has changed, that they are behind a VPN, or some other consideration that requires additional verification + +[[re-authentication]] +== Re-authentication + +The most common of these is re-authentication. +Imagine an application configured in the following way: + +include-code::./SimpleConfiguration[tag=httpSecurity,indent=0] + +By default, this application has two authentication mechanisms that it allows, meaning that the user could use either one and be fully-authenticated. + +If there is a set of endpoints that require a specific factor, we can specify that in `authorizeHttpRequests` as follows: + +include-code::./RequireOttConfiguration[tag=httpSecurity,indent=0] +<1> - States that all `/profile/**` endpoints require one-time-token login to be authorized + +Given the above configuration, users can log in with any mechanism that you support. +And, if they want to visit the profile page, then Spring Security will redirect them to the One-Time-Token Login page to obtain it. + +In this way, the authority given to a user is directly proportional to the amount of proof given. +This adaptive approach allows users to give only the proof needed to perform their intended operations. + +[[multi-factor-authentication]] +== Multi-Factor Authentication + +You may require that all users require both One-Time-Token login and Username/Password login to access any part of your site. + +To require both, you can state an authorization rule with `anyRequest` like so: + +include-code::./ListAuthoritiesConfiguration[tag=httpSecurity,indent=0] +<1> - This states that both `FACTOR_PASSWORD` and `FACTOR_OTT` are needed to use any part of the application + +Spring Security behind the scenes knows which endpoint to go to depending on which authority is missing. +If the user logged in initially with their username and password, then Spring Security redirects to the One-Time-Token Login page. +If the user logged in initially with a token, then Spring Security redirects to the Username/Password Login page. + +[[authorization-manager-factory]] +=== Requiring MFA For All Endpoints + +Specifying all authorities for each request pattern could be unwanted boilerplate: + +include-code::./ListAuthoritiesEverywhereConfiguration[tag=httpSecurity,indent=0] +<1> - Since all authorities need to be specified for each endpoint, deploying MFA in this way can create unwanted boilerplate + +This can be remedied by publishing an `AuthorizationManagerFactory` bean like so: + +include-code::./UseAuthorizationManagerFactoryConfiguration[tag=authorizationManagerFactoryBean,indent=0] + +This yields a more familiar configuration: + +include-code::./UseAuthorizationManagerFactoryConfiguration[tag=httpSecurity,indent=0] + +[[obtaining-more-authorization]] +== Authorizing More Scopes + +You can also configure exception handling to direct Spring Security on how to obtain a missing scope. + +Consider an application that requires a specific OAuth 2.0 scope for a given endpoint: + +include-code::./ScopeConfiguration[tag=httpSecurity,indent=0] + +If this is also configured with an `AuthorizationManagerFactory` bean like this one: + +include-code::./MissingAuthorityConfiguration[tag=authorizationManagerFactoryBean,indent=0] + +Then the application will require an X.509 certificate as well as authorization from an OAuth 2.0 authorization server. + +In the event that the user does not consent to `profile:read`, this application as it stands will issue a 403. +However, if you have a way for the application to re-ask for consent, then you can implement this in an `AuthenticationEntryPoint` like the following: + +include-code::./MissingAuthorityConfiguration[tag=authenticationEntryPoint,indent=0] + +Then, your filter chain declaration can bind this entry point to the given authority like so: + +include-code::./MissingAuthorityConfiguration[tag=httpSecurity,indent=0] + +[[custom-authorization-manager-factory]] +== Programmatically Decide Which Authorities Are Required + +`AuthorizationManager` is the core interface for making authorization decisions. +Consider an authorization manager that looks at the logged in user to decide which factors are necessary: + +include-code::./CustomAuthorizationManagerFactory[tag=authorizationManager,indent=0] + +In this case, using One-Time-Token is only required for those who have opted in. + +This can then be enforced by a custom `AuthorizationManagerFactory` implementation: + +include-code::./CustomAuthorizationManagerFactory[tag=authorizationManagerFactory,indent=0] diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 3958db00af..59f8926dfa 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -15,6 +15,7 @@ Each section that follows will indicate the more notable removals as well as the == Core +* Added Support for xref:servlet/authentication/adaptive.adoc[Multi-factor Authentication] * Removed `AuthorizationManager#check` in favor of `AuthorizationManager#authorize` * Added javadoc:org.springframework.security.authorization.AllAuthoritiesAuthorizationManager[] and javadoc:org.springframework.security.authorization.AllAuthoritiesReactiveAuthorizationManager[] along with corresponding methods for xref:servlet/authorization/authorize-http-requests.adoc#authorize-requests[Authorizing `HttpServletRequests`] and xref:servlet/authorization/method-security.adoc#using-authorization-expression-fields-and-methods[method security expressions]. * Added xref:servlet/authorization/architecture.adoc#authz-authorization-manager-factory[`AuthorizationManagerFactory`] for creating `AuthorizationManager` instances in xref:servlet/authorization/authorize-http-requests.adoc#customizing-authorization-managers[request-based] and xref:servlet/authorization/method-security.adoc#customizing-authorization-managers[method-based] authorization components diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java new file mode 100644 index 0000000000..97899bc163 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.docs.servlet.authentication.authorizationmanagerfactory; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +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.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests {@link CustomX509Configuration}. + * + * @author Rob Winch + */ +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +@TestExecutionListeners(WithSecurityContextTestExecutionListener.class) +public class AuthorizationManagerFactoryTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + MockMvc mockMvc; + + @Test + @WithMockUser(authorities = { "FACTOR_PASSWORD", "FACTOR_OTT" }) + void getWhenAuthenticatedWithPasswordAndOttThenPermits() throws Exception { + this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("user")); + // @formatter:on + } + + @Test + @WithMockUser(authorities = "FACTOR_PASSWORD") + void getWhenAuthenticatedWithPasswordThenRedirectsToOtt() throws Exception { + this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + // @formatter:on + } + + @Test + @WithMockUser(authorities = "FACTOR_OTT") + void getWhenAuthenticatedWithOttThenRedirectsToPassword() throws Exception { + this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=password")); + // @formatter:on + } + + @Test + @WithMockUser + void getWhenAuthenticatedThenRedirectsToPassword() throws Exception { + this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=password")); + // @formatter:on + } + + @Test + void getWhenUnauthenticatedThenRedirectsToBoth() throws Exception { + this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + // @formatter:on + } + + @RestController + static class Http200Controller { + @GetMapping("/**") + String ok() { + return "ok"; + } + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.java new file mode 100644 index 0000000000..7c5728d807 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.java @@ -0,0 +1,54 @@ +package org.springframework.security.docs.servlet.authentication.authorizationmanagerfactory; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; + +import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasAuthority; +import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasRole; +import static org.springframework.security.authorization.AuthorizationManagers.allOf; + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class ListAuthoritiesEverywhereConfiguration { + + // tag::httpSecurity[] + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/admin/**").access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"), hasRole("ADMIN"))) // <1> + .anyRequest().access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"))) + ) + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + // end::httpSecurity[] + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.java new file mode 100644 index 0000000000..0418e87c89 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.java @@ -0,0 +1,60 @@ +package org.springframework.security.docs.servlet.authentication.authorizationmanagerfactory; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorizationManagerFactory; +import org.springframework.security.authorization.DefaultAuthorizationManagerFactory; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class UseAuthorizationManagerFactoryConfiguration { + + // tag::httpSecurity[] + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + // end::httpSecurity[] + + // tag::authorizationManagerFactoryBean[] + @Bean + AuthorizationManagerFactory authz() { + return DefaultAuthorizationManagerFactory.builder() + .requireAdditionalAuthorities("FACTOR_PASSWORD", "FACTOR_OTT").build(); + } + // end::authorizationManagerFactoryBean[] + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.java new file mode 100644 index 0000000000..f00cea15fb --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.java @@ -0,0 +1,103 @@ +package org.springframework.security.docs.servlet.authentication.customauthorizationmanagerfactory; + +import java.util.Collection; +import java.util.function.Supplier; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.expression.SecurityExpressionOperations; +import org.springframework.security.access.expression.SecurityExpressionRoot; +import org.springframework.security.authorization.AuthorityAuthorizationDecision; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationManagerFactory; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.authorization.DefaultAuthorizationManagerFactory; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; +import org.springframework.stereotype.Component; + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class CustomAuthorizationManagerFactory { + // tag::httpSecurity[] + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + // end::httpSecurity[] + + // tag::authorizationManager[] + @Component + class OptInToMfaAuthorizationManager implements AuthorizationManager { + @Override + public AuthorizationResult authorize(Supplier authentication, Object context) { + MyPrincipal principal = (MyPrincipal) authentication.get().getPrincipal(); + if (principal.optedIn()) { + SecurityExpressionOperations sec = new SecurityExpressionRoot<>(authentication, context) {}; + return new AuthorityAuthorizationDecision(sec.hasAuthority("FACTOR_OTT"), + AuthorityUtils.createAuthorityList("FACTOR_OTT")); + } + return new AuthorizationDecision(true); + } + } + // end::authorizationManager[] + + // tag::authorizationManagerFactory[] + @Bean + AuthorizationManagerFactory authorizationManagerFactory(OptInToMfaAuthorizationManager optIn) { + DefaultAuthorizationManagerFactory defaults = new DefaultAuthorizationManagerFactory<>(); + defaults.setAdditionalAuthorization(optIn); + return defaults; + } + // end::authorizationManagerFactory[] + + @NullMarked + record MyPrincipal(String username, boolean optedIn) implements UserDetails { + @Override + public Collection getAuthorities() { + return AuthorityUtils.createAuthorityList("app"); + } + + @Override + public @Nullable String getPassword() { + return null; + } + + @Override + public String getUsername() { + return this.username; + } + } + + @Bean + UserDetailsService users() { + return (username) -> new MyPrincipal(username, username.equals("optedin")); + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java new file mode 100644 index 0000000000..f62cfeedff --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java @@ -0,0 +1,97 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.docs.servlet.authentication.customauthorizationmanagerfactory; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +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.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests {@link CustomX509Configuration}. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension.class) +public class CustomAuthorizationManagerFactoryTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + MockMvc mockMvc; + + @Autowired + UserDetailsService users; + + @Test + void getWhenOptedInThenRedirectsToOtt() throws Exception { + this.spring.register(CustomAuthorizationManagerFactory.class, Http200Controller.class).autowire(); + UserDetails user = this.users.loadUserByUsername("optedin"); + // @formatter:off + this.mockMvc.perform(get("/").with(user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + // @formatter:on + } + + @Test + void getWhenNotOptedInThenAllows() throws Exception { + this.spring.register(CustomAuthorizationManagerFactory.class, Http200Controller.class).autowire(); + UserDetails user = this.users.loadUserByUsername("user"); + // @formatter:off + this.mockMvc.perform(get("/").with(user(user))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("user")); + // @formatter:on + } + + @Test + void getWhenOptedAndHasFactorThenAllows() throws Exception { + this.spring.register(CustomAuthorizationManagerFactory.class, Http200Controller.class).autowire(); + UserDetails user = this.users.loadUserByUsername("optedin"); + TestingAuthenticationToken token = new TestingAuthenticationToken(user, "", "FACTOR_OTT"); + // @formatter:off + this.mockMvc.perform(get("/").with(authentication(token))) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("optedin")); + // @formatter:on + } + + @RestController + static class Http200Controller { + @GetMapping("/**") + String ok() { + return "ok"; + } + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.java new file mode 100644 index 0000000000..f6a003c874 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.java @@ -0,0 +1,52 @@ +package org.springframework.security.docs.servlet.authentication.multifactorauthentication; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; + +import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasAuthority; +import static org.springframework.security.authorization.AuthorizationManagers.allOf; + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class ListAuthoritiesConfiguration { + + // tag::httpSecurity[] + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"))) // <1> + ) + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + // end::httpSecurity[] + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java new file mode 100644 index 0000000000..33e319622d --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java @@ -0,0 +1,114 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.docs.servlet.authentication.multifactorauthentication; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +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.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests {@link CustomX509Configuration}. + * + * @author Rob Winch + */ +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +@TestExecutionListeners(WithSecurityContextTestExecutionListener.class) +public class MultiFactorAuthenticationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + MockMvc mockMvc; + + @Test + @WithMockUser(authorities = { "FACTOR_PASSWORD", "FACTOR_OTT" }) + void getWhenAuthenticatedWithPasswordAndOttThenPermits() throws Exception { + this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("user")); + // @formatter:on + } + + @Test + @WithMockUser(authorities = "FACTOR_PASSWORD") + void getWhenAuthenticatedWithPasswordThenRedirectsToOtt() throws Exception { + this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + // @formatter:on + } + + @Test + @WithMockUser(authorities = "FACTOR_OTT") + void getWhenAuthenticatedWithOttThenRedirectsToPassword() throws Exception { + this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=password")); + // @formatter:on + } + + @Test + @WithMockUser + void getWhenAuthenticatedThenRedirectsToPassword() throws Exception { + this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=password")); + // @formatter:on + } + + @Test + void getWhenUnauthenticatedThenRedirectsToBoth() throws Exception { + this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + // @formatter:on + } + + @RestController + static class Http200Controller { + @GetMapping("/**") + String ok() { + return "ok"; + } + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.java new file mode 100644 index 0000000000..4ccc0c2895 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.java @@ -0,0 +1,147 @@ +package org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationManagerFactory; +import org.springframework.security.authorization.DefaultAuthorizationManagerFactory; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.stereotype.Component; + +import static org.springframework.security.authorization.AllAuthoritiesAuthorizationManager.hasAllAuthorities; +import static org.springframework.security.authorization.AuthorizationManagers.allOf; + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class MissingAuthorityConfiguration { + + // tag::httpSecurity[] + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, ScopeRetrievingAuthenticationEntryPoint oauth2) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/profile/**").hasAuthority("SCOPE_profile:read") + .anyRequest().authenticated() + ) + .x509(Customizer.withDefaults()) + .oauth2Login(Customizer.withDefaults()) + .exceptionHandling((exceptions) -> exceptions + .defaultDeniedHandlerForMissingAuthority(oauth2, "SCOPE_profile:read") + ); + // @formatter:on + return http.build(); + } + // end::httpSecurity[] + + // tag::authorizationManagerFactoryBean[] + @Bean + AuthorizationManagerFactory authz() { + return new FactorAuthorizationManagerFactory(hasAllAuthorities("FACTOR_X509", "FACTOR_AUTHORIZATION_CODE")); + } + // end::authorizationManagerFactoryBean[] + + // tag::authorizationManagerFactory[] + class FactorAuthorizationManagerFactory implements AuthorizationManagerFactory { + private final AuthorizationManager hasAuthorities; + private final DefaultAuthorizationManagerFactory delegate = + new DefaultAuthorizationManagerFactory<>(); + + FactorAuthorizationManagerFactory(AuthorizationManager hasAuthorities) { + this.hasAuthorities = hasAuthorities; + } + + @Override + public AuthorizationManager permitAll() { + return this.delegate.permitAll(); + } + + @Override + public AuthorizationManager denyAll() { + return this.delegate.denyAll(); + } + + @Override + public AuthorizationManager hasRole(String role) { + return hasAnyRole(role); + } + + @Override + public AuthorizationManager hasAnyRole(String... roles) { + return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAnyRole(roles)); + } + + @Override + public AuthorizationManager hasAllRoles(String... roles) { + return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAllRoles(roles)); + } + + @Override + public AuthorizationManager hasAuthority(String authority) { + return hasAnyAuthority(authority); + } + + @Override + public AuthorizationManager hasAnyAuthority(String... authorities) { + return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAnyAuthority(authorities)); + } + + @Override + public AuthorizationManager hasAllAuthorities(String... authorities) { + return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAllAuthorities(authorities)); + } + + @Override + public AuthorizationManager authenticated() { + return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.authenticated()); + } + + @Override + public AuthorizationManager fullyAuthenticated() { + return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.fullyAuthenticated()); + } + + @Override + public AuthorizationManager rememberMe() { + return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.rememberMe()); + } + + @Override + public AuthorizationManager anonymous() { + return this.delegate.anonymous(); + } + } + // end::authorizationManagerFactory[] + + // tag::authenticationEntryPoint[] + @Component + class ScopeRetrievingAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) + throws IOException, ServletException { + response.sendRedirect("https://authz.example.org/authorize?scope=profile:read"); + } + } + // end::authenticationEntryPoint[] + + @Bean + ClientRegistrationRepository clients() { + return new InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build()); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.java new file mode 100644 index 0000000000..f91fa8b2ed --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +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.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests {@link CustomX509Configuration}. + * + * @author Rob Winch + */ +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +@TestExecutionListeners(WithSecurityContextTestExecutionListener.class) +public class ObtainingMoreAuthorizationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + MockMvc mockMvc; + + @Test + @WithMockUser + void profileWhenScopeConfigurationThenDenies() throws Exception { + this.spring.register(ScopeConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/profile")) + .andExpect(status().isForbidden()); + // @formatter:on + } + + @Test + @WithMockUser(authorities = { "FACTOR_X509", "FACTOR_AUTHORIZATION_CODE" }) + void profileWhenMissingAuthorityConfigurationThenRedirectsToAuthorizationServer() throws Exception { + this.spring.register(MissingAuthorityConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/profile")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("https://authz.example.org/authorize?scope=profile:read")); + // @formatter:on + } + + @Test + @WithMockUser(authorities = { "SCOPE_profile:read" }) + void profileWhenMissingX509WithOttThenForbidden() throws Exception { + this.spring.register(MissingAuthorityConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/profile")) + .andExpect(status().isForbidden()); + // @formatter:on + } + + @Test + @WithMockUser(authorities = { "FACTOR_X509", "FACTOR_AUTHORIZATION_CODE", "SCOPE_profile:read" }) + void profileWhenAuthenticatedAndHasScopeThenPermits() throws Exception { + this.spring.register(MissingAuthorityConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/profile")) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("user")); + // @formatter:on + } + + @RestController + static class Http200Controller { + @GetMapping("/**") + String ok() { + return "ok"; + } + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.java new file mode 100644 index 0000000000..ce1e660ed1 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.java @@ -0,0 +1,37 @@ +package org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.TestClientRegistrations; +import org.springframework.security.web.SecurityFilterChain; + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class ScopeConfiguration { + + // tag::httpSecurity[] + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/profile/**").hasAuthority("SCOPE_profile:read") + .anyRequest().authenticated() + ) + .x509(Customizer.withDefaults()) + .oauth2Login(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + // end::httpSecurity[] + + @Bean + ClientRegistrationRepository clients() { + return new InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build()); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java new file mode 100644 index 0000000000..7078eac0f2 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java @@ -0,0 +1,93 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.docs.servlet.authentication.reauthentication; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener; +import org.springframework.test.context.TestExecutionListeners; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +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.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests {@link CustomX509Configuration}. + * + * @author Rob Winch + */ +@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class }) +@TestExecutionListeners(WithSecurityContextTestExecutionListener.class) +public class ReauthenticationTests { + + public final SpringTestContext spring = new SpringTestContext(this); + + @Autowired + MockMvc mockMvc; + + @Test + @WithMockUser + void formLoginWhenSimpleConfigurationThenPermits() throws Exception { + this.spring.register(SimpleConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("user")); + // @formatter:on + } + + @Test + @WithMockUser + void formLoginWhenRequireOttConfigurationThenRedirectsToOtt() throws Exception { + this.spring.register(RequireOttConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/profile")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + // @formatter:on + } + + @Test + @WithMockUser(authorities = "FACTOR_OTT") + void ottWhenRequireOttConfigurationThenAllows() throws Exception { + this.spring.register(RequireOttConfiguration.class, Http200Controller.class).autowire(); + // @formatter:off + this.mockMvc.perform(get("/profile")) + .andExpect(status().isOk()) + .andExpect(authenticated().withUsername("user")); + // @formatter:on + } + + @RestController + static class Http200Controller { + @GetMapping("/**") + String ok() { + return "ok"; + } + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/RequireOttConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/RequireOttConfiguration.java new file mode 100644 index 0000000000..af23bc19f0 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/RequireOttConfiguration.java @@ -0,0 +1,50 @@ +package org.springframework.security.docs.servlet.authentication.reauthentication; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class RequireOttConfiguration { + + // tag::httpSecurity[] + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/profile/**").hasAuthority("FACTOR_OTT") // <1> + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + // end::httpSecurity[] + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } +} diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/SimpleConfiguration.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/SimpleConfiguration.java new file mode 100644 index 0000000000..7e667e1f60 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/SimpleConfiguration.java @@ -0,0 +1,46 @@ +package org.springframework.security.docs.servlet.authentication.reauthentication; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +public class SimpleConfiguration { + // tag::httpSecurity[] + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + // end::httpSecurity[] + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ); + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt new file mode 100644 index 0000000000..23080f1cea --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.kt.docs.servlet.authentication.authorizationmanagerfactory + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener +import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers +import org.springframework.test.context.TestExecutionListeners +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Tests [CustomX509Configuration]. + * + * @author Rob Winch + */ +@ExtendWith(SpringExtension::class, SpringTestContextExtension::class) +@TestExecutionListeners(WithSecurityContextTestExecutionListener::class) +class AuthorizationManagerFactoryTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this) + + @Autowired + var mockMvc: MockMvc? = null + + @Test + @WithMockUser(authorities = ["FACTOR_PASSWORD", "FACTOR_OTT"]) + @Throws(Exception::class) + fun getWhenAuthenticatedWithPasswordAndOttThenPermits() { + this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java) + .autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user")) + // @formatter:on + } + + @Test + @WithMockUser(authorities = ["FACTOR_PASSWORD"]) + @Throws(Exception::class) + fun getWhenAuthenticatedWithPasswordThenRedirectsToOtt() { + this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java) + .autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott")) + // @formatter:on + } + + @Test + @WithMockUser(authorities = ["FACTOR_OTT"]) + @Throws(Exception::class) + fun getWhenAuthenticatedWithOttThenRedirectsToPassword() { + this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java) + .autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + // @formatter:on + } + + @Test + @WithMockUser + @Throws(Exception::class) + fun getWhenAuthenticatedThenRedirectsToPassword() { + this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java) + .autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + // @formatter:on + } + + @Test + @Throws(Exception::class) + fun getWhenUnauthenticatedThenRedirectsToBoth() { + this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java) + .autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login")) + // @formatter:on + } + + @RestController + internal class Http200Controller { + @GetMapping("/**") + fun ok(): String { + return "ok" + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.kt new file mode 100644 index 0000000000..33e7467755 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.kt @@ -0,0 +1,53 @@ +package org.springframework.security.kt.docs.servlet.authentication.authorizationmanagerfactory + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class ListAuthoritiesEverywhereConfiguration { + + // tag::httpSecurity[] + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/admin/**", hasAllAuthorities("FACTOR_PASSWORD", "FACTOR_OTT", "ROLE_ADMIN")) // <1> + authorize(anyRequest, hasAllAuthorities("FACTOR_PASSWORD", "FACTOR_OTT")) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() + } + // end::httpSecurity[] + + + // end::httpSecurity[] + @Bean + fun userDetailsService(): UserDetailsService { + return InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ) + } + + @Bean + fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler { + return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent") + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.kt new file mode 100644 index 0000000000..20f2ca373a --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.kt @@ -0,0 +1,60 @@ +package org.springframework.security.kt.docs.servlet.authentication.authorizationmanagerfactory + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authorization.AuthorizationManagerFactory +import org.springframework.security.authorization.DefaultAuthorizationManagerFactory +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +internal class UseAuthorizationManagerFactoryConfiguration { + // tag::httpSecurity[] + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/admin/**", hasRole("ADMIN")) + authorize(anyRequest, authenticated) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() + } + // end::httpSecurity[] + + // tag::authorizationManagerFactoryBean[] + @Bean + fun authz(): AuthorizationManagerFactory { + return DefaultAuthorizationManagerFactory.builder() + .requireAdditionalAuthorities("FACTOR_PASSWORD", "FACTOR_OTT").build() + } + // end::authorizationManagerFactoryBean[] + + @Bean + fun userDetailsService(): UserDetailsService { + return InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ) + } + + @Bean + fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler { + return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent") + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.kt new file mode 100644 index 0000000000..16f6415d7b --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.kt @@ -0,0 +1,95 @@ +package org.springframework.security.kt.docs.servlet.authentication.customauthorizationmanagerfactory + +import org.jspecify.annotations.NullMarked +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.access.expression.SecurityExpressionRoot +import org.springframework.security.authorization.* +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.Authentication +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.AuthorityUtils +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler +import org.springframework.stereotype.Component +import java.util.function.Supplier + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +internal class CustomAuthorizationManagerFactory { + + // tag::httpSecurity[] + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/admin/**", hasRole("ADMIN")) + authorize(anyRequest, authenticated) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() + } + // end::httpSecurity[] + + // tag::authorizationManager[] + @Component + internal open class OptInToMfaAuthorizationManager : AuthorizationManager { + override fun authorize( + authentication: Supplier, context: Object): AuthorizationResult { + val principal = authentication.get().getPrincipal() as MyPrincipal? + if (principal!!.optedIn) { + val root = object : SecurityExpressionRoot(authentication, context) { } + return AuthorityAuthorizationDecision( + root.hasAuthority("FACTOR_OTT"), + AuthorityUtils.createAuthorityList("FACTOR_OTT") + ) + } + return AuthorizationDecision(true) + } + } + // end::authorizationManager[] + + // tag::authorizationManagerFactory[] + @Bean + fun authorizationManagerFactory(optIn: OptInToMfaAuthorizationManager?): AuthorizationManagerFactory { + val defaults = DefaultAuthorizationManagerFactory() + defaults.setAdditionalAuthorization(optIn) + return defaults + } + // end::authorizationManagerFactory[] + + @NullMarked + class MyPrincipal(val user: String, val optedIn: Boolean) : UserDetails { + override fun getAuthorities(): MutableCollection { + return AuthorityUtils.createAuthorityList("app") + } + + override fun getPassword(): String? { + return null + } + + override fun getUsername(): String { + return this.user + } + + } + + @Bean + fun users(): UserDetailsService { + return UserDetailsService { username: String? -> MyPrincipal(username!!, username == "optedin") } + } + + @Bean + fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler { + return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent") + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt new file mode 100644 index 0000000000..b55dae1c90 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.kt.docs.servlet.authentication.customauthorizationmanagerfactory + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.authentication.TestingAuthenticationToken +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors +import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Tests [CustomX509Configuration]. + * + * @author Rob Winch + */ +@ExtendWith(SpringTestContextExtension::class) +class CustomAuthorizationManagerFactoryTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this) + + @Autowired + var mockMvc: MockMvc? = null + + @Autowired + var users: UserDetailsService? = null + + @Test + @Throws(Exception::class) + fun getWhenOptedInThenRedirectsToOtt() { + this.spring.register(CustomAuthorizationManagerFactory::class.java, Http200Controller::class.java).autowire() + val user = this.users!!.loadUserByUsername("optedin") + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott")) + // @formatter:on + } + + @Test + @Throws(Exception::class) + fun getWhenNotOptedInThenAllows() { + this.spring.register(CustomAuthorizationManagerFactory::class.java, Http200Controller::class.java).autowire() + val user = this.users!!.loadUserByUsername("user") + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user")) + // @formatter:on + } + + @Test + @Throws(Exception::class) + fun getWhenOptedAndHasFactorThenAllows() { + this.spring.register(CustomAuthorizationManagerFactory::class.java, Http200Controller::class.java).autowire() + val user = this.users!!.loadUserByUsername("optedin") + val token = TestingAuthenticationToken(user, "", "FACTOR_OTT") + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.authentication(token))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("optedin")) + // @formatter:on + } + + @RestController + internal class Http200Controller { + @GetMapping("/**") + fun ok(): String { + return "ok" + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.kt new file mode 100644 index 0000000000..79e40e8c73 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.kt @@ -0,0 +1,52 @@ +package org.springframework.security.kt.docs.servlet.authentication.multifactorauthentication + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +internal class ListAuthoritiesConfiguration { + + // tag::httpSecurity[] + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, hasAllAuthorities("FACTOR_PASSWORD", "FACTOR_OTT")) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() + } + // end::httpSecurity[] + + + // end::httpSecurity[] + @Bean + fun userDetailsService(): UserDetailsService { + return InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ) + } + + @Bean + fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler { + return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent") + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt new file mode 100644 index 0000000000..748b6e050a --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.kt.docs.servlet.authentication.multifactorauthentication + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener +import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers +import org.springframework.test.context.TestExecutionListeners +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Tests [CustomX509Configuration]. + * + * @author Rob Winch + */ +@ExtendWith(SpringExtension::class, SpringTestContextExtension::class) +@TestExecutionListeners(WithSecurityContextTestExecutionListener::class) +class MultiFactorAuthenticationTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this) + + @Autowired + var mockMvc: MockMvc? = null + + @Test + @WithMockUser(authorities = ["FACTOR_PASSWORD", "FACTOR_OTT"]) + @Throws(Exception::class) + fun getWhenAuthenticatedWithPasswordAndOttThenPermits() { + this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user")) + // @formatter:on + } + + @Test + @WithMockUser(authorities = ["FACTOR_PASSWORD"]) + @Throws(Exception::class) + fun getWhenAuthenticatedWithPasswordThenRedirectsToOtt() { + this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott")) + // @formatter:on + } + + @Test + @WithMockUser(authorities = ["FACTOR_OTT"]) + @Throws(Exception::class) + fun getWhenAuthenticatedWithOttThenRedirectsToPassword() { + this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + // @formatter:on + } + + @Test + @WithMockUser + @Throws(Exception::class) + fun getWhenAuthenticatedThenRedirectsToPassword() { + this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + // @formatter:on + } + + @Test + @Throws(Exception::class) + fun getWhenUnauthenticatedThenRedirectsToBoth() { + this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login")) + // @formatter:on + } + + @RestController + internal class Http200Controller { + @GetMapping("/**") + fun ok(): String { + return "ok" + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.kt new file mode 100644 index 0000000000..1e4d7431c6 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.kt @@ -0,0 +1,129 @@ +package org.springframework.security.kt.docs.servlet.authentication.obtainingmoreauthorization + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.authorization.AllAuthoritiesAuthorizationManager.hasAllAuthorities +import org.springframework.security.authorization.AuthorizationDecision +import org.springframework.security.authorization.AuthorizationManager +import org.springframework.security.authorization.AuthorizationManagerFactory +import org.springframework.security.authorization.AuthorizationManagers.allOf +import org.springframework.security.authorization.DefaultAuthorizationManagerFactory +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.AuthenticationException +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.TestClientRegistrations +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.DefaultSecurityFilterChain +import org.springframework.security.web.access.intercept.RequestAuthorizationContext +import org.springframework.stereotype.Component + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +internal class MissingAuthorityConfiguration { + + // tag::httpSecurity[] + @Bean + fun securityFilterChain(http: HttpSecurity, oauth2: ScopeRetrievingAuthenticationEntryPoint): DefaultSecurityFilterChain? { + http { + authorizeHttpRequests { + authorize("/profile/**", hasAuthority("SCOPE_profile:read")) + authorize(anyRequest, authenticated) + } + x509 { } + oauth2Login { } + } + + http.exceptionHandling { e: ExceptionHandlingConfigurer -> e + .defaultDeniedHandlerForMissingAuthority(oauth2, "SCOPE_profile:read") + } + return http.build() + } + // end::httpSecurity[] + + // tag::authenticationEntryPoint[] + @Component + internal class ScopeRetrievingAuthenticationEntryPoint : AuthenticationEntryPoint { + override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) { + response.sendRedirect("https://authz.example.org/authorize?scope=profile:read") + } + } + // end::authenticationEntryPoint[] + + // tag::authorizationManagerFactoryBean[] + @Bean + fun authz(): AuthorizationManagerFactory { + return FactorAuthorizationManagerFactory(hasAllAuthorities("FACTOR_X509", "FACTOR_AUTHORIZATION_CODE")) + } + // end::authorizationManagerFactoryBean[] + + // tag::authorizationManagerFactory[] + internal inner class FactorAuthorizationManagerFactory(private val hasAuthorities: AuthorizationManager) : + AuthorizationManagerFactory { + private val delegate = DefaultAuthorizationManagerFactory() + + override fun permitAll(): AuthorizationManager { + return this.delegate.permitAll() + } + + override fun denyAll(): AuthorizationManager { + return this.delegate.denyAll() + } + + override fun hasRole(role: String): AuthorizationManager { + return hasAnyRole(role) + } + + override fun hasAnyRole(vararg roles: String): AuthorizationManager { + return addFactors(this.delegate.hasAnyRole(*roles)) + } + + override fun hasAllRoles(vararg roles: String): AuthorizationManager { + return addFactors(this.delegate.hasAllRoles(*roles)) + } + + override fun hasAuthority(authority: String): AuthorizationManager { + return hasAnyAuthority(authority) + } + + override fun hasAnyAuthority(vararg authorities: String): AuthorizationManager { + return addFactors(this.delegate.hasAnyAuthority(*authorities)) + } + + override fun hasAllAuthorities(vararg authorities: String): AuthorizationManager { + return addFactors(this.delegate.hasAllAuthorities(*authorities)) + } + + override fun authenticated(): AuthorizationManager { + return addFactors(this.delegate.authenticated()) + } + + override fun fullyAuthenticated(): AuthorizationManager { + return addFactors(this.delegate.fullyAuthenticated()) + } + + override fun rememberMe(): AuthorizationManager { + return addFactors(this.delegate.rememberMe()) + } + + override fun anonymous(): AuthorizationManager { + return this.delegate.anonymous() + } + + private fun addFactors(delegate: AuthorizationManager): AuthorizationManager { + return allOf(AuthorizationDecision(false), this.hasAuthorities, delegate) + } + } + // end::authorizationManagerFactory[] + + // end::authenticationEntryPoint[] + @Bean + fun clients(): ClientRegistrationRepository { + return InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build()) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.kt new file mode 100644 index 0000000000..c7cc92478f --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.kt.docs.servlet.authentication.obtainingmoreauthorization + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization.ScopeConfiguration +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener +import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers +import org.springframework.test.context.TestExecutionListeners +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Tests [CustomX509Configuration]. + * + * @author Rob Winch + */ +@ExtendWith(SpringExtension::class, SpringTestContextExtension::class) +@TestExecutionListeners(WithSecurityContextTestExecutionListener::class) +class ObtainingMoreAuthorizationTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this) + + @Autowired + var mockMvc: MockMvc? = null + + @Test + @WithMockUser + @Throws(Exception::class) + fun profileWhenScopeConfigurationThenDenies() { + this.spring.register(ScopeConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile")) + .andExpect(MockMvcResultMatchers.status().isForbidden()) + // @formatter:on + } + + @Test + @WithMockUser(authorities = ["FACTOR_X509", "FACTOR_AUTHORIZATION_CODE"]) + @Throws(Exception::class) + fun profileWhenMissingAuthorityConfigurationThenRedirectsToAuthorizationServer() { + this.spring.register(MissingAuthorityConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("https://authz.example.org/authorize?scope=profile:read")) + // @formatter:on + } + + @Test + @WithMockUser(authorities = ["SCOPE_profile:read"]) + @Throws(Exception::class) + fun profileWhenMissingX509WithOttThenForbidden() { + this.spring.register(MissingAuthorityConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile")) + .andExpect(MockMvcResultMatchers.status().isForbidden()) + // @formatter:on + } + + @Test + @WithMockUser(authorities = ["FACTOR_X509", "FACTOR_AUTHORIZATION_CODE", "SCOPE_profile:read"]) + @Throws( + Exception::class + ) + fun profileWhenAuthenticatedAndHasScopeThenPermits() { + this.spring.register(MissingAuthorityConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user")) + // @formatter:on + } + + @RestController + internal class Http200Controller { + @GetMapping("/**") + fun ok(): String { + return "ok" + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.kt new file mode 100644 index 0000000000..745456d71d --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.kt @@ -0,0 +1,38 @@ +package org.springframework.security.kt.docs.servlet.authentication.obtainingmoreauthorization + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository +import org.springframework.security.oauth2.client.registration.TestClientRegistrations +import org.springframework.security.web.SecurityFilterChain + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class ScopeConfiguration { + // tag::httpSecurity[] + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/profile/**", hasAuthority("SCOPE_profile:read")) + authorize(anyRequest, authenticated) + } + x509 { } + oauth2Login { } + } + // @formatter:on + return http.build() + } + // end::httpSecurity[] + + // end::httpSecurity[] + @Bean + fun clients(): ClientRegistrationRepository { + return InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build()) + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt new file mode 100644 index 0000000000..1b7278ce15 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.security.kt.docs.servlet.authentication.reauthentication + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.security.config.test.SpringTestContext +import org.springframework.security.config.test.SpringTestContextExtension +import org.springframework.security.docs.servlet.authentication.reauthentication.RequireOttConfiguration +import org.springframework.security.docs.servlet.authentication.reauthentication.SimpleConfiguration +import org.springframework.security.test.context.support.WithMockUser +import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener +import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers +import org.springframework.test.context.TestExecutionListeners +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Tests [CustomX509Configuration]. + * + * @author Rob Winch + */ +@ExtendWith(SpringExtension::class, SpringTestContextExtension::class) +@TestExecutionListeners(WithSecurityContextTestExecutionListener::class) +class ReauthenticationTests { + @JvmField + val spring: SpringTestContext = SpringTestContext(this) + + @Autowired + var mockMvc: MockMvc? = null + + @Test + @WithMockUser + @Throws(Exception::class) + fun formLoginWhenSimpleConfigurationThenPermits() { + this.spring.register(SimpleConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user")) + // @formatter:on + } + + @Test + @WithMockUser + @Throws(Exception::class) + fun formLoginWhenRequireOttConfigurationThenRedirectsToOtt() { + this.spring.register(RequireOttConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott")) + // @formatter:on + } + + @Test + @WithMockUser(authorities = ["FACTOR_OTT"]) + @Throws(Exception::class) + fun ottWhenRequireOttConfigurationThenAllows() { + this.spring.register(RequireOttConfiguration::class.java, Http200Controller::class.java).autowire() + // @formatter:off + this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile")) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user")) + // @formatter:on + } + + @RestController + internal class Http200Controller { + @GetMapping("/**") + fun ok(): String { + return "ok" + } + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/RequireOttConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/RequireOttConfiguration.kt new file mode 100644 index 0000000000..cca01a6c85 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/RequireOttConfiguration.kt @@ -0,0 +1,52 @@ +package org.springframework.security.kt.docs.servlet.authentication.reauthentication + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class RequireOttConfiguration { + + // tag::httpSecurity[] + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/profile/**", hasAuthority("FACTOR_OTT")) // <1> + authorize(anyRequest, authenticated) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() + } + // end::httpSecurity[] + + // end::httpSecurity[] + @Bean + fun userDetailsService(): UserDetailsService { + return InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ) + } + + @Bean + fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler { + return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent") + } +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/SimpleConfiguration.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/SimpleConfiguration.kt new file mode 100644 index 0000000000..71c2c5a7a7 --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/SimpleConfiguration.kt @@ -0,0 +1,50 @@ +package org.springframework.security.kt.docs.servlet.authentication.reauthentication + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.annotation.web.invoke +import org.springframework.security.core.userdetails.User +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.security.provisioning.InMemoryUserDetailsManager +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler + +@EnableWebSecurity +@Configuration(proxyBeanMethods = false) +class SimpleConfiguration { + // tag::httpSecurity[] + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? { + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() + } + // end::httpSecurity[] + + // end::httpSecurity[] + @Bean + fun userDetailsService(): UserDetailsService { + return InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .authorities("app") + .build() + ) + } + + @Bean + fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler { + return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent") + } +} diff --git a/web/src/main/java/org/springframework/security/web/WebAttributes.java b/web/src/main/java/org/springframework/security/web/WebAttributes.java index b9effcd807..53ec1c698c 100644 --- a/web/src/main/java/org/springframework/security/web/WebAttributes.java +++ b/web/src/main/java/org/springframework/security/web/WebAttributes.java @@ -16,6 +16,9 @@ package org.springframework.security.web; +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.access.WebInvocationPrivilegeEvaluator; /** @@ -52,6 +55,17 @@ public final class WebAttributes { public static final String WEB_INVOCATION_PRIVILEGE_EVALUATOR_ATTRIBUTE = WebAttributes.class.getName() + ".WEB_INVOCATION_PRIVILEGE_EVALUATOR_ATTRIBUTE"; + /** + * Used to set a {@code Collection} of {@link GrantedAuthority} instances into the + * {@link HttpServletRequest}. + *

+ * Represents what authorities are missing to be authorized for the current request + * + * @since 7.0 + * @see org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler + */ + public static final String MISSING_AUTHORITIES = WebAttributes.class + ".MISSING_AUTHORITIES"; + private WebAttributes() { } diff --git a/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java b/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java new file mode 100644 index 0000000000..fbf2ff753c --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java @@ -0,0 +1,209 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.access; + +import java.io.IOException; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.Nullable; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authorization.AuthorityAuthorizationDecision; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.WebAttributes; +import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; +import org.springframework.security.web.savedrequest.NullRequestCache; +import org.springframework.security.web.savedrequest.RequestCache; +import org.springframework.security.web.util.ThrowableAnalyzer; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.util.Assert; + +/** + * An {@link AccessDeniedHandler} that adapts {@link AuthenticationEntryPoint}s based on + * missing {@link GrantedAuthority}s. These authorities are specified in an + * {@link AuthorityAuthorizationDecision} inside an {@link AuthorizationDeniedException}. + * + *

+ * This is helpful in adaptive authentication scenarios where an + * {@link org.springframework.security.authorization.AuthorizationManager} indicates + * additional authorities needed to access a given resource. + *

+ * + *

+ * For example, if an + * {@link org.springframework.security.authorization.AuthorizationManager} states that to + * access the home page, the user needs the {@code FACTOR_OTT} authority, then this + * handler can be configured in the following way to redirect to the one-time-token login + * page: + *

+ * + * + * AccessDeniedHandler handler = DelegatingMissingAuthorityAccessDeniedHandler.builder() + * .addEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_OTT") + * .addEntryPointFor(new MyCustomEntryPoint(), "FACTOR_PASSWORD") + * .build(); + * + * + * @author Josh Cummings + * @since 7.0 + * @see AuthorizationDeniedException + * @see AuthorityAuthorizationDecision + * @see org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer + */ +public final class DelegatingMissingAuthorityAccessDeniedHandler implements AccessDeniedHandler { + + private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer(); + + private final Map entryPoints; + + private RequestCache requestCache = new NullRequestCache(); + + private AccessDeniedHandler defaultAccessDeniedHandler = new AccessDeniedHandlerImpl(); + + private DelegatingMissingAuthorityAccessDeniedHandler(Map entryPoints) { + Assert.notEmpty(entryPoints, "entryPoints cannot be empty"); + this.entryPoints = entryPoints; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied) + throws IOException, ServletException { + Collection authorities = missingAuthorities(denied); + for (GrantedAuthority needed : authorities) { + AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority()); + if (entryPoint == null) { + continue; + } + this.requestCache.saveRequest(request, response); + request.setAttribute(WebAttributes.MISSING_AUTHORITIES, List.of(needed)); + String message = String.format("Missing Authorities %s", List.of(needed)); + AuthenticationException ex = new InsufficientAuthenticationException(message, denied); + entryPoint.commence(request, response, ex); + return; + } + this.defaultAccessDeniedHandler.handle(request, response, denied); + } + + /** + * Use this {@link AccessDeniedHandler} for {@link AccessDeniedException}s that this + * handler doesn't support. By default, this uses {@link AccessDeniedHandlerImpl}. + * @param defaultAccessDeniedHandler the default {@link AccessDeniedHandler} to use + */ + public void setDefaultAccessDeniedHandler(AccessDeniedHandler defaultAccessDeniedHandler) { + Assert.notNull(defaultAccessDeniedHandler, "defaultAccessDeniedHandler cannot be null"); + this.defaultAccessDeniedHandler = defaultAccessDeniedHandler; + } + + /** + * Use this {@link RequestCache} to remember the current request. + *

+ * Uses {@link NullRequestCache} by default + *

+ * @param requestCache the {@link RequestCache} to use + */ + public void setRequestCache(RequestCache requestCache) { + Assert.notNull(requestCache, "requestCachgrantedaue cannot be null"); + this.requestCache = requestCache; + } + + private Collection missingAuthorities(AccessDeniedException ex) { + AuthorizationDeniedException denied = findAuthorizationDeniedException(ex); + if (denied == null) { + return List.of(); + } + if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) { + return List.of(); + } + return authorization.getAuthorities(); + } + + private @Nullable AuthorizationDeniedException findAuthorizationDeniedException(AccessDeniedException ex) { + if (ex instanceof AuthorizationDeniedException denied) { + return denied; + } + Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex); + return (AuthorizationDeniedException) this.throwableAnalyzer + .getFirstThrowableOfType(AuthorizationDeniedException.class, chain); + } + + public static Builder builder() { + return new Builder(); + } + + /** + * A builder for configuring the set of authority/entry-point pairs + * + * @author Josh Cummings + * @since 7.0 + */ + public static final class Builder { + + private final Map entryPointBuilderByAuthority = new LinkedHashMap<>(); + + private Builder() { + + } + + /** + * Use this {@link AuthenticationEntryPoint} when the given + * {@code missingAuthority} is missing from the authenticated user + * @param entryPoint the {@link AuthenticationEntryPoint} for the given authority + * @param missingAuthority the authority + * @return the {@link Builder} for further configurations + */ + public Builder addEntryPointFor(AuthenticationEntryPoint entryPoint, String missingAuthority) { + DelegatingAuthenticationEntryPoint.Builder builder = DelegatingAuthenticationEntryPoint.builder() + .addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE); + this.entryPointBuilderByAuthority.put(missingAuthority, builder); + return this; + } + + /** + * Use this {@link AuthenticationEntryPoint} when the given + * {@code missingAuthority} is missing from the authenticated user + * @param entryPoint a consumer to configure the underlying + * {@link DelegatingAuthenticationEntryPoint} + * @param missingAuthority the authority + * @return the {@link Builder} for further configurations + */ + public Builder addEntryPointFor(Consumer entryPoint, + String missingAuthority) { + entryPoint.accept(this.entryPointBuilderByAuthority.computeIfAbsent(missingAuthority, + (k) -> DelegatingAuthenticationEntryPoint.builder())); + return this; + } + + public DelegatingMissingAuthorityAccessDeniedHandler build() { + Map entryPointByAuthority = new LinkedHashMap<>(); + this.entryPointBuilderByAuthority.forEach((key, value) -> entryPointByAuthority.put(key, value.build())); + return new DelegatingMissingAuthorityAccessDeniedHandler(entryPointByAuthority); + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java b/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java index 9be0de9478..84a0ba7c26 100644 --- a/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java +++ b/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java @@ -196,7 +196,8 @@ public class ExceptionTranslationFilter extends GenericFilterBean implements Mes } AuthenticationException ex = new InsufficientAuthenticationException( this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", - "Full authentication is required to access this resource")); + "Full authentication is required to access this resource"), + exception); ex.setAuthenticationRequest(authentication); sendStartAuthentication(request, response, chain, ex); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java b/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java index 2cc2ac92d3..dbbba096da 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java +++ b/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java @@ -17,6 +17,8 @@ package org.springframework.security.web.authentication; import java.io.IOException; +import java.util.Collection; +import java.util.Locale; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.ServletException; @@ -30,16 +32,20 @@ import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.InitializingBean; import org.springframework.core.log.LogMessage; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.DefaultRedirectStrategy; import org.springframework.security.web.PortMapper; import org.springframework.security.web.PortMapperImpl; import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.WebAttributes; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.util.RedirectUrlBuilder; import org.springframework.security.web.util.UrlUtils; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; /** * Used by the {@link ExceptionTranslationFilter} to commence a form login authentication @@ -68,6 +74,8 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin private static final Log logger = LogFactory.getLog(LoginUrlAuthenticationEntryPoint.class); + private static final String FACTOR_PREFIX = "FACTOR_"; + private PortMapper portMapper = new PortMapperImpl(); private String loginFormUrl; @@ -107,9 +115,29 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin * @param exception the exception * @return the URL (cannot be null or empty; defaults to {@link #getLoginFormUrl()}) */ + @SuppressWarnings("unchecked") protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) { - return getLoginFormUrl(); + Collection authorities = getAttribute(request, WebAttributes.MISSING_AUTHORITIES, + Collection.class); + if (CollectionUtils.isEmpty(authorities)) { + return getLoginFormUrl(); + } + Collection factors = authorities.stream() + .filter((a) -> a.getAuthority().startsWith(FACTOR_PREFIX)) + .map((a) -> a.getAuthority().substring(FACTOR_PREFIX.length()).toLowerCase(Locale.ROOT)) + .toList(); + return UriComponentsBuilder.fromUriString(getLoginFormUrl()).queryParam("factor", factors).toUriString(); + } + + private static @Nullable T getAttribute(HttpServletRequest request, String name, Class clazz) { + Object value = request.getAttribute(name); + if (value == null) { + return null; + } + String message = String.format("Found %s in %s, but expecting a %s", value.getClass(), name, clazz); + Assert.isInstanceOf(clazz, value, message); + return (T) value; } /** diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java index 3c74146b83..1346185330 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java @@ -18,9 +18,12 @@ package org.springframework.security.web.authentication.ui; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.Collection; import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import jakarta.servlet.FilterChain; @@ -31,10 +34,14 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.jspecify.annotations.Nullable; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices; import org.springframework.util.Assert; import org.springframework.web.filter.GenericFilterBean; +import org.springframework.web.util.UriComponentsBuilder; /** * For internal use with namespace configuration in the case where a user doesn't @@ -52,6 +59,9 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { public static final String ERROR_PARAMETER_NAME = "error"; + private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder + .getContextHolderStrategy(); + private @Nullable String loginPageUrl; private @Nullable String logoutSuccessUrl; @@ -78,6 +88,10 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { private @Nullable String rememberMeParameter; + private final String factorParameter = "factor"; + + private final Collection allowedParameters = List.of(this.factorParameter); + @SuppressWarnings("NullAway.Init") private Map oauth2AuthenticationUrlToClientName; @@ -109,6 +123,18 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { } } + /** + * Use this {@link SecurityContextHolderStrategy} to retrieve authenticated users. + *

+ * Uses {@link SecurityContextHolder#getContextHolderStrategy()} by default. + * @param securityContextHolderStrategy the strategy to use + * @since 7.0 + */ + public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) { + Assert.notNull(securityContextHolderStrategy, "securityContextHolderStrategy cannot be null"); + this.securityContextHolderStrategy = securityContextHolderStrategy; + } + /** * Sets a Function used to resolve a Map of the hidden inputs where the key is the * name of the input and the value is the value of the input. Typically this is used @@ -223,16 +249,43 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { String errorMsg = "Invalid credentials"; String contextPath = request.getContextPath(); - return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE) + HtmlTemplates.Builder builder = HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE) .withRawHtml("contextPath", contextPath) - .withRawHtml("javaScript", renderJavaScript(request, contextPath)) - .withRawHtml("formLogin", renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg)) - .withRawHtml("oneTimeTokenLogin", - renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg)) - .withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath)) - .withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath)) - .withRawHtml("passkeyLogin", renderPasskeyLogin()) - .render(); + .withRawHtml("javaScript", "") + .withRawHtml("formLogin", "") + .withRawHtml("oneTimeTokenLogin", "") + .withRawHtml("oauth2Login", "") + .withRawHtml("saml2Login", "") + .withRawHtml("passkeyLogin", ""); + + Predicate wantsAuthority = wantsAuthority(request); + if (wantsAuthority.test("webauthn")) { + builder.withRawHtml("javaScript", renderJavaScript(request, contextPath)) + .withRawHtml("passkeyLogin", renderPasskeyLogin()); + } + if (wantsAuthority.test("password")) { + builder.withRawHtml("formLogin", + renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg)); + } + if (wantsAuthority.test("ott")) { + builder.withRawHtml("oneTimeTokenLogin", + renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg)); + } + if (wantsAuthority.test("authorization_code")) { + builder.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath)); + } + if (wantsAuthority.test("saml_response")) { + builder.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath)); + } + return builder.render(); + } + + private Predicate wantsAuthority(HttpServletRequest request) { + String[] authorities = request.getParameterValues(this.factorParameter); + if (authorities == null) { + return (authority) -> true; + } + return List.of(authorities)::contains; } private String renderJavaScript(HttpServletRequest request, String contextPath) { @@ -271,6 +324,13 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { return ""; } + String username = getUsername(); + String usernameInput = ((username != null) + ? HtmlTemplates.fromTemplate(FORM_READONLY_USERNAME_INPUT).withValue("username", username) + : HtmlTemplates.fromTemplate(FORM_USERNAME_INPUT)) + .withValue("usernameParameter", this.usernameParameter) + .render(); + String hiddenInputs = this.resolveHiddenInputs.apply(request) .entrySet() .stream() @@ -281,7 +341,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { .withValue("loginUrl", contextPath + this.authenticationUrl) .withRawHtml("errorMessage", renderError(loginError, errorMsg)) .withRawHtml("logoutMessage", renderSuccess(logoutSuccess)) - .withValue("usernameParameter", this.usernameParameter) + .withRawHtml("usernameInput", usernameInput) .withValue("passwordParameter", this.passwordParameter) .withRawHtml("rememberMeInput", renderRememberMe(this.rememberMeParameter)) .withRawHtml("hiddenInputs", hiddenInputs) @@ -301,11 +361,17 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { .map((inputKeyValue) -> renderHiddenInput(inputKeyValue.getKey(), inputKeyValue.getValue())) .collect(Collectors.joining("\n")); + String username = getUsername(); + String usernameInput = (username != null) + ? HtmlTemplates.fromTemplate(ONE_TIME_READONLY_USERNAME_INPUT).withValue("username", username).render() + : ONE_TIME_USERNAME_INPUT; + return HtmlTemplates.fromTemplate(ONE_TIME_TEMPLATE) .withValue("generateOneTimeTokenUrl", contextPath + this.generateOneTimeTokenUrl) .withRawHtml("errorMessage", renderError(loginError, errorMsg)) .withRawHtml("logoutMessage", renderSuccess(logoutSuccess)) .withRawHtml("hiddenInputs", hiddenInputs) + .withRawHtml("usernameInput", usernameInput) .render(); } @@ -374,6 +440,14 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { .render(); } + private @Nullable String getUsername() { + Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication.getName(); + } + return null; + } + private boolean isLogoutSuccess(HttpServletRequest request) { return this.logoutSuccessUrl != null && matches(request, this.logoutSuccessUrl); } @@ -413,10 +487,19 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { if (request.getQueryString() != null) { uri += "?" + request.getQueryString(); } - if ("".equals(request.getContextPath())) { - return uri.equals(url); + UriComponentsBuilder addAllowed = UriComponentsBuilder.fromUriString(url); + for (String parameter : this.allowedParameters) { + String[] values = request.getParameterValues(parameter); + if (values != null) { + for (String value : values) { + addAllowed.queryParam(parameter, value); + } + } } - return uri.equals(request.getContextPath() + url); + if ("".equals(request.getContextPath())) { + return uri.equals(addAllowed.toUriString()); + } + return uri.equals(request.getContextPath() + addAllowed.toUriString()); } private static final String CSRF_HEADERS = """ @@ -466,7 +549,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { {{errorMessage}}{{logoutMessage}}

- + {{usernameInput}}

@@ -477,6 +560,14 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { """; + private static final String FORM_READONLY_USERNAME_INPUT = """ + + """; + + private static final String FORM_USERNAME_INPUT = """ + + """; + private static final String HIDDEN_HTML_INPUT_TEMPLATE = """ """; @@ -509,11 +600,19 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { {{errorMessage}}{{logoutMessage}}

- + {{usernameInput}}

{{hiddenInputs}} """; + private static final String ONE_TIME_READONLY_USERNAME_INPUT = """ + + """; + + private static final String ONE_TIME_USERNAME_INPUT = """ + + """; + } diff --git a/web/src/test/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandlerTests.java b/web/src/test/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandlerTests.java new file mode 100644 index 0000000000..7aa02ef980 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandlerTests.java @@ -0,0 +1,145 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.access; + +import java.util.Collection; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authorization.AuthorityAuthorizationDecision; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.savedrequest.RequestCache; +import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class DelegatingMissingAuthorityAccessDeniedHandlerTests { + + DelegatingMissingAuthorityAccessDeniedHandler.Builder builder; + + MockHttpServletRequest request; + + MockHttpServletResponse response; + + @Mock + AuthenticationEntryPoint factorEntryPoint; + + @Mock + AccessDeniedHandler defaultAccessDeniedHandler; + + @BeforeEach + void setUp() { + this.builder = DelegatingMissingAuthorityAccessDeniedHandler.builder(); + this.builder.addEntryPointFor(this.factorEntryPoint, "FACTOR"); + this.request = new MockHttpServletRequest(); + this.response = new MockHttpServletResponse(); + } + + @Test + void whenKnownAuthorityThenCommences() throws Exception { + AccessDeniedHandler accessDeniedHandler = this.builder.build(); + accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR")); + verify(this.factorEntryPoint).commence(any(), any(), any()); + } + + @Test + void whenUnknownAuthorityThenDefaultCommences() throws Exception { + DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build(); + accessDeniedHandler.setDefaultAccessDeniedHandler(this.defaultAccessDeniedHandler); + accessDeniedHandler.handle(this.request, this.response, missingAuthorities("ROLE_USER")); + verify(this.defaultAccessDeniedHandler).handle(any(), any(), any()); + verifyNoInteractions(this.factorEntryPoint); + } + + @Test + void whenNoAuthoritiesFoundThenDefaultCommences() throws Exception { + DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build(); + accessDeniedHandler.setDefaultAccessDeniedHandler(this.defaultAccessDeniedHandler); + accessDeniedHandler.handle(this.request, this.response, new AccessDeniedException("access denied")); + verify(this.defaultAccessDeniedHandler).handle(any(), any(), any()); + } + + @Test + void whenMultipleAuthoritiesThenFirstMatchCommences() throws Exception { + AuthenticationEntryPoint passwordEntryPoint = mock(AuthenticationEntryPoint.class); + this.builder.addEntryPointFor(passwordEntryPoint, "PASSWORD"); + AccessDeniedHandler accessDeniedHandler = this.builder.build(); + accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD", "FACTOR")); + verify(passwordEntryPoint).commence(any(), any(), any()); + accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR", "PASSWORD")); + verify(this.factorEntryPoint).commence(any(), any(), any()); + } + + @Test + void whenCustomRequestCacheThenUses() throws Exception { + RequestCache requestCache = mock(RequestCache.class); + DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build(); + accessDeniedHandler.setRequestCache(requestCache); + accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR")); + verify(requestCache).saveRequest(any(), any()); + verify(this.factorEntryPoint).commence(any(), any(), any()); + } + + @Test + void whenKnownAuthorityButNoRequestMatchThenCommences() throws Exception { + AuthenticationEntryPoint passwordEntryPoint = mock(AuthenticationEntryPoint.class); + RequestMatcher xhr = new RequestHeaderRequestMatcher("X-Requested-With"); + this.builder.addEntryPointFor((ep) -> ep.addEntryPointFor(passwordEntryPoint, xhr), "PASSWORD"); + AccessDeniedHandler accessDeniedHandler = this.builder.build(); + accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD")); + verify(passwordEntryPoint).commence(any(), any(), any()); + } + + @Test + void whenMultipleEntryPointsThenFirstRequestMatchCommences() throws Exception { + AuthenticationEntryPoint basicPasswordEntryPoint = mock(AuthenticationEntryPoint.class); + AuthenticationEntryPoint formPasswordEntryPoint = mock(AuthenticationEntryPoint.class); + RequestMatcher xhr = new RequestHeaderRequestMatcher("X-Requested-With"); + this.builder.addEntryPointFor( + (ep) -> ep.addEntryPointFor(basicPasswordEntryPoint, xhr).defaultEntryPoint(formPasswordEntryPoint), + "PASSWORD"); + AccessDeniedHandler accessDeniedHandler = this.builder.build(); + accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD")); + verify(formPasswordEntryPoint).commence(any(), any(), any()); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("X-Requested-With", "XmlHttpRequest"); + accessDeniedHandler.handle(request, this.response, missingAuthorities("PASSWORD")); + verify(basicPasswordEntryPoint).commence(any(), any(), any()); + } + + AuthorizationDeniedException missingAuthorities(String... authorities) { + Collection granted = AuthorityUtils.createAuthorityList(authorities); + AuthorityAuthorizationDecision decision = new AuthorityAuthorizationDecision(false, granted); + return new AuthorizationDeniedException("access denied", decision); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java index 2dff03e7e4..9717e676a0 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java @@ -26,10 +26,15 @@ import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.TestAuthentication; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.core.context.SecurityContextImpl; import org.springframework.security.web.WebAttributes; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; +import org.springframework.security.web.servlet.TestMockHttpServletRequests; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; /** @@ -191,6 +196,83 @@ public class DefaultLoginPageGeneratingFilterTests { """); } + @Test + public void generateWhenOneTimeTokenRequestedThenOttForm() throws Exception { + DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(); + filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL); + filter.setFormLoginEnabled(true); + filter.setOneTimeTokenEnabled(true); + filter.setOneTimeTokenGenerationUrl("/ott/authenticate"); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(TestMockHttpServletRequests.get("/login?factor=ott").build(), response, this.chain); + assertThat(response.getContentAsString()).contains("Request a One-Time Token"); + assertThat(response.getContentAsString()).contains(""" + + """); + assertThat(response.getContentAsString()).doesNotContain("Password"); + } + + @Test + public void generateWhenTwoAuthoritiesRequestedThenBothForms() throws Exception { + DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(); + filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL); + filter.setFormLoginEnabled(true); + filter.setUsernameParameter("username"); + filter.setPasswordParameter("password"); + filter.setOneTimeTokenEnabled(true); + filter.setOneTimeTokenGenerationUrl("/ott/authenticate"); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(TestMockHttpServletRequests.get("/login?factor=ott&factor=password").build(), response, + this.chain); + assertThat(response.getContentAsString()).contains("Request a One-Time Token"); + assertThat(response.getContentAsString()).contains(""" + + """); + assertThat(response.getContentAsString()).contains("Password"); + } + + @Test + public void generateWhenAuthenticatedThenReadOnlyUsername() throws Exception { + SecurityContextHolderStrategy strategy = mock(SecurityContextHolderStrategy.class); + DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(); + filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL); + filter.setFormLoginEnabled(true); + filter.setUsernameParameter("username"); + filter.setPasswordParameter("password"); + filter.setOneTimeTokenEnabled(true); + filter.setOneTimeTokenGenerationUrl("/ott/authenticate"); + filter.setSecurityContextHolderStrategy(strategy); + given(strategy.getContext()).willReturn(new SecurityContextImpl(TestAuthentication.authenticatedUser())); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(TestMockHttpServletRequests.get("/login").build(), response, this.chain); + assertThat(response.getContentAsString()).contains("Request a One-Time Token"); + assertThat(response.getContentAsString()).contains( + """ + + """); + assertThat(response.getContentAsString()).contains(""" + + """); + } + @Test void generatesThenRenders() throws ServletException, IOException { DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(