Initial Exception Handling

This commit hardcodes factors as a proof of concept for
multi-factor authentication

Issue gh-17934
This commit is contained in:
Josh Cummings 2025-09-19 09:58:13 -06:00
parent 549569ea55
commit fe17f2904d
No known key found for this signature in database
GPG Key ID: 869B37A20E876129
2 changed files with 281 additions and 2 deletions

View File

@ -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<H extends HttpSecurityBuilder<H>>
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<H extends HttpSecurityBuilder<H>>
return new HttpSessionRequestCache();
}
private static final class AuthenticationFactorDelegatingAccessDeniedHandler implements AccessDeniedHandler {
private final Map<String, AuthenticationEntryPoint> 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<String> 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<String> 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<String, Function<Authentication, String>> params;
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
private RedirectStrategy redirectStrategy = new FormPostRedirectStrategy();
private PostAuthenticationEntryPoint(String entryPointUri,
Map<String, Function<Authentication, String>> 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<String, String> 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;
}
}
}

View File

@ -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<C> implements AuthorizationManager<C> {
private final Collection<String> authorities;
private HasAllAuthoritiesAuthorizationManager(String... authorities) {
this.authorities = List.of(authorities);
}
@Override
public @Nullable AuthorizationResult authorize(Supplier<Authentication> authentication, C object) {
List<String> authorities = authentication.get()
.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.toList();
List<String> needed = new ArrayList<>(this.authorities);
needed.removeIf(authorities::contains);
return new AuthorityAuthorizationDecision(needed.isEmpty(), AuthorityUtils.createAuthorityList(needed));
}
}
}