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 7d97ea50d2..58fe15f9f5 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 @@ -403,7 +403,7 @@ public class FormLoginConfigurerTests { UserDetails user = PasswordEncodedUser.user(); this.mockMvc.perform(get("/profile").with(user(user))) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=password")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")); this.mockMvc .perform(post("/ott/generate").param("username", "rod") .with(user(user)) @@ -421,13 +421,13 @@ public class FormLoginConfigurerTests { .build(); this.mockMvc.perform(get("/profile").with(user(user))) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=password")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")); user = PasswordEncodedUser.withUserDetails(user) .authorities("profile:read", GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY) .build(); this.mockMvc.perform(get("/profile").with(user(user))) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")); user = PasswordEncodedUser.withUserDetails(user) .authorities("profile:read", GrantedAuthorities.FACTOR_PASSWORD_AUTHORITY, GrantedAuthorities.FACTOR_OTT_AUTHORITY) @@ -444,7 +444,7 @@ public class FormLoginConfigurerTests { this.mockMvc.perform(get("/login")).andExpect(status().isOk()); this.mockMvc.perform(get("/profile").with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=password")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")); this.mockMvc .perform(post("/login").param("username", "rod") .param("password", "password") diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java index 60337ace1f..e3091010a8 100644 --- a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java @@ -69,7 +69,7 @@ public class AuthorizationManagerFactoryTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")); // @formatter:on } @@ -80,7 +80,7 @@ public class AuthorizationManagerFactoryTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=password")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")); // @formatter:on } @@ -91,7 +91,7 @@ public class AuthorizationManagerFactoryTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=password")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")); // @formatter:on } diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java index fe4f03e07b..872ad9e69d 100644 --- a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java @@ -58,7 +58,7 @@ public class CustomAuthorizationManagerFactoryTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")); // @formatter:on } diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/enableglobalmfa/EnableGlobalMultiFactorAuthenticationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/enableglobalmfa/EnableGlobalMultiFactorAuthenticationTests.java index 70dcb737a2..34f0a81fca 100644 --- a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/enableglobalmfa/EnableGlobalMultiFactorAuthenticationTests.java +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/enableglobalmfa/EnableGlobalMultiFactorAuthenticationTests.java @@ -69,7 +69,7 @@ public class EnableGlobalMultiFactorAuthenticationTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")); // @formatter:on } @@ -80,7 +80,7 @@ public class EnableGlobalMultiFactorAuthenticationTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=password")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")); // @formatter:on } @@ -91,7 +91,7 @@ public class EnableGlobalMultiFactorAuthenticationTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=password")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")); // @formatter:on } diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java index 9aa6ee3da5..8ba5b7cf3e 100644 --- a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java @@ -69,7 +69,7 @@ public class MultiFactorAuthenticationTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")); // @formatter:on } @@ -80,7 +80,7 @@ public class MultiFactorAuthenticationTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=password")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")); // @formatter:on } @@ -91,7 +91,7 @@ public class MultiFactorAuthenticationTests { // @formatter:off this.mockMvc.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=password")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")); // @formatter:on } diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java index 81b810e84a..c9da833907 100644 --- a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java @@ -69,7 +69,7 @@ public class ReauthenticationTests { // @formatter:off this.mockMvc.perform(get("/profile")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=ott")); + .andExpect(redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")); // @formatter:on } diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt index f0ac6b88d6..bff52b21a5 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt @@ -68,7 +68,7 @@ class AuthorizationManagerFactoryTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")) // @formatter:on } @@ -81,7 +81,7 @@ class AuthorizationManagerFactoryTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")) // @formatter:on } @@ -94,7 +94,7 @@ class AuthorizationManagerFactoryTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")) // @formatter:on } diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt index 839a83ab29..b182cbd007 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt @@ -55,7 +55,7 @@ class CustomAuthorizationManagerFactoryTests { // @formatter:off this.mockMvc!!.perform(get("/")) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("http://localhost/login?factor=ott")) + .andExpect(redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")) // @formatter:on } diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/enableglobalmfa/AuthorizationManagerFactoryTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/enableglobalmfa/AuthorizationManagerFactoryTests.kt index eea878f2da..c2303f1f3e 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/enableglobalmfa/AuthorizationManagerFactoryTests.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/enableglobalmfa/AuthorizationManagerFactoryTests.kt @@ -68,7 +68,7 @@ class AuthorizationManagerFactoryTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")) // @formatter:on } @@ -81,7 +81,7 @@ class AuthorizationManagerFactoryTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")) // @formatter:on } @@ -94,7 +94,7 @@ class AuthorizationManagerFactoryTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")) // @formatter:on } diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt index 945631448e..3de64b1064 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt @@ -66,7 +66,7 @@ class MultiFactorAuthenticationTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")) // @formatter:on } @@ -78,7 +78,7 @@ class MultiFactorAuthenticationTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")) // @formatter:on } @@ -90,7 +90,7 @@ class MultiFactorAuthenticationTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=password&factor.reason=missing")) // @formatter:on } diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt index 2ceb9d26cb..6cf9bd57c9 100644 --- a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt @@ -68,7 +68,7 @@ class ReauthenticationTests { // @formatter:off this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile")) .andExpect(MockMvcResultMatchers.status().is3xxRedirection()) - .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott")) + .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor.type=ott&factor.reason=missing")) // @formatter:on } diff --git a/web/src/main/java/org/springframework/security/web/WebAttributes.java b/web/src/main/java/org/springframework/security/web/WebAttributes.java index 53ec1c698c..3ed65f0053 100644 --- a/web/src/main/java/org/springframework/security/web/WebAttributes.java +++ b/web/src/main/java/org/springframework/security/web/WebAttributes.java @@ -18,7 +18,6 @@ package org.springframework.security.web; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.access.WebInvocationPrivilegeEvaluator; /** @@ -56,15 +55,16 @@ public final class WebAttributes { + ".WEB_INVOCATION_PRIVILEGE_EVALUATOR_ATTRIBUTE"; /** - * Used to set a {@code Collection} of {@link GrantedAuthority} instances into the - * {@link HttpServletRequest}. + * Used to set a {@code Collection} of + * {@link org.springframework.security.authorization.RequiredFactorError} instances + * into the {@link HttpServletRequest}. *

