Initial Exception Handling
This commit hardcodes factors as a proof of concept for multi-factor authentication Issue gh-17934
This commit is contained in:
parent
549569ea55
commit
fe17f2904d
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue