Skip to content

Commit fe17f29

Browse files
committed
Initial Exception Handling
This commit hardcodes factors as a proof of concept for multi-factor authentication Issue gh-17934
1 parent 549569e commit fe17f29

File tree

2 files changed

+281
-2
lines changed

2 files changed

+281
-2
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,48 @@
1616

1717
package org.springframework.security.config.annotation.web.configurers;
1818

19+
import java.io.IOException;
20+
import java.util.Collection;
1921
import java.util.LinkedHashMap;
22+
import java.util.Map;
23+
import java.util.function.Function;
24+
import java.util.stream.Collectors;
2025

26+
import jakarta.servlet.ServletException;
27+
import jakarta.servlet.http.HttpServletRequest;
28+
import jakarta.servlet.http.HttpServletResponse;
2129
import org.jspecify.annotations.Nullable;
2230

31+
import org.springframework.security.access.AccessDeniedException;
32+
import org.springframework.security.authentication.InsufficientAuthenticationException;
33+
import org.springframework.security.authorization.AuthorityAuthorizationDecision;
34+
import org.springframework.security.authorization.AuthorizationDeniedException;
2335
import org.springframework.security.config.Customizer;
2436
import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
2537
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
38+
import org.springframework.security.core.Authentication;
39+
import org.springframework.security.core.AuthenticationException;
40+
import org.springframework.security.core.GrantedAuthority;
41+
import org.springframework.security.core.context.SecurityContextHolder;
42+
import org.springframework.security.core.context.SecurityContextHolderStrategy;
43+
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
2644
import org.springframework.security.web.AuthenticationEntryPoint;
45+
import org.springframework.security.web.FormPostRedirectStrategy;
46+
import org.springframework.security.web.RedirectStrategy;
2747
import org.springframework.security.web.access.AccessDeniedHandler;
2848
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
2949
import org.springframework.security.web.access.ExceptionTranslationFilter;
3050
import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler;
3151
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
3252
import org.springframework.security.web.authentication.Http403ForbiddenEntryPoint;
53+
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
54+
import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter;
55+
import org.springframework.security.web.csrf.CsrfToken;
3356
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
3457
import org.springframework.security.web.savedrequest.RequestCache;
3558
import org.springframework.security.web.util.matcher.RequestMatcher;
59+
import org.springframework.util.Assert;
60+
import org.springframework.web.util.UriComponentsBuilder;
3661

