From 484a85f10ad74788cbf10df5f808bf5d2a4e4e37 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Fri, 19 Sep 2025 09:58:13 -0600 Subject: [PATCH 1/9] Initial Exception Handling This commit hardcodes factors as a proof of concept for multi-factor authentication Issue gh-17934 --- .../ExceptionHandlingConfigurer.java | 121 ++++++++++++- .../configurers/FormLoginConfigurerTests.java | 162 ++++++++++++++++++ 2 files changed, 281 insertions(+), 2 deletions(-) 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..22c9219a725 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 @@ -16,23 +16,48 @@ package org.springframework.security.config.annotation.web.configurers; +import java.io.IOException; +import java.util.Collection; import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +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.config.Customizer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.FormPostRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandlerImpl; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler; import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter; +import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.util.UriComponentsBuilder; /** * Adds exception handling for Spring Security related exceptions to an application. All @@ -230,13 +255,13 @@ AuthenticationEntryPoint getAuthenticationEntryPoint(H http) { private AccessDeniedHandler createDefaultDeniedHandler(H http) { if (this.defaultDeniedHandlerMappings.isEmpty()) { - return new AccessDeniedHandlerImpl(); + return new AuthenticationFactorDelegatingAccessDeniedHandler(); } if (this.defaultDeniedHandlerMappings.size() == 1) { return this.defaultDeniedHandlerMappings.values().iterator().next(); } return new RequestMatcherDelegatingAccessDeniedHandler(this.defaultDeniedHandlerMappings, - new AccessDeniedHandlerImpl()); + new AuthenticationFactorDelegatingAccessDeniedHandler()); } private AuthenticationEntryPoint createDefaultEntryPoint(H http) { @@ -262,4 +287,96 @@ private RequestCache getRequestCache(H http) { return new HttpSessionRequestCache(); } + private static final class AuthenticationFactorDelegatingAccessDeniedHandler implements AccessDeniedHandler { + + private final Map entryPoints = Map.of("FACTOR_PASSWORD", + new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_AUTHORIZATION_CODE", + new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_SAML_RESPONSE", + new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_WEBAUTHN", + new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_BEARER", + new BearerTokenAuthenticationEntryPoint(), "FACTOR_OTT", + new PostAuthenticationEntryPoint(GenerateOneTimeTokenFilter.DEFAULT_GENERATE_URL + "?username={u}", + Map.of("u", Authentication::getName))); + + private final AccessDeniedHandler defaults = new AccessDeniedHandlerImpl(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) + throws IOException, ServletException { + Collection needed = authorizationRequest(ex); + if (needed == null) { + this.defaults.handle(request, response, ex); + return; + } + for (String authority : needed) { + AuthenticationEntryPoint entryPoint = this.entryPoints.get(authority); + if (entryPoint != null) { + AuthenticationException insufficient = new InsufficientAuthenticationException(ex.getMessage(), ex); + entryPoint.commence(request, response, insufficient); + return; + } + } + this.defaults.handle(request, response, ex); + } + + private Collection authorizationRequest(AccessDeniedException access) { + if (!(access instanceof AuthorizationDeniedException denied)) { + return null; + } + if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision decision)) { + return null; + } + return decision.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(); + } + + } + + private static final class PostAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final String entryPointUri; + + private final Map> params; + + private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder + .getContextHolderStrategy(); + + private RedirectStrategy redirectStrategy = new FormPostRedirectStrategy(); + + private PostAuthenticationEntryPoint(String entryPointUri, + Map> params) { + this.entryPointUri = entryPointUri; + this.params = params; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + Authentication authentication = getAuthentication(authException); + Assert.notNull(authentication, "could not find authentication in order to perform post"); + Map params = this.params.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, (entry) -> entry.getValue().apply(authentication))); + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(this.entryPointUri); + CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrf != null) { + builder.queryParam(csrf.getParameterName(), csrf.getToken()); + } + String entryPointUrl = builder.build(false).expand(params).toUriString(); + this.redirectStrategy.sendRedirect(request, response, entryPointUrl); + } + + private Authentication getAuthentication(AuthenticationException authException) { + Authentication authentication = authException.getAuthenticationRequest(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication; + } + authentication = this.securityContextHolderStrategy.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication; + } + return null; + } + + } + } 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..59b1eaa2098 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 @@ -16,6 +16,12 @@ package org.springframework.security.config.annotation.web.configurers; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,6 +29,10 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authorization.AuthorityAuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.config.Customizer; import org.springframework.security.config.ObjectPostProcessor; import org.springframework.security.config.annotation.SecurityContextChangedListenerConfig; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -31,22 +41,32 @@ import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; import org.springframework.security.config.users.AuthenticationTestConfiguration; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextChangedListener; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.userdetails.PasswordEncodedUser; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; import org.springframework.security.web.PortMapper; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeastOnce; @@ -60,6 +80,7 @@ import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -378,6 +399,61 @@ public void configureWhenRegisteringObjectPostProcessorThenInvokedOnExceptionTra verify(ObjectPostProcessorConfig.objectPostProcessor).postProcess(any(ExceptionTranslationFilter.class)); } + @Test + void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { + this.spring.register(MfaDslConfig.class).autowire(); + UserDetails user = PasswordEncodedUser.user(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + this.mockMvc + .perform(post("/ott/generate").param("username", "user") + .with(SecurityMockMvcRequestPostProcessors.user(user)) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/ott/sent")); + this.mockMvc + .perform(post("/login").param("username", user.getUsername()) + .param("password", user.getPassword()) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")); + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_OTT").build(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("/ott/generate"))); + user = PasswordEncodedUser.withUserDetails(user) + .authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") + .build(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().isNotFound()); + } + + @Test + void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception { + this.spring.register(MfaDslX509Config.class).autowire(); + this.mockMvc.perform(get("/")).andExpect(status().isForbidden()); + this.mockMvc.perform(get("/login")).andExpect(status().isOk()); + this.mockMvc.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + UserDetails user = PasswordEncodedUser.withUsername("rod") + .password("password") + .authorities("AUTHN_FORM") + .build(); + this.mockMvc + .perform(post("/login").param("username", user.getUsername()) + .param("password", user.getPassword()) + .with(SecurityMockMvcRequestPostProcessors.x509("rod.cer")) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")); + } + @Configuration @EnableWebSecurity static class RequestCacheConfig { @@ -714,4 +790,90 @@ public O postProcess(O object) { } + @Configuration + @EnableWebSecurity + static class MfaDslConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()) + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/profile").access( + new HasAllAuthoritiesAuthorizationManager<>("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") + ) + .anyRequest().access(new HasAllAuthoritiesAuthorizationManager<>("FACTOR_PASSWORD", "FACTOR_OTT")) + ); + return http.build(); + // @formatter:on + } + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager(PasswordEncodedUser.user()); + } + + @Bean + PasswordEncoder encoder() { + return NoOpPasswordEncoder.getInstance(); + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } + + } + + @Configuration + @EnableWebSecurity + static class MfaDslX509Config { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .formLogin(Customizer.withDefaults()) + .x509(Customizer.withDefaults()) + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().access( + new HasAllAuthoritiesAuthorizationManager<>("FACTOR_X509", "FACTOR_PASSWORD") + ) + ); + return http.build(); + // @formatter:on + } + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager( + PasswordEncodedUser.withUsername("rod").password("{noop}password").build()); + } + + } + + private static final class HasAllAuthoritiesAuthorizationManager implements AuthorizationManager { + + private final Collection authorities; + + private HasAllAuthoritiesAuthorizationManager(String... authorities) { + this.authorities = List.of(authorities); + } + + @Override + public @Nullable AuthorizationResult authorize(Supplier authentication, C object) { + List authorities = authentication.get() + .getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority) + .toList(); + List needed = new ArrayList<>(this.authorities); + needed.removeIf(authorities::contains); + return new AuthorityAuthorizationDecision(needed.isEmpty(), AuthorityUtils.createAuthorityList(needed)); + } + + } + } From 10b438807b43d4876a9adb9c2a427f5065601957 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 3 Sep 2025 15:36:09 -0600 Subject: [PATCH 2/9] Redirect to Appropriate Entry Point Based on Missing Authorities Issue gh-17934 --- .../ExceptionHandlingConfigurer.java | 233 ++++++++++++------ .../web/configurers/FormLoginConfigurer.java | 4 + .../web/configurers/WebAuthnConfigurer.java | 10 + .../web/configurers/X509Configurer.java | 3 +- .../oauth2/client/OAuth2LoginConfigurer.java | 5 + .../OAuth2ResourceServerConfigurer.java | 4 + .../ott/OneTimeTokenLoginConfigurer.java | 67 +++++ .../saml2/Saml2LoginConfigurer.java | 5 + .../access/ExceptionTranslationFilter.java | 3 +- 9 files changed, 258 insertions(+), 76 deletions(-) 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 22c9219a725..e88ed029109 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 @@ -19,9 +19,8 @@ import java.io.IOException; import java.util.Collection; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -35,29 +34,22 @@ 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.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.context.SecurityContextHolderStrategy; -import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.FormPostRedirectStrategy; -import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandlerImpl; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler; import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint; import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; -import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; -import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter; -import org.springframework.security.web.csrf.CsrfToken; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; +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.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; -import org.springframework.web.util.UriComponentsBuilder; /** * Adds exception handling for Spring Security related exceptions to an application. All @@ -102,6 +94,8 @@ public final class ExceptionHandlingConfigurer> private LinkedHashMap defaultDeniedHandlerMappings = new LinkedHashMap<>(); + private Map> entryPoints = new LinkedHashMap<>(); + /** * Creates a new instance * @see HttpSecurity#exceptionHandling(Customizer) @@ -195,6 +189,26 @@ public ExceptionHandlingConfigurer defaultAuthenticationEntryPointFor(Authent return this; } + public ExceptionHandlingConfigurer defaultAuthenticationEntryPointFor(AuthenticationEntryPoint entryPoint, + RequestMatcher preferredMatcher, String authority) { + this.defaultEntryPointMappings.put(preferredMatcher, entryPoint); + LinkedHashMap byMatcher = this.entryPoints.get(authority); + if (byMatcher == null) { + byMatcher = new LinkedHashMap<>(); + } + byMatcher.put(preferredMatcher, entryPoint); + this.entryPoints.put(authority, byMatcher); + return this; + } + + public ExceptionHandlingConfigurer defaultAuthenticationEntryPointFor(AuthenticationEntryPoint entryPoint, + String authority) { + LinkedHashMap byMatcher = new LinkedHashMap<>(); + byMatcher.put(AnyRequestMatcher.INSTANCE, entryPoint); + this.entryPoints.put(authority, byMatcher); + return this; + } + /** * Gets any explicitly configured {@link AuthenticationEntryPoint} * @return @@ -254,21 +268,60 @@ AuthenticationEntryPoint getAuthenticationEntryPoint(H http) { } private AccessDeniedHandler createDefaultDeniedHandler(H http) { + AccessDeniedHandler defaults = createDefaultAccessDeniedHandler(http); + if (this.entryPoints.isEmpty()) { + return defaults; + } + Map deniedHandlers = new LinkedHashMap<>(); + for (Map.Entry> entry : this.entryPoints + .entrySet()) { + AuthenticationEntryPoint entryPoint = entryPointFrom(entry.getValue()); + AuthenticationEntryPointAccessDeniedHandlerAdapter deniedHandler = new AuthenticationEntryPointAccessDeniedHandlerAdapter( + entryPoint); + RequestCache requestCache = http.getSharedObject(RequestCache.class); + if (requestCache != null) { + deniedHandler.setRequestCache(requestCache); + } + deniedHandlers.put(entry.getKey(), deniedHandler); + } + return new AuthenticationFactorDelegatingAccessDeniedHandler(deniedHandlers, defaults); + } + + private AccessDeniedHandler createDefaultAccessDeniedHandler(H http) { if (this.defaultDeniedHandlerMappings.isEmpty()) { - return new AuthenticationFactorDelegatingAccessDeniedHandler(); + return new AccessDeniedHandlerImpl(); } if (this.defaultDeniedHandlerMappings.size() == 1) { return this.defaultDeniedHandlerMappings.values().iterator().next(); } return new RequestMatcherDelegatingAccessDeniedHandler(this.defaultDeniedHandlerMappings, - new AuthenticationFactorDelegatingAccessDeniedHandler()); + new AccessDeniedHandlerImpl()); } private AuthenticationEntryPoint createDefaultEntryPoint(H http) { - if (this.defaultEntryPoint == null) { + AuthenticationEntryPoint defaults = entryPointFrom(this.defaultEntryPointMappings); + if (this.entryPoints.isEmpty()) { + return defaults; + } + Map entryPoints = new LinkedHashMap<>(); + for (Map.Entry> entry : this.entryPoints + .entrySet()) { + entryPoints.put(entry.getKey(), entryPointFrom(entry.getValue())); + } + return new AuthenticationFactorDelegatingAuthenticationEntryPoint(entryPoints, defaults); + } + + private AuthenticationEntryPoint entryPointFrom( + LinkedHashMap entryPoints) { + if (entryPoints.isEmpty()) { return new Http403ForbiddenEntryPoint(); } - return this.defaultEntryPoint.build(); + if (entryPoints.size() == 1) { + return entryPoints.values().iterator().next(); + } + DelegatingAuthenticationEntryPoint entryPoint = new DelegatingAuthenticationEntryPoint(entryPoints); + entryPoint.setDefaultEntryPoint(entryPoints.values().iterator().next()); + return entryPoint; } /** @@ -287,94 +340,126 @@ private RequestCache getRequestCache(H http) { return new HttpSessionRequestCache(); } - private static final class AuthenticationFactorDelegatingAccessDeniedHandler implements AccessDeniedHandler { + private static final class AuthenticationFactorDelegatingAuthenticationEntryPoint + implements AuthenticationEntryPoint { + + private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer(); - private final Map entryPoints = Map.of("FACTOR_PASSWORD", - new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_AUTHORIZATION_CODE", - new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_SAML_RESPONSE", - new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_WEBAUTHN", - new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_BEARER", - new BearerTokenAuthenticationEntryPoint(), "FACTOR_OTT", - new PostAuthenticationEntryPoint(GenerateOneTimeTokenFilter.DEFAULT_GENERATE_URL + "?username={u}", - Map.of("u", Authentication::getName))); + private final Map entryPoints; - private final AccessDeniedHandler defaults = new AccessDeniedHandlerImpl(); + private final AuthenticationEntryPoint defaults; + + private AuthenticationFactorDelegatingAuthenticationEntryPoint( + Map entryPoints, AuthenticationEntryPoint defaults) { + this.entryPoints = new LinkedHashMap<>(entryPoints); + this.defaults = defaults; + } @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) throws IOException, ServletException { - Collection needed = authorizationRequest(ex); - if (needed == null) { - this.defaults.handle(request, response, ex); - return; + Collection authorization = authorizationRequest(ex); + entryPoint(authorization).commence(request, response, ex); + } + + private AuthenticationEntryPoint entryPoint(Collection authorities) { + if (authorities == null) { + return this.defaults; } - for (String authority : needed) { - AuthenticationEntryPoint entryPoint = this.entryPoints.get(authority); + for (GrantedAuthority needed : authorities) { + AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority()); if (entryPoint != null) { - AuthenticationException insufficient = new InsufficientAuthenticationException(ex.getMessage(), ex); - entryPoint.commence(request, response, insufficient); - return; + return entryPoint; } } - this.defaults.handle(request, response, ex); + return this.defaults; } - private Collection authorizationRequest(AccessDeniedException access) { - if (!(access instanceof AuthorizationDeniedException denied)) { - return null; + private Collection authorizationRequest(Exception ex) { + Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex); + AuthorizationDeniedException denied = (AuthorizationDeniedException) this.throwableAnalyzer + .getFirstThrowableOfType(AuthorizationDeniedException.class, chain); + if (denied == null) { + return List.of(); } - if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision decision)) { - return null; + if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) { + return List.of(); } - return decision.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(); + return authorization.getAuthorities(); } } - private static final class PostAuthenticationEntryPoint implements AuthenticationEntryPoint { + private static final class AuthenticationEntryPointAccessDeniedHandlerAdapter implements AccessDeniedHandler { + + private final AuthenticationEntryPoint entryPoint; + + private RequestCache requestCache = new NullRequestCache(); + + private AuthenticationEntryPointAccessDeniedHandlerAdapter(AuthenticationEntryPoint entryPoint) { + this.entryPoint = entryPoint; + } + + void setRequestCache(RequestCache requestCache) { + Assert.notNull(requestCache, "requestCache cannot be null"); + this.requestCache = requestCache; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied) + throws IOException, ServletException { + AuthenticationException ex = new InsufficientAuthenticationException("access denied", denied); + this.requestCache.saveRequest(request, response); + this.entryPoint.commence(request, response, ex); + } - private final String entryPointUri; + } + + private static final class AuthenticationFactorDelegatingAccessDeniedHandler implements AccessDeniedHandler { - private final Map> params; + private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer(); - private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder - .getContextHolderStrategy(); + private final Map deniedHandlers; - private RedirectStrategy redirectStrategy = new FormPostRedirectStrategy(); + private final AccessDeniedHandler defaults; - private PostAuthenticationEntryPoint(String entryPointUri, - Map> params) { - this.entryPointUri = entryPointUri; - this.params = params; + private AuthenticationFactorDelegatingAccessDeniedHandler(Map deniedHandlers, + AccessDeniedHandler defaults) { + this.deniedHandlers = new LinkedHashMap<>(deniedHandlers); + this.defaults = defaults; } @Override - public void commence(HttpServletRequest request, HttpServletResponse response, - AuthenticationException authException) throws IOException, ServletException { - Authentication authentication = getAuthentication(authException); - Assert.notNull(authentication, "could not find authentication in order to perform post"); - Map params = this.params.entrySet() - .stream() - .collect(Collectors.toMap(Map.Entry::getKey, (entry) -> entry.getValue().apply(authentication))); - UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(this.entryPointUri); - CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); - if (csrf != null) { - builder.queryParam(csrf.getParameterName(), csrf.getToken()); + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) + throws IOException, ServletException { + Collection authorization = authorizationRequest(ex); + deniedHandler(authorization).handle(request, response, ex); + } + + private AccessDeniedHandler deniedHandler(Collection authorities) { + if (authorities == null) { + return this.defaults; + } + for (GrantedAuthority needed : authorities) { + AccessDeniedHandler deniedHandler = this.deniedHandlers.get(needed.getAuthority()); + if (deniedHandler != null) { + return deniedHandler; + } } - String entryPointUrl = builder.build(false).expand(params).toUriString(); - this.redirectStrategy.sendRedirect(request, response, entryPointUrl); + return this.defaults; } - private Authentication getAuthentication(AuthenticationException authException) { - Authentication authentication = authException.getAuthenticationRequest(); - if (authentication != null && authentication.isAuthenticated()) { - return authentication; + private Collection authorizationRequest(Exception ex) { + Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex); + AuthorizationDeniedException denied = (AuthorizationDeniedException) this.throwableAnalyzer + .getFirstThrowableOfType(AuthorizationDeniedException.class, chain); + if (denied == null) { + return List.of(); } - authentication = this.securityContextHolderStrategy.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated()) { - return authentication; + if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) { + return List.of(); } - return null; + return authorization.getAuthorities(); } } 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..e3014bcab79 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,10 @@ 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) { + exceptions.defaultAuthenticationEntryPointFor(getAuthenticationEntryPoint(), "FACTOR_PASSWORD"); + } } @Override 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..90538cc79f7 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 @@ -28,6 +28,7 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.core.userdetails.UserDetailsService; 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; @@ -150,6 +151,15 @@ public WebAuthnConfigurer creationOptionsRepository( return this; } + @Override + public void init(H http) throws Exception { + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), + "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..4b1db122ec4 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,8 @@ 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); + exceptions.defaultAuthenticationEntryPointFor(new Http403ForbiddenEntryPoint(), 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..e6cea04b6f1 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; @@ -372,6 +373,10 @@ public void init(B http) throws Exception { http.authenticationProvider(new OidcAuthenticationRequestChecker()); } this.initDefaultLoginFilter(http); + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + exceptions.defaultAuthenticationEntryPointFor(getAuthenticationEntryPoint(), "FACTOR_AUTHORIZATION_CODE"); + } } @Override 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..79c0d8a3aa4 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 @@ -259,6 +259,10 @@ public void init(H http) { if (authenticationProvider != null) { http.authenticationProvider(authenticationProvider); } + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + exceptions.defaultAuthenticationEntryPointFor(this.authenticationEntryPoint, "FACTOR_BEARER"); + } } @Override 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..1d04d1f23de 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 @@ -16,10 +16,15 @@ package org.springframework.security.config.annotation.web.configurers.ott; +import java.io.IOException; import java.util.Collections; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpMethod; @@ -35,8 +40,15 @@ 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.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.FormPostRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @@ -55,6 +67,7 @@ import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; /** * An {@link AbstractHttpConfigurer} for One-Time Token Login. @@ -134,6 +147,12 @@ 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 = new PostAuthenticationEntryPoint( + this.tokenGeneratingUrl + "?username={u}", Map.of("u", Authentication::getName)); + exceptions.defaultAuthenticationEntryPointFor(entryPoint, "FACTOR_OTT"); + } } private void intiDefaultLoginFilter(H http) { @@ -391,4 +410,52 @@ public ApplicationContext getContext() { return this.context; } + private static final class PostAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final String entryPointUri; + + private final Map> params; + + private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder + .getContextHolderStrategy(); + + private RedirectStrategy redirectStrategy = new FormPostRedirectStrategy(); + + private PostAuthenticationEntryPoint(String entryPointUri, + Map> params) { + this.entryPointUri = entryPointUri; + this.params = params; + } + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + Authentication authentication = getAuthentication(authException); + Assert.notNull(authentication, "could not find authentication in order to perform post"); + Map params = this.params.entrySet() + .stream() + .collect(Collectors.toMap(Map.Entry::getKey, (entry) -> entry.getValue().apply(authentication))); + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(this.entryPointUri); + CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (csrf != null) { + builder.queryParam(csrf.getParameterName(), csrf.getToken()); + } + String entryPointUrl = builder.build(false).expand(params).toUriString(); + this.redirectStrategy.sendRedirect(request, response, entryPointUrl); + } + + private Authentication getAuthentication(AuthenticationException authException) { + Authentication authentication = authException.getAuthenticationRequest(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication; + } + authentication = this.securityContextHolderStrategy.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated()) { + return authentication; + } + return null; + } + + } + } 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..93d3d08a5d9 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; @@ -304,6 +305,10 @@ public void init(B http) throws Exception { if (this.authenticationManager == null) { registerDefaultAuthenticationProvider(http); } + ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); + if (exceptions != null) { + exceptions.defaultAuthenticationEntryPointFor(getAuthenticationEntryPoint(), "FACTOR_SAML_RESPONSE"); + } } /** 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); } From c88e2a7bdfc169d7f49bf6794e488217bd4ecfe8 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:02:29 -0600 Subject: [PATCH 3/9] Update Test for Method Security Issue gh-17936 --- .../configurers/FormLoginConfigurerTests.java | 151 ++++++++++-------- 1 file changed, 83 insertions(+), 68 deletions(-) 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 59b1eaa2098..f786a619bf1 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 @@ -16,47 +16,43 @@ package org.springframework.security.config.annotation.web.configurers; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.function.Supplier; - -import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; 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.AuthorityAuthorizationDecision; +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.AuthorizationResult; +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; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; import org.springframework.security.config.users.AuthenticationTestConfiguration; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextChangedListener; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.userdetails.PasswordEncodedUser; +import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.crypto.password.NoOpPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; import org.springframework.security.web.PortMapper; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -64,6 +60,8 @@ 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.hamcrest.Matchers.containsString; @@ -77,6 +75,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; @@ -401,57 +400,58 @@ public void configureWhenRegisteringObjectPostProcessorThenInvokedOnExceptionTra @Test void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { - this.spring.register(MfaDslConfig.class).autowire(); + this.spring.register(MfaDslConfig.class, UserConfig.class).autowire(); UserDetails user = PasswordEncodedUser.user(); - this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + this.mockMvc.perform(get("/profile").with(user(user))) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("http://localhost/login")); this.mockMvc - .perform(post("/ott/generate").param("username", "user") - .with(SecurityMockMvcRequestPostProcessors.user(user)) + .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", user.getUsername()) - .param("password", user.getPassword()) + .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(SecurityMockMvcRequestPostProcessors.user(user))) + this.mockMvc.perform(get("/profile").with(user(user))) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("http://localhost/login")); user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build(); - this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + this.mockMvc.perform(get("/profile").with(user(user))) .andExpect(status().isOk()) .andExpect(content().string(containsString("/ott/generate"))); user = PasswordEncodedUser.withUserDetails(user) .authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") .build(); - this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) - .andExpect(status().isNotFound()); + this.mockMvc.perform(get("/profile").with(user(user))).andExpect(status().isNotFound()); } @Test void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception { - this.spring.register(MfaDslX509Config.class).autowire(); - this.mockMvc.perform(get("/")).andExpect(status().isForbidden()); + this.spring.register(MfaDslX509Config.class, UserConfig.class, org.springframework.security.config.annotation.web.configurers.FormLoginConfigurerTests.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("/").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("http://localhost/login")); - UserDetails user = PasswordEncodedUser.withUsername("rod") - .password("password") - .authorities("AUTHN_FORM") - .build(); this.mockMvc - .perform(post("/login").param("username", user.getUsername()) - .param("password", user.getPassword()) + .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 @@ -795,83 +795,98 @@ public O postProcess(O object) { static class MfaDslConfig { @Bean - SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + SecurityFilterChain filterChain(HttpSecurity http, AuthorizationManagerFactory authz) throws Exception { // @formatter:off http .formLogin(Customizer.withDefaults()) .oneTimeTokenLogin(Customizer.withDefaults()) .authorizeHttpRequests((authorize) -> authorize - .requestMatchers("/profile").access( - new HasAllAuthoritiesAuthorizationManager<>("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") - ) - .anyRequest().access(new HasAllAuthoritiesAuthorizationManager<>("FACTOR_PASSWORD", "FACTOR_OTT")) + .requestMatchers("/profile").access(authz.hasAuthority("profile:read")) + .anyRequest().access(authz.authenticated()) ); return http.build(); // @formatter:on } @Bean - UserDetailsService users() { - return new InMemoryUserDetailsManager(PasswordEncodedUser.user()); - } - - @Bean - PasswordEncoder encoder() { - return NoOpPasswordEncoder.getInstance(); + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); } @Bean - OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { - return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + AuthorizationManagerFactory authz() { + return new AuthorizationManagerFactory<>("FACTOR_PASSWORD", "FACTOR_OTT"); } } @Configuration @EnableWebSecurity + @EnableMethodSecurity static class MfaDslX509Config { @Bean - SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + SecurityFilterChain filterChain(HttpSecurity http, AuthorizationManagerFactory authz) throws Exception { // @formatter:off http - .formLogin(Customizer.withDefaults()) .x509(Customizer.withDefaults()) + .formLogin(Customizer.withDefaults()) .authorizeHttpRequests((authorize) -> authorize - .anyRequest().access( - new HasAllAuthoritiesAuthorizationManager<>("FACTOR_X509", "FACTOR_PASSWORD") - ) + .anyRequest().access(authz.authenticated()) ); return http.build(); // @formatter:on } @Bean - UserDetailsService users() { - return new InMemoryUserDetailsManager( - PasswordEncodedUser.withUsername("rod").password("{noop}password").build()); + AuthorizationManagerFactory authz() { + return new AuthorizationManagerFactory<>("FACTOR_X509", "FACTOR_PASSWORD"); } } - private static final class HasAllAuthoritiesAuthorizationManager implements AuthorizationManager { + @Configuration + static class UserConfig { - private final Collection authorities; + @Bean + UserDetails rod() { + return PasswordEncodedUser.withUsername("rod").password("password").build(); + } - private HasAllAuthoritiesAuthorizationManager(String... authorities) { - this.authorities = List.of(authorities); + @Bean + UserDetailsService users(UserDetails user) { + return new InMemoryUserDetailsManager(user); } - @Override - public @Nullable AuthorizationResult authorize(Supplier authentication, C object) { - List authorities = authentication.get() - .getAuthorities() - .stream() - .map(GrantedAuthority::getAuthority) - .toList(); - List needed = new ArrayList<>(this.authorities); - needed.removeIf(authorities::contains); - return new AuthorityAuthorizationDecision(needed.isEmpty(), AuthorityUtils.createAuthorityList(needed)); + } + + @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); } } From 908659c7ebf5cf8f47fc6a8db5b887c3c8253657 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Mon, 15 Sep 2025 18:44:10 -0600 Subject: [PATCH 4/9] Make Public Missing Authority AccessDeniedHandler Issue gh-17934 --- .../ExceptionHandlingConfigurer.java | 239 +++--------------- .../web/configurers/FormLoginConfigurer.java | 5 +- .../web/configurers/HttpBasicConfigurer.java | 6 +- .../web/configurers/WebAuthnConfigurer.java | 7 +- .../web/configurers/X509Configurer.java | 6 +- .../oauth2/client/OAuth2LoginConfigurer.java | 13 +- .../OAuth2ResourceServerConfigurer.java | 6 +- .../ott/OneTimeTokenLoginConfigurer.java | 66 +---- .../saml2/Saml2LoginConfigurer.java | 13 +- .../configurers/FormLoginConfigurerTests.java | 6 +- ...ngMissingAuthorityAccessDeniedHandler.java | 223 ++++++++++++++++ ...singAuthorityAccessDeniedHandlerTests.java | 145 +++++++++++ 12 files changed, 450 insertions(+), 285 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java create mode 100644 web/src/test/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandlerTests.java 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 e88ed029109..295a33e2e22 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 @@ -16,40 +16,26 @@ package org.springframework.security.config.annotation.web.configurers; -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.config.Customizer; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.core.AuthenticationException; 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; import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; -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.security.web.util.matcher.RequestMatcher; -import org.springframework.util.Assert; /** * Adds exception handling for Spring Security related exceptions to an application. All @@ -94,7 +80,8 @@ public final class ExceptionHandlingConfigurer> private LinkedHashMap defaultDeniedHandlerMappings = new LinkedHashMap<>(); - private Map> entryPoints = new LinkedHashMap<>(); + private final DelegatingMissingAuthorityAccessDeniedHandler.Builder missingAuthoritiesHandlerBuilder = DelegatingMissingAuthorityAccessDeniedHandler + .builder(); /** * Creates a new instance @@ -146,6 +133,37 @@ 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 defaultAuthenticationEntryPointFor(AuthenticationEntryPoint entryPoint, + String authority) { + 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 defaultAuthenticationEntryPointFor( + Consumer entryPoint, String authority) { + this.missingAuthoritiesHandlerBuilder.addEntryPointFor(entryPoint, authority); + return this; + } + /** * Sets the {@link AuthenticationEntryPoint} to be used. * @@ -189,26 +207,6 @@ public ExceptionHandlingConfigurer defaultAuthenticationEntryPointFor(Authent return this; } - public ExceptionHandlingConfigurer defaultAuthenticationEntryPointFor(AuthenticationEntryPoint entryPoint, - RequestMatcher preferredMatcher, String authority) { - this.defaultEntryPointMappings.put(preferredMatcher, entryPoint); - LinkedHashMap byMatcher = this.entryPoints.get(authority); - if (byMatcher == null) { - byMatcher = new LinkedHashMap<>(); - } - byMatcher.put(preferredMatcher, entryPoint); - this.entryPoints.put(authority, byMatcher); - return this; - } - - public ExceptionHandlingConfigurer defaultAuthenticationEntryPointFor(AuthenticationEntryPoint entryPoint, - String authority) { - LinkedHashMap byMatcher = new LinkedHashMap<>(); - byMatcher.put(AnyRequestMatcher.INSTANCE, entryPoint); - this.entryPoints.put(authority, byMatcher); - return this; - } - /** * Gets any explicitly configured {@link AuthenticationEntryPoint} * @return @@ -269,22 +267,10 @@ AuthenticationEntryPoint getAuthenticationEntryPoint(H http) { private AccessDeniedHandler createDefaultDeniedHandler(H http) { AccessDeniedHandler defaults = createDefaultAccessDeniedHandler(http); - if (this.entryPoints.isEmpty()) { - return defaults; - } - Map deniedHandlers = new LinkedHashMap<>(); - for (Map.Entry> entry : this.entryPoints - .entrySet()) { - AuthenticationEntryPoint entryPoint = entryPointFrom(entry.getValue()); - AuthenticationEntryPointAccessDeniedHandlerAdapter deniedHandler = new AuthenticationEntryPointAccessDeniedHandlerAdapter( - entryPoint); - RequestCache requestCache = http.getSharedObject(RequestCache.class); - if (requestCache != null) { - deniedHandler.setRequestCache(requestCache); - } - deniedHandlers.put(entry.getKey(), deniedHandler); - } - return new AuthenticationFactorDelegatingAccessDeniedHandler(deniedHandlers, defaults); + DelegatingMissingAuthorityAccessDeniedHandler deniedHandler = this.missingAuthoritiesHandlerBuilder.build(); + deniedHandler.setRequestCache(getRequestCache(http)); + deniedHandler.setDefaultAccessDeniedHandler(defaults); + return deniedHandler; } private AccessDeniedHandler createDefaultAccessDeniedHandler(H http) { @@ -299,29 +285,10 @@ private AccessDeniedHandler createDefaultAccessDeniedHandler(H http) { } private AuthenticationEntryPoint createDefaultEntryPoint(H http) { - AuthenticationEntryPoint defaults = entryPointFrom(this.defaultEntryPointMappings); - if (this.entryPoints.isEmpty()) { - return defaults; - } - Map entryPoints = new LinkedHashMap<>(); - for (Map.Entry> entry : this.entryPoints - .entrySet()) { - entryPoints.put(entry.getKey(), entryPointFrom(entry.getValue())); - } - return new AuthenticationFactorDelegatingAuthenticationEntryPoint(entryPoints, defaults); - } - - private AuthenticationEntryPoint entryPointFrom( - LinkedHashMap entryPoints) { - if (entryPoints.isEmpty()) { + if (this.defaultEntryPoint == null) { return new Http403ForbiddenEntryPoint(); } - if (entryPoints.size() == 1) { - return entryPoints.values().iterator().next(); - } - DelegatingAuthenticationEntryPoint entryPoint = new DelegatingAuthenticationEntryPoint(entryPoints); - entryPoint.setDefaultEntryPoint(entryPoints.values().iterator().next()); - return entryPoint; + return this.defaultEntryPoint.build(); } /** @@ -340,128 +307,4 @@ private RequestCache getRequestCache(H http) { return new HttpSessionRequestCache(); } - private static final class AuthenticationFactorDelegatingAuthenticationEntryPoint - implements AuthenticationEntryPoint { - - private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer(); - - private final Map entryPoints; - - private final AuthenticationEntryPoint defaults; - - private AuthenticationFactorDelegatingAuthenticationEntryPoint( - Map entryPoints, AuthenticationEntryPoint defaults) { - this.entryPoints = new LinkedHashMap<>(entryPoints); - this.defaults = defaults; - } - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) - throws IOException, ServletException { - Collection authorization = authorizationRequest(ex); - entryPoint(authorization).commence(request, response, ex); - } - - private AuthenticationEntryPoint entryPoint(Collection authorities) { - if (authorities == null) { - return this.defaults; - } - for (GrantedAuthority needed : authorities) { - AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority()); - if (entryPoint != null) { - return entryPoint; - } - } - return this.defaults; - } - - private Collection authorizationRequest(Exception ex) { - Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex); - AuthorizationDeniedException denied = (AuthorizationDeniedException) this.throwableAnalyzer - .getFirstThrowableOfType(AuthorizationDeniedException.class, chain); - if (denied == null) { - return List.of(); - } - if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) { - return List.of(); - } - return authorization.getAuthorities(); - } - - } - - private static final class AuthenticationEntryPointAccessDeniedHandlerAdapter implements AccessDeniedHandler { - - private final AuthenticationEntryPoint entryPoint; - - private RequestCache requestCache = new NullRequestCache(); - - private AuthenticationEntryPointAccessDeniedHandlerAdapter(AuthenticationEntryPoint entryPoint) { - this.entryPoint = entryPoint; - } - - void setRequestCache(RequestCache requestCache) { - Assert.notNull(requestCache, "requestCache cannot be null"); - this.requestCache = requestCache; - } - - @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied) - throws IOException, ServletException { - AuthenticationException ex = new InsufficientAuthenticationException("access denied", denied); - this.requestCache.saveRequest(request, response); - this.entryPoint.commence(request, response, ex); - } - - } - - private static final class AuthenticationFactorDelegatingAccessDeniedHandler implements AccessDeniedHandler { - - private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer(); - - private final Map deniedHandlers; - - private final AccessDeniedHandler defaults; - - private AuthenticationFactorDelegatingAccessDeniedHandler(Map deniedHandlers, - AccessDeniedHandler defaults) { - this.deniedHandlers = new LinkedHashMap<>(deniedHandlers); - this.defaults = defaults; - } - - @Override - public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) - throws IOException, ServletException { - Collection authorization = authorizationRequest(ex); - deniedHandler(authorization).handle(request, response, ex); - } - - private AccessDeniedHandler deniedHandler(Collection authorities) { - if (authorities == null) { - return this.defaults; - } - for (GrantedAuthority needed : authorities) { - AccessDeniedHandler deniedHandler = this.deniedHandlers.get(needed.getAuthority()); - if (deniedHandler != null) { - return deniedHandler; - } - } - return this.defaults; - } - - private Collection authorizationRequest(Exception ex) { - Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex); - AuthorizationDeniedException denied = (AuthorizationDeniedException) this.throwableAnalyzer - .getFirstThrowableOfType(AuthorizationDeniedException.class, chain); - if (denied == null) { - return List.of(); - } - if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) { - return List.of(); - } - return authorization.getAuthorities(); - } - - } - } 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 e3014bcab79..7ef16749b9d 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 @@ -233,7 +233,10 @@ public void init(H http) throws Exception { initDefaultLoginFilter(http); ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); if (exceptions != null) { - exceptions.defaultAuthenticationEntryPointFor(getAuthenticationEntryPoint(), "FACTOR_PASSWORD"); + AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint(); + RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http); + exceptions.defaultAuthenticationEntryPointFor((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher), + "FACTOR_PASSWORD"); } } 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..8f32dc9a7e8 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.defaultAuthenticationEntryPointFor((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 90538cc79f7..e980242a0c3 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,12 +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; @@ -155,8 +157,9 @@ public WebAuthnConfigurer creationOptionsRepository( public void init(H http) throws Exception { ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); if (exceptions != null) { - exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), - "FACTOR_WEBAUTHN"); + AuthenticationEntryPoint entryPoint = new LoginUrlAuthenticationEntryPoint("/login"); + exceptions.defaultAuthenticationEntryPointFor( + (ep) -> ep.addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE), "FACTOR_WEBAUTHN"); } } 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 4b1db122ec4..374f9646d59 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,8 +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, - "FACTOR_X509"); + AuthenticationEntryPoint forbidden = new Http403ForbiddenEntryPoint(); + exceptions.defaultAuthenticationEntryPointFor(forbidden, AnyRequestMatcher.INSTANCE); + exceptions.defaultAuthenticationEntryPointFor( + (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 e6cea04b6f1..800ea9f7c75 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 @@ -373,10 +373,6 @@ public void init(B http) throws Exception { http.authenticationProvider(new OidcAuthenticationRequestChecker()); } this.initDefaultLoginFilter(http); - ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); - if (exceptions != null) { - exceptions.defaultAuthenticationEntryPointFor(getAuthenticationEntryPoint(), "FACTOR_AUTHORIZATION_CODE"); - } } @Override @@ -561,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.defaultAuthenticationEntryPointFor((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 79c0d8a3aa4..8448be46485 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 @@ -259,10 +259,6 @@ public void init(H http) { if (authenticationProvider != null) { http.authenticationProvider(authenticationProvider); } - ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); - if (exceptions != null) { - exceptions.defaultAuthenticationEntryPointFor(this.authenticationEntryPoint, "FACTOR_BEARER"); - } } @Override @@ -331,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.defaultAuthenticationEntryPointFor( + (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 1d04d1f23de..e9ee9b7d7fa 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 @@ -16,15 +16,10 @@ package org.springframework.security.config.annotation.web.configurers.ott; -import java.io.IOException; import java.util.Collections; import java.util.Map; -import java.util.function.Function; -import java.util.stream.Collectors; -import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpMethod; @@ -42,13 +37,8 @@ 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.AuthenticationException; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.FormPostRedirectStrategy; -import org.springframework.security.web.RedirectStrategy; import org.springframework.security.web.authentication.AuthenticationConverter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @@ -67,7 +57,6 @@ import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; import org.springframework.util.StringUtils; -import org.springframework.web.util.UriComponentsBuilder; /** * An {@link AbstractHttpConfigurer} for One-Time Token Login. @@ -149,9 +138,10 @@ public void init(H http) throws Exception { intiDefaultLoginFilter(http); ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); if (exceptions != null) { - AuthenticationEntryPoint entryPoint = new PostAuthenticationEntryPoint( - this.tokenGeneratingUrl + "?username={u}", Map.of("u", Authentication::getName)); - exceptions.defaultAuthenticationEntryPointFor(entryPoint, "FACTOR_OTT"); + AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint(); + RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http); + exceptions.defaultAuthenticationEntryPointFor((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher), + "FACTOR_OTT"); } } @@ -410,52 +400,4 @@ public ApplicationContext getContext() { return this.context; } - private static final class PostAuthenticationEntryPoint implements AuthenticationEntryPoint { - - private final String entryPointUri; - - private final Map> params; - - private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder - .getContextHolderStrategy(); - - private RedirectStrategy redirectStrategy = new FormPostRedirectStrategy(); - - private PostAuthenticationEntryPoint(String entryPointUri, - Map> params) { - this.entryPointUri = entryPointUri; - this.params = params; - } - - @Override - public void commence(HttpServletRequest request, HttpServletResponse response, - AuthenticationException authException) throws IOException, ServletException { - Authentication authentication = getAuthentication(authException); - Assert.notNull(authentication, "could not find authentication in order to perform post"); - Map params = this.params.entrySet() - .stream() - .collect(Collectors.toMap(Map.Entry::getKey, (entry) -> entry.getValue().apply(authentication))); - UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(this.entryPointUri); - CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); - if (csrf != null) { - builder.queryParam(csrf.getParameterName(), csrf.getToken()); - } - String entryPointUrl = builder.build(false).expand(params).toUriString(); - this.redirectStrategy.sendRedirect(request, response, entryPointUrl); - } - - private Authentication getAuthentication(AuthenticationException authException) { - Authentication authentication = authException.getAuthenticationRequest(); - if (authentication != null && authentication.isAuthenticated()) { - return authentication; - } - authentication = this.securityContextHolderStrategy.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated()) { - return authentication; - } - return null; - } - - } - } 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 93d3d08a5d9..19dda33cd57 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 @@ -305,10 +305,6 @@ public void init(B http) throws Exception { if (this.authenticationManager == null) { registerDefaultAuthenticationProvider(http); } - ExceptionHandlingConfigurer exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class); - if (exceptions != null) { - exceptions.defaultAuthenticationEntryPointFor(getAuthenticationEntryPoint(), "FACTOR_SAML_RESPONSE"); - } } /** @@ -348,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.defaultAuthenticationEntryPointFor((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 f786a619bf1..a9f12cfcc92 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 @@ -64,7 +64,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.config.annotation.EnableWebMvc; -import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeastOnce; @@ -79,7 +78,6 @@ import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -423,8 +421,8 @@ void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { .andExpect(redirectedUrl("http://localhost/login")); user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build(); this.mockMvc.perform(get("/profile").with(user(user))) - .andExpect(status().isOk()) - .andExpect(content().string(containsString("/ott/generate"))); + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); user = PasswordEncodedUser.withUserDetails(user) .authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") .build(); 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..215ed6832cf --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java @@ -0,0 +1,223 @@ +/* + * 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.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; + +/** + * 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() + * .authorities("FACTOR_OTT").commence(new LoginUrlAuthenticationEntryPoint("/login")) + * .authorities("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) { + this.entryPoints = entryPoints; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied) + throws IOException, ServletException { + Collection authorities = missingAuthorities(denied); + AuthenticationEntryPoint entryPoint = entryPoint(authorities); + if (entryPoint == null) { + this.defaultAccessDeniedHandler.handle(request, response, denied); + return; + } + this.requestCache.saveRequest(request, response); + AuthenticationException ex = new InsufficientAuthenticationException("missing authorities", denied); + entryPoint.commence(request, response, ex); + } + + /** + * 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) { + 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) { + this.requestCache = requestCache; + } + + private @Nullable AuthenticationEntryPoint entryPoint(Collection authorities) { + for (GrantedAuthority needed : authorities) { + AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority()); + if (entryPoint == null) { + continue; + } + return entryPoint; + } + return null; + } + + 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 entryPointByRequestMatcherByAuthority = new LinkedHashMap<>(); + + private Builder() { + + } + + DelegatingAuthenticationEntryPoint.Builder entryPointBuilder(String authority) { + return this.entryPointByRequestMatcherByAuthority.computeIfAbsent(authority, + (k) -> DelegatingAuthenticationEntryPoint.builder()); + } + + void entryPoint(String authority, AuthenticationEntryPoint entryPoint) { + DelegatingAuthenticationEntryPoint.Builder builder = DelegatingAuthenticationEntryPoint.builder() + .addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE); + this.entryPointByRequestMatcherByAuthority.put(authority, builder); + } + + /** + * Bind these authorities to the given {@link AuthenticationEntryPoint} + * @param entryPoint the {@link AuthenticationEntryPoint} for the given + * authorities + * @param authorities the authorities + * @return the {@link Builder} for further configurations + */ + public Builder addEntryPointFor(AuthenticationEntryPoint entryPoint, String... authorities) { + for (String authority : authorities) { + Builder.this.entryPoint(authority, entryPoint); + } + return this; + } + + /** + * Bind these authorities to the given {@link AuthenticationEntryPoint} + * @param entryPoint a consumer to configure the underlying + * {@link DelegatingAuthenticationEntryPoint} + * @param authorities the authorities + * @return the {@link Builder} for further configurations + */ + public Builder addEntryPointFor(Consumer entryPoint, + String... authorities) { + for (String authority : authorities) { + entryPoint.accept(Builder.this.entryPointBuilder(authority)); + } + return this; + } + + public DelegatingMissingAuthorityAccessDeniedHandler build() { + Map entryPointByAuthority = new LinkedHashMap<>(); + for (String authority : this.entryPointByRequestMatcherByAuthority.keySet()) { + entryPointByAuthority.put(authority, this.entryPointByRequestMatcherByAuthority.get(authority).build()); + } + return new DelegatingMissingAuthorityAccessDeniedHandler(entryPointByAuthority); + } + + } + +} 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); + } + +} From e3fed3e8370d9b3fa378fe3670d651f7691ad322 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:17:02 -0600 Subject: [PATCH 5/9] Add Initial Documentation Issue gh-17934 --- .../servlet/authentication/adaptive.adoc | 528 ++++++++++++++++++ docs/modules/ROOT/pages/whats-new.adoc | 1 + 2 files changed, 529 insertions(+) create mode 100644 docs/modules/ROOT/pages/servlet/authentication/adaptive.adoc 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 From 209ebd22c426046967aeaa3c465cb36c2c389198 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:22:09 -0600 Subject: [PATCH 6/9] Support Showing One Part of Login Page Closes gh-17901 --- .../configurers/FormLoginConfigurerTests.java | 8 +-- .../security/core/GrantedAuthority.java | 2 + ...ngMissingAuthorityAccessDeniedHandler.java | 28 +++----- .../LoginUrlAuthenticationEntryPoint.java | 9 +++ .../ui/DefaultLoginPageGeneratingFilter.java | 67 ++++++++++++++++--- ...DefaultLoginPageGeneratingFilterTests.java | 55 +++++++++++++++ 6 files changed, 137 insertions(+), 32 deletions(-) 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 a9f12cfcc92..cb66974b72f 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 @@ -402,7 +402,7 @@ void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { UserDetails user = PasswordEncodedUser.user(); this.mockMvc.perform(get("/profile").with(user(user))) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login")); + .andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_PASSWORD")); this.mockMvc .perform(post("/ott/generate").param("username", "rod") .with(user(user)) @@ -418,11 +418,11 @@ void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { 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")); + .andExpect(redirectedUrl("http://localhost/login?authority=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")); + .andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_OTT")); user = PasswordEncodedUser.withUserDetails(user) .authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") .build(); @@ -438,7 +438,7 @@ void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception { 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")); + .andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_PASSWORD")); this.mockMvc .perform(post("/login").param("username", "rod") .param("password", "password") diff --git a/core/src/main/java/org/springframework/security/core/GrantedAuthority.java b/core/src/main/java/org/springframework/security/core/GrantedAuthority.java index 143b254b854..8e7ff7ddc5d 100644 --- a/core/src/main/java/org/springframework/security/core/GrantedAuthority.java +++ b/core/src/main/java/org/springframework/security/core/GrantedAuthority.java @@ -31,6 +31,8 @@ */ public interface GrantedAuthority extends Serializable { + String MISSING_AUTHORITIES_ATTRIBUTE = GrantedAuthority.class + ".missingAuthorities"; + /** * If the GrantedAuthority can be represented as a String * and that String is sufficient in precision to be relied upon for an 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 index 215ed6832cf..1e8773be529 100644 --- a/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java +++ b/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java @@ -91,14 +91,19 @@ private DelegatingMissingAuthorityAccessDeniedHandler(Map authorities = missingAuthorities(denied); - AuthenticationEntryPoint entryPoint = entryPoint(authorities); - if (entryPoint == null) { - this.defaultAccessDeniedHandler.handle(request, response, denied); + for (GrantedAuthority needed : authorities) { + AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority()); + if (entryPoint == null) { + continue; + } + this.requestCache.saveRequest(request, response); + request.setAttribute(GrantedAuthority.MISSING_AUTHORITIES_ATTRIBUTE, 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.requestCache.saveRequest(request, response); - AuthenticationException ex = new InsufficientAuthenticationException("missing authorities", denied); - entryPoint.commence(request, response, ex); + this.defaultAccessDeniedHandler.handle(request, response, denied); } /** @@ -121,17 +126,6 @@ public void setRequestCache(RequestCache requestCache) { this.requestCache = requestCache; } - private @Nullable AuthenticationEntryPoint entryPoint(Collection authorities) { - for (GrantedAuthority needed : authorities) { - AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority()); - if (entryPoint == null) { - continue; - } - return entryPoint; - } - return null; - } - private Collection missingAuthorities(AccessDeniedException ex) { AuthorizationDeniedException denied = findAuthorizationDeniedException(ex); if (denied == null) { 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..50e0adbc05b 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,7 @@ package org.springframework.security.web.authentication; import java.io.IOException; +import java.util.Collection; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.ServletException; @@ -30,6 +31,7 @@ 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; @@ -40,6 +42,7 @@ import org.springframework.security.web.util.UrlUtils; import org.springframework.util.Assert; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponentsBuilder; /** * Used by the {@link ExceptionTranslationFilter} to commence a form login authentication @@ -109,6 +112,12 @@ public void afterPropertiesSet() { */ protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) { + Object value = request.getAttribute(GrantedAuthority.MISSING_AUTHORITIES_ATTRIBUTE); + if (value instanceof Collection authorities) { + return UriComponentsBuilder.fromUriString(getLoginFormUrl()) + .queryParam("authority", authorities) + .toUriString(); + } return getLoginFormUrl(); } 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..05adc4b58be 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 @@ -78,6 +85,8 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { private @Nullable String rememberMeParameter; + private final Collection allowedParameters = List.of("authority"); + @SuppressWarnings("NullAway.Init") private Map oauth2AuthenticationUrlToClientName; @@ -223,16 +232,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("FACTOR_WEBAUTHN")) { + builder.withRawHtml("javaScript", renderJavaScript(request, contextPath)) + .withRawHtml("passkeyLogin", renderPasskeyLogin()); + } + if (wantsAuthority.test("FACTOR_PASSWORD")) { + builder.withRawHtml("formLogin", + renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg)); + } + if (wantsAuthority.test("FACTOR_OTT")) { + builder.withRawHtml("oneTimeTokenLogin", + renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg)); + } + if (wantsAuthority.test("FACTOR_AUTHORIZATION_CODE")) { + builder.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath)); + } + if (wantsAuthority.test("FACTOR_SAML_RESPONSE")) { + builder.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath)); + } + return builder.render(); + } + + private Predicate wantsAuthority(HttpServletRequest request) { + String[] authorities = request.getParameterValues("authority"); + if (authorities == null) { + return (authority) -> true; + } + return List.of(authorities)::contains; } private String renderJavaScript(HttpServletRequest request, String contextPath) { @@ -413,10 +449,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 = """ 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..fc469f4f3c0 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 @@ -28,6 +28,7 @@ import org.springframework.security.authentication.BadCredentialsException; 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.Mockito.mock; @@ -191,6 +192,60 @@ 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?authority=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?authority=FACTOR_OTT&authority=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 void generatesThenRenders() throws ServletException, IOException { DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter( From 1faea57aa5f788a717e14869a25e5875d7dff536 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:28:00 -0600 Subject: [PATCH 7/9] Prepopulate Username When Known Closes gh-17935 --- .../DefaultLoginPageConfigurer.java | 1 + .../ui/DefaultLoginPageGeneratingFilter.java | 58 ++++++++++++++++++- ...DefaultLoginPageGeneratingFilterTests.java | 28 +++++++++ 3 files changed, 84 insertions(+), 3 deletions(-) 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/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 05adc4b58be..e782deddbc9 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 @@ -59,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; @@ -118,6 +121,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 @@ -307,6 +322,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() @@ -317,7 +339,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) @@ -337,11 +359,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(); } @@ -410,6 +438,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); } @@ -511,7 +547,7 @@ private boolean matches(HttpServletRequest request, @Nullable String url) { {{errorMessage}}{{logoutMessage}}

- + {{usernameInput}}

@@ -522,6 +558,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 = """ """; @@ -554,11 +598,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/authentication/DefaultLoginPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java index fc469f4f3c0..ee38d177b00 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,11 +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; /** @@ -246,6 +250,30 @@ public void generateWhenTwoAuthoritiesRequestedThenBothForms() throws Exception 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( From 835c8e1c469c71e57634675ea25ad4e5994d1633 Mon Sep 17 00:00:00 2001 From: Josh Cummings <3627351+jzheaux@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:48:58 -0600 Subject: [PATCH 8/9] Polish Default Login Page Issue gh-17901 --- .../configurers/FormLoginConfigurerTests.java | 16 ++++++----- .../LoginUrlAuthenticationEntryPoint.java | 28 +++++++++++++++---- .../ui/DefaultLoginPageGeneratingFilter.java | 16 ++++++----- ...DefaultLoginPageGeneratingFilterTests.java | 7 ++--- 4 files changed, 44 insertions(+), 23 deletions(-) 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 cb66974b72f..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 @@ -402,7 +402,7 @@ void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { UserDetails user = PasswordEncodedUser.user(); this.mockMvc.perform(get("/profile").with(user(user))) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?authority=FACTOR_PASSWORD")); + .andExpect(redirectedUrl("http://localhost/login?factor=password")); this.mockMvc .perform(post("/ott/generate").param("username", "rod") .with(user(user)) @@ -418,11 +418,11 @@ void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { 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?authority=FACTOR_PASSWORD")); + .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?authority=FACTOR_OTT")); + .andExpect(redirectedUrl("http://localhost/login?factor=ott")); user = PasswordEncodedUser.withUserDetails(user) .authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") .build(); @@ -431,14 +431,14 @@ void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { @Test void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception { - this.spring.register(MfaDslX509Config.class, UserConfig.class, org.springframework.security.config.annotation.web.configurers.FormLoginConfigurerTests.BasicMfaController.class).autowire(); + 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?authority=FACTOR_PASSWORD")); + .andExpect(redirectedUrl("http://localhost/login?factor=password")); this.mockMvc .perform(post("/login").param("username", "rod") .param("password", "password") @@ -793,7 +793,8 @@ public O postProcess(O object) { static class MfaDslConfig { @Bean - SecurityFilterChain filterChain(HttpSecurity http, AuthorizationManagerFactory authz) throws Exception { + SecurityFilterChain filterChain(HttpSecurity http, + AuthorizationManagerFactory authz) throws Exception { // @formatter:off http .formLogin(Customizer.withDefaults()) @@ -824,7 +825,8 @@ AuthorizationManagerFactory authz() { static class MfaDslX509Config { @Bean - SecurityFilterChain filterChain(HttpSecurity http, AuthorizationManagerFactory authz) throws Exception { + SecurityFilterChain filterChain(HttpSecurity http, + AuthorizationManagerFactory authz) throws Exception { // @formatter:off http .x509(Customizer.withDefaults()) 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 50e0adbc05b..f5f58e76076 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 @@ -18,6 +18,7 @@ import java.io.IOException; import java.util.Collection; +import java.util.Locale; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.ServletException; @@ -41,6 +42,7 @@ 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; @@ -71,6 +73,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; @@ -110,15 +114,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) { + Collection authorities = getAttribute(request, GrantedAuthority.MISSING_AUTHORITIES_ATTRIBUTE, + 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(GrantedAuthority.MISSING_AUTHORITIES_ATTRIBUTE); - if (value instanceof Collection authorities) { - return UriComponentsBuilder.fromUriString(getLoginFormUrl()) - .queryParam("authority", authorities) - .toUriString(); + if (value == null) { + return null; } - return getLoginFormUrl(); + 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 e782deddbc9..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 @@ -88,7 +88,9 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { private @Nullable String rememberMeParameter; - private final Collection allowedParameters = List.of("authority"); + private final String factorParameter = "factor"; + + private final Collection allowedParameters = List.of(this.factorParameter); @SuppressWarnings("NullAway.Init") private Map oauth2AuthenticationUrlToClientName; @@ -257,29 +259,29 @@ private String generateLoginPageHtml(HttpServletRequest request, boolean loginEr .withRawHtml("passkeyLogin", ""); Predicate wantsAuthority = wantsAuthority(request); - if (wantsAuthority.test("FACTOR_WEBAUTHN")) { + if (wantsAuthority.test("webauthn")) { builder.withRawHtml("javaScript", renderJavaScript(request, contextPath)) .withRawHtml("passkeyLogin", renderPasskeyLogin()); } - if (wantsAuthority.test("FACTOR_PASSWORD")) { + if (wantsAuthority.test("password")) { builder.withRawHtml("formLogin", renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg)); } - if (wantsAuthority.test("FACTOR_OTT")) { + if (wantsAuthority.test("ott")) { builder.withRawHtml("oneTimeTokenLogin", renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg)); } - if (wantsAuthority.test("FACTOR_AUTHORIZATION_CODE")) { + if (wantsAuthority.test("authorization_code")) { builder.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath)); } - if (wantsAuthority.test("FACTOR_SAML_RESPONSE")) { + 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("authority"); + String[] authorities = request.getParameterValues(this.factorParameter); if (authorities == null) { return (authority) -> true; } 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 ee38d177b00..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 @@ -204,7 +204,7 @@ public void generateWhenOneTimeTokenRequestedThenOttForm() throws Exception { filter.setOneTimeTokenEnabled(true); filter.setOneTimeTokenGenerationUrl("/ott/authenticate"); MockHttpServletResponse response = new MockHttpServletResponse(); - filter.doFilter(TestMockHttpServletRequests.get("/login?authority=FACTOR_OTT").build(), response, this.chain); + filter.doFilter(TestMockHttpServletRequests.get("/login?factor=ott").build(), response, this.chain); assertThat(response.getContentAsString()).contains("Request a One-Time Token"); assertThat(response.getContentAsString()).contains("""