* Represents what authorities are missing to be authorized for the current request * * @since 7.0 * @see org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler */ - public static final String MISSING_AUTHORITIES = WebAttributes.class + ".MISSING_AUTHORITIES"; + public static final String REQUIRED_FACTOR_ERRORS = WebAttributes.class + ".REQUIRED_FACTOR_ERRORS "; private WebAttributes() { } diff --git a/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java b/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java index 7260146b38..eee4e286aa 100644 --- a/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java +++ b/web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java @@ -17,11 +17,11 @@ package org.springframework.security.web.access; import java.io.IOException; -import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Consumer; +import java.util.stream.Collectors; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -32,6 +32,10 @@ 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.authorization.AuthorizationResult; +import org.springframework.security.authorization.FactorAuthorizationDecision; +import org.springframework.security.authorization.RequiredFactor; +import org.springframework.security.authorization.RequiredFactorError; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.AuthenticationEntryPoint; @@ -93,15 +97,19 @@ public final class DelegatingMissingAuthorityAccessDeniedHandler implements Acce @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException denied) throws IOException, ServletException { - Collection authorities = missingAuthorities(denied); - for (GrantedAuthority needed : authorities) { - AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority()); + List authorityErrors = authorityErrors(denied); + for (AuthorityRequiredFactorErrorEntry authorityError : authorityErrors) { + String requiredAuthority = authorityError.getAuthority(); + AuthenticationEntryPoint entryPoint = this.entryPoints.get(requiredAuthority); if (entryPoint == null) { continue; } this.requestCache.saveRequest(request, response); - request.setAttribute(WebAttributes.MISSING_AUTHORITIES, List.of(needed)); - String message = String.format("Missing Authorities %s", List.of(needed)); + RequiredFactorError required = authorityError.getError(); + if (required != null) { + request.setAttribute(WebAttributes.REQUIRED_FACTOR_ERRORS, List.of(required)); + } + String message = String.format("Missing Authorities %s", requiredAuthority); AuthenticationException ex = new InsufficientAuthenticationException(message, denied); entryPoint.commence(request, response, ex); return; @@ -131,15 +139,39 @@ public final class DelegatingMissingAuthorityAccessDeniedHandler implements Acce this.requestCache = requestCache; } - private Collection missingAuthorities(AccessDeniedException ex) { + private List authorityErrors(AccessDeniedException ex) { AuthorizationDeniedException denied = findAuthorizationDeniedException(ex); if (denied == null) { return List.of(); } - if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) { - return List.of(); + AuthorizationResult authorizationResult = denied.getAuthorizationResult(); + if (authorizationResult instanceof FactorAuthorizationDecision factorDecision) { + // @formatter:off + return factorDecision.getFactorErrors().stream() + .map((error) -> { + String authority = error.getRequiredFactor().getAuthority(); + return new AuthorityRequiredFactorErrorEntry(authority, error); + }) + .collect(Collectors.toList()); + // @formatter:on + } + if (authorizationResult instanceof AuthorityAuthorizationDecision authorityDecision) { + // @formatter:off + return authorityDecision.getAuthorities().stream() + .map((grantedAuthority) -> { + String authority = grantedAuthority.getAuthority(); + if (authority.startsWith("FACTOR_")) { + RequiredFactor required = RequiredFactor.withAuthority(authority).build(); + return new AuthorityRequiredFactorErrorEntry(authority, RequiredFactorError.createMissing(required)); + } + else { + return new AuthorityRequiredFactorErrorEntry(authority, null); + } + }) + .collect(Collectors.toList()); + // @formatter:on } - return authorization.getAuthorities(); + return List.of(); } private @Nullable AuthorizationDeniedException findAuthorizationDeniedException(AccessDeniedException ex) { @@ -206,4 +238,33 @@ public final class DelegatingMissingAuthorityAccessDeniedHandler implements Acce } + /** + * A mapping of a {@link GrantedAuthority#getAuthority()} to a possibly null + * {@link RequiredFactorError}. + * + * @author Rob Winch + * @since 7.0 + */ + private static final class AuthorityRequiredFactorErrorEntry { + + private final String authority; + + private final @Nullable RequiredFactorError error; + + private AuthorityRequiredFactorErrorEntry(String authority, @Nullable RequiredFactorError error) { + Assert.notNull(authority, "authority cannot be null"); + this.authority = authority; + this.error = error; + } + + private String getAuthority() { + return this.authority; + } + + private @Nullable RequiredFactorError getError() { + return this.error; + } + + } + } diff --git a/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java b/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java index dbbba096da..2984271404 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java +++ b/web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java @@ -18,6 +18,7 @@ package org.springframework.security.web.authentication; import java.io.IOException; import java.util.Collection; +import java.util.List; import java.util.Locale; import jakarta.servlet.RequestDispatcher; @@ -31,8 +32,8 @@ import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.InitializingBean; import org.springframework.core.log.LogMessage; +import org.springframework.security.authorization.RequiredFactorError; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.DefaultRedirectStrategy; import org.springframework.security.web.PortMapper; @@ -118,16 +119,22 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin @SuppressWarnings("unchecked") protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) { - Collection authorities = getAttribute(request, WebAttributes.MISSING_AUTHORITIES, + Collection factorErrors = getAttribute(request, WebAttributes.REQUIRED_FACTOR_ERRORS, Collection.class); - if (CollectionUtils.isEmpty(authorities)) { + if (CollectionUtils.isEmpty(factorErrors)) { return getLoginFormUrl(); } - Collection factors = authorities.stream() - .filter((a) -> a.getAuthority().startsWith(FACTOR_PREFIX)) - .map((a) -> a.getAuthority().substring(FACTOR_PREFIX.length()).toLowerCase(Locale.ROOT)) + List factorTypes = factorErrors.stream() + .map((factorError) -> factorError.getRequiredFactor().getAuthority()) + .map((a) -> a.substring(FACTOR_PREFIX.length()).toLowerCase(Locale.ROOT)) .toList(); - return UriComponentsBuilder.fromUriString(getLoginFormUrl()).queryParam("factor", factors).toUriString(); + List factorReasons = factorErrors.stream() + .map((factorError) -> factorError.isExpired() ? "expired" : "missing") + .toList(); + return UriComponentsBuilder.fromUriString(getLoginFormUrl()) + .queryParam("factor.type", factorTypes) + .queryParam("factor.reason", factorReasons) + .toUriString(); } private static @Nullable T getAttribute(HttpServletRequest request, String name, Class clazz) { diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java index 1346185330..d9da2cb181 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java @@ -18,10 +18,10 @@ package org.springframework.security.web.authentication.ui; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -88,9 +88,11 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { private @Nullable String rememberMeParameter; - private final String factorParameter = "factor"; + private final String factorTypeParameter = "factor.type"; - private final Collection allowedParameters = List.of(this.factorParameter); + private final String factorReasonParameter = "factor.reason"; + + private final Set allowedParameters = Set.of(this.factorTypeParameter, this.factorReasonParameter); @SuppressWarnings("NullAway.Init") private Map oauth2AuthenticationUrlToClientName; @@ -281,7 +283,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { } private Predicate wantsAuthority(HttpServletRequest request) { - String[] authorities = request.getParameterValues(this.factorParameter); + String[] authorities = request.getParameterValues(this.factorTypeParameter); if (authorities == null) { return (authority) -> true; } diff --git a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java index 9717e676a0..71815f9d04 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java @@ -204,7 +204,8 @@ public class DefaultLoginPageGeneratingFilterTests { filter.setOneTimeTokenEnabled(true); filter.setOneTimeTokenGenerationUrl("/ott/authenticate"); MockHttpServletResponse response = new MockHttpServletResponse(); - filter.doFilter(TestMockHttpServletRequests.get("/login?factor=ott").build(), response, this.chain); + filter.doFilter(TestMockHttpServletRequests.get("/login?factor.type=ott&factor.reason=missing").build(), + response, this.chain); assertThat(response.getContentAsString()).contains("Request a One-Time Token"); assertThat(response.getContentAsString()).contains("""

@@ -231,8 +232,9 @@ public class DefaultLoginPageGeneratingFilterTests { filter.setOneTimeTokenEnabled(true); filter.setOneTimeTokenGenerationUrl("/ott/authenticate"); MockHttpServletResponse response = new MockHttpServletResponse(); - filter.doFilter(TestMockHttpServletRequests.get("/login?factor=ott&factor=password").build(), response, - this.chain); + filter.doFilter(TestMockHttpServletRequests + .get("/login?factor.type=ott&factor.type=password&factor.reason=missing&factor.reason=missing") + .build(), response, this.chain); assertThat(response.getContentAsString()).contains("Request a One-Time Token"); assertThat(response.getContentAsString()).contains("""