From fe17f2904d1f1f7360b134ada8e86ae2b8d06401 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] 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 6b6a2f1f7c..22c9219a72 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 @@ public final class ExceptionHandlingConfigurer> 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 @@ public final class ExceptionHandlingConfigurer> 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 cb8a6005b6..59b1eaa209 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.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authorization.AuthorityAuthorizationDecision; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.config.Customizer; import org.springframework.security.config.ObjectPostProcessor; import org.springframework.security.config.annotation.SecurityContextChangedListenerConfig; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -31,22 +41,32 @@ import org.springframework.security.config.annotation.web.configuration.WebSecur import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; import org.springframework.security.config.users.AuthenticationTestConfiguration; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.context.SecurityContextChangedListener; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.userdetails.PasswordEncodedUser; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.NoOpPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; import org.springframework.security.web.PortMapper; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; +import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.savedrequest.RequestCache; import org.springframework.test.web.servlet.MockMvc; import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeastOnce; @@ -60,6 +80,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMock import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -378,6 +399,61 @@ public class FormLoginConfigurerTests { verify(ObjectPostProcessorConfig.objectPostProcessor).postProcess(any(ExceptionTranslationFilter.class)); } + @Test + void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception { + this.spring.register(MfaDslConfig.class).autowire(); + UserDetails user = PasswordEncodedUser.user(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + this.mockMvc + .perform(post("/ott/generate").param("username", "user") + .with(SecurityMockMvcRequestPostProcessors.user(user)) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/ott/sent")); + this.mockMvc + .perform(post("/login").param("username", user.getUsername()) + .param("password", user.getPassword()) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")); + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_OTT").build(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("/ott/generate"))); + user = PasswordEncodedUser.withUserDetails(user) + .authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") + .build(); + this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user))) + .andExpect(status().isNotFound()); + } + + @Test + void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception { + this.spring.register(MfaDslX509Config.class).autowire(); + this.mockMvc.perform(get("/")).andExpect(status().isForbidden()); + this.mockMvc.perform(get("/login")).andExpect(status().isOk()); + this.mockMvc.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/login")); + UserDetails user = PasswordEncodedUser.withUsername("rod") + .password("password") + .authorities("AUTHN_FORM") + .build(); + this.mockMvc + .perform(post("/login").param("username", user.getUsername()) + .param("password", user.getPassword()) + .with(SecurityMockMvcRequestPostProcessors.x509("rod.cer")) + .with(SecurityMockMvcRequestPostProcessors.csrf())) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/")); + } + @Configuration @EnableWebSecurity static class RequestCacheConfig { @@ -714,4 +790,90 @@ public class FormLoginConfigurerTests { } + @Configuration + @EnableWebSecurity + static class MfaDslConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()) + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/profile").access( + new HasAllAuthoritiesAuthorizationManager<>("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT") + ) + .anyRequest().access(new HasAllAuthoritiesAuthorizationManager<>("FACTOR_PASSWORD", "FACTOR_OTT")) + ); + return http.build(); + // @formatter:on + } + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager(PasswordEncodedUser.user()); + } + + @Bean + PasswordEncoder encoder() { + return NoOpPasswordEncoder.getInstance(); + } + + @Bean + OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() { + return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); + } + + } + + @Configuration + @EnableWebSecurity + static class MfaDslX509Config { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .formLogin(Customizer.withDefaults()) + .x509(Customizer.withDefaults()) + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().access( + new HasAllAuthoritiesAuthorizationManager<>("FACTOR_X509", "FACTOR_PASSWORD") + ) + ); + return http.build(); + // @formatter:on + } + + @Bean + UserDetailsService users() { + return new InMemoryUserDetailsManager( + PasswordEncodedUser.withUsername("rod").password("{noop}password").build()); + } + + } + + private static final class HasAllAuthoritiesAuthorizationManager 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)); + } + + } + }