3762
/**
3863
* Adds exception handling for Spring Security related exceptions to an application. All
@@ -230,13 +255,13 @@ AuthenticationEntryPoint getAuthenticationEntryPoint(H http) {
230255

231256
private AccessDeniedHandler createDefaultDeniedHandler(H http) {
232257
if (this.defaultDeniedHandlerMappings.isEmpty()) {
233-
return new AccessDeniedHandlerImpl();
258+
return new AuthenticationFactorDelegatingAccessDeniedHandler();
234259
}
235260
if (this.defaultDeniedHandlerMappings.size() == 1) {
236261
return this.defaultDeniedHandlerMappings.values().iterator().next();
237262
}
238263
return new RequestMatcherDelegatingAccessDeniedHandler(this.defaultDeniedHandlerMappings,
239-
new AccessDeniedHandlerImpl());
264+
new AuthenticationFactorDelegatingAccessDeniedHandler());
240265
}
241266

242267
private AuthenticationEntryPoint createDefaultEntryPoint(H http) {
@@ -262,4 +287,96 @@ private RequestCache getRequestCache(H http) {
262287
return new HttpSessionRequestCache();
263288
}
264289

290+
private static final class AuthenticationFactorDelegatingAccessDeniedHandler implements AccessDeniedHandler {
291+
292+
private final Map<String, AuthenticationEntryPoint> entryPoints = Map.of("FACTOR_PASSWORD",
293+
new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_AUTHORIZATION_CODE",
294+
new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_SAML_RESPONSE",
295+
new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_WEBAUTHN",
296+
new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_BEARER",
297+
new BearerTokenAuthenticationEntryPoint(), "FACTOR_OTT",
298+
new PostAuthenticationEntryPoint(GenerateOneTimeTokenFilter.DEFAULT_GENERATE_URL + "?username={u}",
299+
Map.of("u", Authentication::getName)));
300+
301+
private final AccessDeniedHandler defaults = new AccessDeniedHandlerImpl();
302+
303+
@Override
304+
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex)
305+
throws IOException, ServletException {
306+
Collection<String> needed = authorizationRequest(ex);
307+
if (needed == null) {
308+
this.defaults.handle(request, response, ex);
309+
return;
310+
}
311+
for (String authority : needed) {
312+
AuthenticationEntryPoint entryPoint = this.entryPoints.get(authority);
313+
if (entryPoint != null) {
314+
AuthenticationException insufficient = new InsufficientAuthenticationException(ex.getMessage(), ex);
315+
entryPoint.commence(request, response, insufficient);
316+
return;
317+
}
318+
}
319+
this.defaults.handle(request, response, ex);
320+
}
321+
322+
private Collection<String> authorizationRequest(AccessDeniedException access) {
323+
if (!(access instanceof AuthorizationDeniedException denied)) {
324+
return null;
325+
}
326+
if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision decision)) {
327+
return null;
328+
}
329+
return decision.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList();
330+
}
331+
332+
}
333+
334+
private static final class PostAuthenticationEntryPoint implements AuthenticationEntryPoint {
335+
336+
private final String entryPointUri;
337+
338+
private final Map<String, Function<Authentication, String>> params;
339+
340+
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
341+
.getContextHolderStrategy();
342+
343+
private RedirectStrategy redirectStrategy = new FormPostRedirectStrategy();
344+
345+
private PostAuthenticationEntryPoint(String entryPointUri,
346+
Map<String, Function<Authentication, String>> params) {
347+
this.entryPointUri = entryPointUri;
348+
this.params = params;
349+
}
350+
351+
@Override
352+
public void commence(HttpServletRequest request, HttpServletResponse response,
353+
AuthenticationException authException) throws IOException, ServletException {
354+
Authentication authentication = getAuthentication(authException);
355+
Assert.notNull(authentication, "could not find authentication in order to perform post");
356+
Map<String, String> params = this.params.entrySet()
357+
.stream()
358+
.collect(Collectors.toMap(Map.Entry::getKey, (entry) -> entry.getValue().apply(authentication)));
359+
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(this.entryPointUri);
360+
CsrfToken csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
361+
if (csrf != null) {
362+
builder.queryParam(csrf.getParameterName(), csrf.getToken());
363+
}
364+
String entryPointUrl = builder.build(false).expand(params).toUriString();
365+
this.redirectStrategy.sendRedirect(request, response, entryPointUrl);
366+
}
367+
368+
private Authentication getAuthentication(AuthenticationException authException) {
369+
Authentication authentication = authException.getAuthenticationRequest();
370+
if (authentication != null && authentication.isAuthenticated()) {
371+
return authentication;
372+
}
373+
authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
374+
if (authentication != null && authentication.isAuthenticated()) {
375+
return authentication;
376+
}
377+
return null;
378+
}
379+
380+
}
381+
265382
}

config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,23 @@
1616

1717
package org.springframework.security.config.annotation.web.configurers;
1818

19+
import java.util.ArrayList;
20+
import java.util.Collection;
21+
import java.util.List;
22+
import java.util.function.Supplier;
23+
24+
import org.jspecify.annotations.Nullable;
1925
import org.junit.jupiter.api.Test;
2026
import org.junit.jupiter.api.extension.ExtendWith;
2127

2228
import org.springframework.beans.factory.annotation.Autowired;
2329
import org.springframework.context.annotation.Bean;
2430
import org.springframework.context.annotation.Configuration;
2531
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
32+
import org.springframework.security.authorization.AuthorityAuthorizationDecision;
33+
import org.springframework.security.authorization.AuthorizationManager;
34+
import org.springframework.security.authorization.AuthorizationResult;
35+
import org.springframework.security.config.Customizer;
2636
import org.springframework.security.config.ObjectPostProcessor;
2737
import org.springframework.security.config.annotation.SecurityContextChangedListenerConfig;
2838
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -31,22 +41,32 @@
3141
import org.springframework.security.config.test.SpringTestContext;
3242
import org.springframework.security.config.test.SpringTestContextExtension;
3343
import org.springframework.security.config.users.AuthenticationTestConfiguration;
44+
import org.springframework.security.core.Authentication;
45+
import org.springframework.security.core.GrantedAuthority;
46+
import org.springframework.security.core.authority.AuthorityUtils;
3447
import org.springframework.security.core.context.SecurityContextChangedListener;
3548
import org.springframework.security.core.context.SecurityContextHolderStrategy;
3649
import org.springframework.security.core.userdetails.PasswordEncodedUser;
50+
import org.springframework.security.core.userdetails.UserDetails;
3751
import org.springframework.security.core.userdetails.UserDetailsService;
52+
import org.springframework.security.crypto.password.NoOpPasswordEncoder;
53+
import org.springframework.security.crypto.password.PasswordEncoder;
3854
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
3955
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders;
56+
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
4057
import org.springframework.security.web.PortMapper;
4158
import org.springframework.security.web.SecurityFilterChain;
4259
import org.springframework.security.web.access.ExceptionTranslationFilter;
4360
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
4461
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
4562
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
63+
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
64+
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
4665
import org.springframework.security.web.savedrequest.RequestCache;
4766
import org.springframework.test.web.servlet.MockMvc;
4867
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
4968

69+
import static org.hamcrest.Matchers.containsString;
5070
import static org.mockito.ArgumentMatchers.any;
5171
import static org.mockito.BDDMockito.given;
5272
import static org.mockito.Mockito.atLeastOnce;
@@ -60,6 +80,7 @@
6080
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
6181
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
6282
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
83+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
6384
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
6485
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
6586
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -378,6 +399,61 @@ public void configureWhenRegisteringObjectPostProcessorThenInvokedOnExceptionTra
378399
verify(ObjectPostProcessorConfig.objectPostProcessor).postProcess(any(ExceptionTranslationFilter.class));
379400
}
380401

402+
@Test
403+
void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception {
404+
this.spring.register(MfaDslConfig.class).autowire();
405+
UserDetails user = PasswordEncodedUser.user();
406+
this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user)))
407+
.andExpect(status().is3xxRedirection())
408+
.andExpect(redirectedUrl("http://localhost/login"));
409+
this.mockMvc
410+
.perform(post("/ott/generate").param("username", "user")
411+
.with(SecurityMockMvcRequestPostProcessors.user(user))
412+
.with(SecurityMockMvcRequestPostProcessors.csrf()))
413+
.andExpect(status().is3xxRedirection())
414+
.andExpect(redirectedUrl("/ott/sent"));
415+
this.mockMvc
416+
.perform(post("/login").param("username", user.getUsername())
417+
.param("password", user.getPassword())
418+
.with(SecurityMockMvcRequestPostProcessors.csrf()))
419+
.andExpect(status().is3xxRedirection())
420+
.andExpect(redirectedUrl("/"));
421+
user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_OTT").build();
422+
this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user)))
423+
.andExpect(status().is3xxRedirection())
424+
.andExpect(redirectedUrl("http://localhost/login"));
425+
user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build();
426+
this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user)))
427+
.andExpect(status().isOk())
428+
.andExpect(content().string(containsString("/ott/generate")));
429+
user = PasswordEncodedUser.withUserDetails(user)
430+
.authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT")
431+
.build();
432+
this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.user(user)))
433+
.andExpect(status().isNotFound());
434+
}
435+
436+
@Test
437+
void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception {
438+
this.spring.register(MfaDslX509Config.class).autowire();
439+
this.mockMvc.perform(get("/")).andExpect(status().isForbidden());
440+
this.mockMvc.perform(get("/login")).andExpect(status().isOk());
441+
this.mockMvc.perform(get("/").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer")))
442+
.andExpect(status().is3xxRedirection())
443+
.andExpect(redirectedUrl("http://localhost/login"));
444+
UserDetails user = PasswordEncodedUser.withUsername("rod")
445+
.password("password")
446+
.authorities("AUTHN_FORM")
447+
.build();
448+
this.mockMvc
449+
.perform(post("/login").param("username", user.getUsername())
450+
.param("password", user.getPassword())
451+
.with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))
452+
.with(SecurityMockMvcRequestPostProcessors.csrf()))
453+
.andExpect(status().is3xxRedirection())
454+
.andExpect(redirectedUrl("/"));
455+
}
456+
381457
@Configuration
382458
@EnableWebSecurity
383459
static class RequestCacheConfig {
@@ -714,4 +790,90 @@ public <O> O postProcess(O object) {
714790

715791
}
716792

793+
@Configuration
794+
@EnableWebSecurity
795+
static class MfaDslConfig {
796+
797+
@Bean
798+
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
799+
// @formatter:off
800+
http
801+
.formLogin(Customizer.withDefaults())
802+
.oneTimeTokenLogin(Customizer.withDefaults())
803+
.authorizeHttpRequests((authorize) -> authorize
804+
.requestMatchers("/profile").access(
805+
new HasAllAuthoritiesAuthorizationManager<>("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT")
806+
)
807+
.anyRequest().access(new HasAllAuthoritiesAuthorizationManager<>("FACTOR_PASSWORD", "FACTOR_OTT"))
808+
);
809+
return http.build();
810+
// @formatter:on
811+
}
812+
813+
@Bean
814+
UserDetailsService users() {
815+
return new InMemoryUserDetailsManager(PasswordEncodedUser.user());
816+
}
817+
818+
@Bean
819+
PasswordEncoder encoder() {
820+
return NoOpPasswordEncoder.getInstance();
821+
}
822+
823+
@Bean
824+
OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
825+
return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
826+
}
827+
828+
}
829+
830+
@Configuration
831+
@EnableWebSecurity
832+
static class MfaDslX509Config {
833+
834+
@Bean
835+
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
836+
// @formatter:off
837+
http
838+
.formLogin(Customizer.withDefaults())
839+
.x509(Customizer.withDefaults())
840+
.authorizeHttpRequests((authorize) -> authorize
841+
.anyRequest().access(
842+
new HasAllAuthoritiesAuthorizationManager<>("FACTOR_X509", "FACTOR_PASSWORD")
843+
)
844+
);
845+
return http.build();
846+
// @formatter:on
847+
}
848+
849+
@Bean
850+
UserDetailsService users() {
851+
return new InMemoryUserDetailsManager(
852+
PasswordEncodedUser.withUsername("rod").password("{noop}password").build());
853+
}
854+
855+
}
856+
857+
private static final class HasAllAuthoritiesAuthorizationManager<C> implements AuthorizationManager<C> {
858+
859+
private final Collection<String> authorities;
860+
861+
private HasAllAuthoritiesAuthorizationManager(String... authorities) {
862+
this.authorities = List.of(authorities);
863+
}
864+
865+
@Override
866+
public @Nullable AuthorizationResult authorize(Supplier<Authentication> authentication, C object) {
867+
List<String> authorities = authentication.get()
868+
.getAuthorities()
869+
.stream()
870+
.map(GrantedAuthority::getAuthority)
871+
.toList();
872+
List<String> needed = new ArrayList<>(this.authorities);
873+
needed.removeIf(authorities::contains);
874+
return new AuthorityAuthorizationDecision(needed.isEmpty(), AuthorityUtils.createAuthorityList(needed));
875+
}
876+
877+
}
878+
717879
}

0 commit comments

Comments
 (0)