浏览代码

Initial Exception Handling

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

Issue gh-17934
Josh Cummings 2 月之前
父节点
当前提交
fe17f2904d

+ 119 - 2
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<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;
+		}
+
+	}
+
 }

+ 162 - 0
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<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));
+		}
+
+	}
+
 }