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 43d913f54a6..dd73b14bf6c 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 6b6a2f1f7c4..dd093e8141b 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 ExceptionHandlingConfigurer defaultAccessDeniedHandlerFor(AccessDenied 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 @@ AuthenticationEntryPoint getAuthenticationEntryPoint(H http) { } 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 03cf95b3901..c78152454ab 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 FormLoginConfigurer successForwardUrl(String forwardUrl) { 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 e2f2a8ec593..701fc415a66 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 @@ private void registerDefaultEntryPoint(B http, RequestMatcher preferredMatcher) 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 7ec3279efb5..39f2768d86d 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.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 WebAuthnConfigurer creationOptionsRepository( 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 79a22659628..be19fa671b0 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,10 @@ public void init(H http) { .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.defaultAuthenticationEntryPointFor(forbidden, AnyRequestMatcher.INSTANCE); + 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 494f75109a1..ecf695bcc01 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.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 @@ private AuthenticationEntryPoint getLoginEntryPoint(B http, String providerLogin 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 331730f1dbf..5c9b56198d2 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 @@ private void registerDefaultEntryPoint(H http) { RequestMatcher preferredMatcher = new OrRequestMatcher( Arrays.asList(this.requestMatcher, X_REQUESTED_WITH, restNotHtmlMatcher, allMatcher)); exceptionHandling.defaultAuthenticationEntryPointFor(this.authenticationEntryPoint, preferredMatcher); + exceptionHandling.defaultDeniedHandlerForMissingAuthority( + (ep) -> 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 4f01a17e5eb..c193caf971f 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.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 void init(H http) throws Exception { 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 fbbb3f73a78..be783ef0f69 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.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 @@ private AuthenticationEntryPoint getLoginEntryPoint(B http, String providerLogin 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 cb8a6005b6a..b23d45853db 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.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.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.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 void configureWhenRegisteringObjectPostProcessorThenInvokedOnExceptionTra 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().isForbidden()); + 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 O postProcess(O object) { } + @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/pages/servlet/authentication/adaptive.adoc b/docs/modules/ROOT/pages/servlet/authentication/adaptive.adoc new file mode 100644 index 00000000000..d1e03e6dab8 --- /dev/null +++ b/docs/modules/ROOT/pages/servlet/authentication/adaptive.adoc @@ -0,0 +1,528 @@ += 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 + +The most common of these is re-authentication. +Imagine an application configured in the following way: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@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(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() +} +---- +====== + +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: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@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(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/profile/**", hasAuthority("FACTOR_OTT")) <1> + authorize(anyRequest, authenticated) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() +} +---- +====== +<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 + +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: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public 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(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"))) <1> + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() +} +---- +====== +<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. + +=== Requiring MFA For All Endpoints + +Specifying all authorities for each request pattern could be unwanted boilerplate: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest("/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(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/admin/**", allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"), hasRole("ADMIN"))) <1> + authorize(anyRequest, allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"))) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() +} +---- +====== +<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: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +DefaultAuthorizationManagerFactory authz() { + return DefaultAuthorizationManager.withAuthorities("FACTOR_PASSWORD", "FACTOR_OTT"); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun authz(): DefaultAuthorizationManagerFactory { + return DefaultAuthorizationManager.withAuthorities("FACTOR_PASSWORD", "FACTOR_OTT") +} +---- +====== + +This yields a more familiar configuration: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public 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(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/admin/**", hasRole("ADMIN")) + authorize(anyRequest, authenticated) + } + formLogin { } + oneTimeTokenLogin { } + } + // @formatter:on + return http.build() +} +---- +====== + +== 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: + + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public 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(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/profile/**", hasAuthority("SCOPE_profile:read")) + authorize(anyRequest, authenticated) + } + x509 { } + oauth2Login { } + } + // @formatter:on + return http.build() +} +---- +====== + +If this is also configured with an `AuthorizationManagerFactory` bean like this one: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +DefaultAuthorizationManagerFactory authz() { + return DefaultAuthorizationManager.withAuthorities("FACTOR_X509", "FACTOR_AUTHORIZATION_CODE"); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun authz(): DefaultAuthorizationManagerFactory { + return DefaultAuthorizationManager.withAuthorities("FACTOR_X509", "FACTOR_AUTHORIZATION_CODE") +} +---- +====== + +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: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Component +class ScopeRetrievingAuthenticationEntryPoint implements AuthenticationEntryPoint { + // ... redirects to authorization server to request profile:read scope +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Component +class ScopeRetrievingAuthenticationEntryPoint : AuthenticationEntryPoint { + // ... redirects to authorization server to request profile:read scope +} +---- +====== + +Then, your filter chain declaration can bind this entry point to the given authority like so: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public 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 + .defaultAuthenticationEntryPointFor(oauth2, "SCOPE_profile:read") + ); + // @formatter:on + return http.build(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http { + authorizeHttpRequests { + authorize("/profile/**", hasAuthority("SCOPE_profile:read")) + authorize(anyRequest, authenticated) + } + x509 { } + oauth2Login { } + .exceptionHandling { + exceptions.defaultAuthenticationEntryPointFor(oauth2, "SCOPE_profile:read") + } + } + // @formatter:on + return http.build() +} +---- +====== + +== 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: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Component +class OptInToMfaAuthorizationManager implements AuthorizationManager { + @Override + public AuthorizationResult authorize(Supplier authentication, RequestAuthorizationContext context) { + MyPrincipal principal = (MyPrincipal) authentication.get().getPrincipal(); + if (principal.isOptedIn()) { + WebSecurityExpressionRoot root = new WebSecurityExpressionRoot(authentication, context); + return new AuthorityAuthorizationDecision(root.hasAuthority("FACTOR_OTT"), List.of("FACTOR_OTT")); + } + return AuthorizationDecision(true); + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Component +class OptInToMfaAuthorizationManager : AuthorizationManager { + override fun authorize(authentication : Supplier, context: RequestAuthorizationContext) { + val principal = authentication.get().getPrincipal() as MyPrincipal + if (principal.isOptedIn()) { + val root = WebSecurityExpressionRoot(authentication, context) + return AuthorityAuthorizationDecision(root.hasAuthority("FACTOR_OTT"), List.of("FACTOR_OTT")) + } + return AuthorizationDecision(true) + } +} +---- +====== + +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: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Component +class OptInAuthorizationManagerFactory implements AuthorizationManagerFactory { + private final OptInAuthorizationManager optIn; + private final DefaultAuthorizationManagerFactory delegate = + new DefaultAuthorizationManagerFactory<>(); + + // ... + + @Override + public AuthorizationManager hasRole(String role) { + return AuthorizationManagers.allOf(new AuthorizationDecision(false), this.optIn, this.delegate.hasRole(role)); + } + + @Override + public AuthorizationManager authenticated() { + return AuthorizationManagers.allOf(new AuthorizationDecision(false), this.optIn, this.delegate.authenicated()); + } + + // ... + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Component +class OptInAuthorizationManagerFactory : AuthorizationManagerFactory { + val optIn: OptInAuthorizationManager + val delegate = DefaultAuthorizationManagerFactory() + + // ... + + override fun hasRole(role: String): AuthorizationManager { + return AuthorizationManagers.allOf(AuthorizationDecision(false), this.optIn, this.delegate.hasRole(role)) + } + + override fun authenticated(): AuthorizationManager { + return AuthorizationManagers.allOf(AuthorizationDecision(false), this.optIn, this.delegate.authenicated()) + } + + // ... + +} +---- +====== diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 3958db00af2..59f8926dfa9 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/web/src/main/java/org/springframework/security/web/WebAttributes.java b/web/src/main/java/org/springframework/security/web/WebAttributes.java index b9effcd8075..54fdd1a5fd7 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,7 @@ package org.springframework.security.web; +import org.springframework.security.authorization.AuthorizationDeniedException; import org.springframework.security.web.access.WebInvocationPrivilegeEvaluator; /** @@ -52,6 +53,15 @@ public final class WebAttributes { public static final String WEB_INVOCATION_PRIVILEGE_EVALUATOR_ATTRIBUTE = WebAttributes.class.getName() + ".WEB_INVOCATION_PRIVILEGE_EVALUATOR_ATTRIBUTE"; + /** + * Used to specify to the view layer what missing authorities caused an + * {@link AuthorizationDeniedException} + * + * @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 00000000000..fbf2ff753c1 --- /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 9be0de94787..84a0ba7c261 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 @@ private void handleAccessDeniedException(HttpServletRequest request, HttpServlet } 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 2cc2ac92d33..dbbba096da2 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.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 void afterPropertiesSet() { * @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 3c74146b83c..1346185330e 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 @@ 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.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 @@ private void initAuthFilter(UsernamePasswordAuthenticationFilter authFilter) { } } + /** + * 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 @@ private String generateLoginPageHtml(HttpServletRequest request, boolean loginEr 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 @@ private String renderFormLogin(HttpServletRequest request, boolean loginError, b 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 @@ private String renderFormLogin(HttpServletRequest request, boolean loginError, b .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 @@ private String renderOneTimeTokenLogin(HttpServletRequest request, boolean login .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 @@ private String renderRememberMe(@Nullable String paramName) { .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 @@ private boolean matches(HttpServletRequest request, @Nullable String url) { if (request.getQueryString() != null) { uri += "?" + request.getQueryString(); } + 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); + } + } + } if ("".equals(request.getContextPath())) { - return uri.equals(url); + return uri.equals(addAllowed.toUriString()); } - return uri.equals(request.getContextPath() + url); + return uri.equals(request.getContextPath() + addAllowed.toUriString()); } private static final String CSRF_HEADERS = """ @@ -466,7 +549,7 @@ private boolean matches(HttpServletRequest request, @Nullable String url) { {{errorMessage}}{{logoutMessage}}

- + {{usernameInput}}

@@ -477,6 +560,14 @@ private boolean matches(HttpServletRequest request, @Nullable String url) { """; + 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 @@ private boolean matches(HttpServletRequest request, @Nullable String url) { {{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 00000000000..7aa02ef9800 --- /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 2dff03e7e40..9717e676a04 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.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 void generateWhenOneTimeTokenLoginThenOttForm() throws Exception { """); } + @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(