Sfoglia il codice sorgente

Use RequiredFactorErrors

Closes gh-18002
Rob Winch 1 settimana fa
parent
commit
2473378fcd
16 ha cambiato i file con 126 aggiunte e 54 eliminazioni
  1. 4 4
      config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java
  2. 3 3
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java
  3. 1 1
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java
  4. 3 3
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/enableglobalmfa/EnableGlobalMultiFactorAuthenticationTests.java
  5. 3 3
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java
  6. 1 1
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java
  7. 3 3
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt
  8. 1 1
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt
  9. 3 3
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/enableglobalmfa/AuthorizationManagerFactoryTests.kt
  10. 3 3
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt
  11. 1 1
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt
  12. 4 4
      web/src/main/java/org/springframework/security/web/WebAttributes.java
  13. 71 10
      web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java
  14. 14 7
      web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java
  15. 6 4
      web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java
  16. 5 3
      web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java

+ 4 - 4
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")

+ 3 - 3
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
 	}
 

+ 1 - 1
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
 	}
 

+ 3 - 3
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
 	}
 

+ 3 - 3
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
 	}
 

+ 1 - 1
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
 	}
 

+ 3 - 3
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
     }
 

+ 1 - 1
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
     }
 

+ 3 - 3
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
     }
 

+ 3 - 3
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
     }
 

+ 1 - 1
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
     }
 

+ 4 - 4
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}.
 	 * <p>
 	 * 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() {
 	}

+ 71 - 10
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<GrantedAuthority> authorities = missingAuthorities(denied);
-		for (GrantedAuthority needed : authorities) {
-			AuthenticationEntryPoint entryPoint = this.entryPoints.get(needed.getAuthority());
+		List<AuthorityRequiredFactorErrorEntry> 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<GrantedAuthority> missingAuthorities(AccessDeniedException ex) {
+	private List<AuthorityRequiredFactorErrorEntry> 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;
+		}
+
+	}
+
 }

+ 14 - 7
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<GrantedAuthority> authorities = getAttribute(request, WebAttributes.MISSING_AUTHORITIES,
+		Collection<RequiredFactorError> factorErrors = getAttribute(request, WebAttributes.REQUIRED_FACTOR_ERRORS,
 				Collection.class);
-		if (CollectionUtils.isEmpty(authorities)) {
+		if (CollectionUtils.isEmpty(factorErrors)) {
 			return getLoginFormUrl();
 		}
-		Collection<String> factors = authorities.stream()
-			.filter((a) -> a.getAuthority().startsWith(FACTOR_PREFIX))
-			.map((a) -> a.getAuthority().substring(FACTOR_PREFIX.length()).toLowerCase(Locale.ROOT))
+		List<String> 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<String> 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 <T> @Nullable T getAttribute(HttpServletRequest request, String name, Class<T> clazz) {

+ 6 - 4
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<String> allowedParameters = List.of(this.factorParameter);
+	private final String factorReasonParameter = "factor.reason";
+
+	private final Set<String> allowedParameters = Set.of(this.factorTypeParameter, this.factorReasonParameter);
 
 	@SuppressWarnings("NullAway.Init")
 	private Map<String, String> oauth2AuthenticationUrlToClientName;
@@ -281,7 +283,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 	}
 
 	private Predicate<String> wantsAuthority(HttpServletRequest request) {
-		String[] authorities = request.getParameterValues(this.factorParameter);
+		String[] authorities = request.getParameterValues(this.factorTypeParameter);
 		if (authorities == null) {
 			return (authority) -> true;
 		}

+ 5 - 3
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("""
 				      <form id="ott-form" class="login-form" method="post" action="/ott/authenticate">
@@ -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("""
 				      <form id="ott-form" class="login-form" method="post" action="/ott/authenticate">