Browse Source

Merge branch 'mfa'

Closes gh-2603
Josh Cummings 1 week ago
parent
commit
28aad8855c
47 changed files with 3105 additions and 21 deletions
  1. 1 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurer.java
  2. 53 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java
  3. 7 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java
  4. 4 2
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java
  5. 13 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java
  6. 3 1
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java
  7. 9 1
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java
  8. 2 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java
  9. 9 0
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java
  10. 9 1
      config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java
  11. 177 0
      config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java
  12. 1 0
      docs/modules/ROOT/nav.adoc
  13. 101 0
      docs/modules/ROOT/pages/servlet/authentication/adaptive.adoc
  14. 1 0
      docs/modules/ROOT/pages/whats-new.adoc
  15. 114 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java
  16. 54 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.java
  17. 60 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.java
  18. 103 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.java
  19. 97 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java
  20. 52 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.java
  21. 114 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java
  22. 147 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.java
  23. 102 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.java
  24. 37 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.java
  25. 93 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java
  26. 50 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/RequireOttConfiguration.java
  27. 46 0
      docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/SimpleConfiguration.java
  28. 119 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt
  29. 53 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.kt
  30. 60 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.kt
  31. 95 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.kt
  32. 93 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt
  33. 52 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.kt
  34. 114 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt
  35. 129 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.kt
  36. 104 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.kt
  37. 38 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.kt
  38. 93 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt
  39. 52 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/RequireOttConfiguration.kt
  40. 50 0
      docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/SimpleConfiguration.kt
  41. 14 0
      web/src/main/java/org/springframework/security/web/WebAttributes.java
  42. 209 0
      web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java
  43. 2 1
      web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java
  44. 29 1
      web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java
  45. 113 14
      web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java
  46. 145 0
      web/src/test/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandlerTests.java
  47. 82 0
      web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java

+ 1 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/DefaultLoginPageConfigurer.java

@@ -68,6 +68,7 @@ public final class DefaultLoginPageConfigurer<H extends HttpSecurityBuilder<H>>
 
 	@Override
 	public void init(H http) {
+		this.loginPageGeneratingFilter.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy());
 		this.loginPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs);
 		this.logoutPageGeneratingFilter.setResolveHiddenInputs(DefaultLoginPageConfigurer.this::hiddenInputs);
 		http.setSharedObject(DefaultLoginPageGeneratingFilter.class, this.loginPageGeneratingFilter);

+ 53 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java

@@ -17,15 +17,18 @@
 package org.springframework.security.config.annotation.web.configurers;
 
 import java.util.LinkedHashMap;
+import java.util.function.Consumer;
 
 import org.jspecify.annotations.Nullable;
 
 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.GrantedAuthority;
 import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.web.access.AccessDeniedHandler;
 import org.springframework.security.web.access.AccessDeniedHandlerImpl;
+import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler;
 import org.springframework.security.web.access.ExceptionTranslationFilter;
 import org.springframework.security.web.access.RequestMatcherDelegatingAccessDeniedHandler;
 import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
@@ -77,6 +80,8 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
 
 	private LinkedHashMap<RequestMatcher, AccessDeniedHandler> defaultDeniedHandlerMappings = new LinkedHashMap<>();
 
+	private DelegatingMissingAuthorityAccessDeniedHandler.@Nullable Builder missingAuthoritiesHandlerBuilder;
+
 	/**
 	 * Creates a new instance
 	 * @see HttpSecurity#exceptionHandling(Customizer)
@@ -127,6 +132,43 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
 		return this;
 	}
 
+	/**
+	 * Sets a default {@link AuthenticationEntryPoint} to be used which prefers being
+	 * invoked for the provided missing {@link GrantedAuthority}.
+	 * @param entryPoint the {@link AuthenticationEntryPoint} to use for the given
+	 * {@code authority}
+	 * @param authority the authority
+	 * @return the {@link ExceptionHandlingConfigurer} for further customizations
+	 * @since 7.0
+	 */
+	public ExceptionHandlingConfigurer<H> defaultDeniedHandlerForMissingAuthority(AuthenticationEntryPoint entryPoint,
+			String authority) {
+		if (this.missingAuthoritiesHandlerBuilder == null) {
+			this.missingAuthoritiesHandlerBuilder = DelegatingMissingAuthorityAccessDeniedHandler.builder();
+		}
+		this.missingAuthoritiesHandlerBuilder.addEntryPointFor(entryPoint, authority);
+		return this;
+	}
+
+	/**
+	 * Sets a default {@link AuthenticationEntryPoint} to be used which prefers being
+	 * invoked for the provided missing {@link GrantedAuthority}.
+	 * @param entryPoint a consumer of a
+	 * {@link DelegatingAuthenticationEntryPoint.Builder} to use for the given
+	 * {@code authority}
+	 * @param authority the authority
+	 * @return the {@link ExceptionHandlingConfigurer} for further customizations
+	 * @since 7.0
+	 */
+	public ExceptionHandlingConfigurer<H> defaultDeniedHandlerForMissingAuthority(
+			Consumer<DelegatingAuthenticationEntryPoint.Builder> entryPoint, String authority) {
+		if (this.missingAuthoritiesHandlerBuilder == null) {
+			this.missingAuthoritiesHandlerBuilder = DelegatingMissingAuthorityAccessDeniedHandler.builder();
+		}
+		this.missingAuthoritiesHandlerBuilder.addEntryPointFor(entryPoint, authority);
+		return this;
+	}
+
 	/**
 	 * Sets the {@link AuthenticationEntryPoint} to be used.
 	 *
@@ -229,6 +271,17 @@ public final class ExceptionHandlingConfigurer<H extends HttpSecurityBuilder<H>>
 	}
 
 	private AccessDeniedHandler createDefaultDeniedHandler(H http) {
+		AccessDeniedHandler defaults = createDefaultAccessDeniedHandler(http);
+		if (this.missingAuthoritiesHandlerBuilder == null) {
+			return defaults;
+		}
+		DelegatingMissingAuthorityAccessDeniedHandler deniedHandler = this.missingAuthoritiesHandlerBuilder.build();
+		deniedHandler.setRequestCache(getRequestCache(http));
+		deniedHandler.setDefaultAccessDeniedHandler(defaults);
+		return deniedHandler;
+	}
+
+	private AccessDeniedHandler createDefaultAccessDeniedHandler(H http) {
 		if (this.defaultDeniedHandlerMappings.isEmpty()) {
 			return new AccessDeniedHandlerImpl();
 		}

+ 7 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java

@@ -231,6 +231,13 @@ public final class FormLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
 	public void init(H http) throws Exception {
 		super.init(http);
 		initDefaultLoginFilter(http);
+		ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
+		if (exceptions != null) {
+			AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint();
+			RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
+			exceptions.defaultDeniedHandlerForMissingAuthority((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher),
+					"FACTOR_PASSWORD");
+		}
 	}
 
 	@Override

+ 4 - 2
config/src/main/java/org/springframework/security/config/annotation/web/configurers/HttpBasicConfigurer.java

@@ -192,8 +192,10 @@ public final class HttpBasicConfigurer<B extends HttpSecurityBuilder<B>>
 		if (exceptionHandling == null) {
 			return;
 		}
-		exceptionHandling.defaultAuthenticationEntryPointFor(postProcess(this.authenticationEntryPoint),
-				preferredMatcher);
+		AuthenticationEntryPoint entryPoint = postProcess(this.authenticationEntryPoint);
+		exceptionHandling.defaultAuthenticationEntryPointFor(entryPoint, preferredMatcher);
+		exceptionHandling.defaultDeniedHandlerForMissingAuthority(
+				(ep) -> ep.addEntryPointFor(entryPoint, preferredMatcher), "FACTOR_PASSWORD");
 	}
 
 	private void registerDefaultLogoutSuccessHandler(B http, RequestMatcher preferredMatcher) {

+ 13 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java

@@ -27,11 +27,14 @@ import org.springframework.http.converter.HttpMessageConverter;
 import org.springframework.security.authentication.ProviderManager;
 import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
 import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.web.access.intercept.AuthorizationFilter;
+import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
 import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
 import org.springframework.security.web.authentication.ui.DefaultResourcesFilter;
 import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
 import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.security.web.util.matcher.AnyRequestMatcher;
 import org.springframework.security.web.webauthn.api.PublicKeyCredentialRpEntity;
 import org.springframework.security.web.webauthn.authentication.PublicKeyCredentialRequestOptionsFilter;
 import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
@@ -150,6 +153,16 @@ public class WebAuthnConfigurer<H extends HttpSecurityBuilder<H>>
 		return this;
 	}
 
+	@Override
+	public void init(H http) throws Exception {
+		ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
+		if (exceptions != null) {
+			AuthenticationEntryPoint entryPoint = new LoginUrlAuthenticationEntryPoint("/login");
+			exceptions.defaultDeniedHandlerForMissingAuthority(
+					(ep) -> ep.addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE), "FACTOR_WEBAUTHN");
+		}
+	}
+
 	@Override
 	public void configure(H http) throws Exception {
 		UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class)

+ 3 - 1
config/src/main/java/org/springframework/security/config/annotation/web/configurers/X509Configurer.java

@@ -184,7 +184,9 @@ public final class X509Configurer<H extends HttpSecurityBuilder<H>>
 			.setSharedObject(AuthenticationEntryPoint.class, new Http403ForbiddenEntryPoint());
 		ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
 		if (exceptions != null) {
-			exceptions.defaultAuthenticationEntryPointFor(new Http403ForbiddenEntryPoint(), AnyRequestMatcher.INSTANCE);
+			AuthenticationEntryPoint forbidden = new Http403ForbiddenEntryPoint();
+			exceptions.defaultDeniedHandlerForMissingAuthority(
+					(ep) -> ep.addEntryPointFor(forbidden, AnyRequestMatcher.INSTANCE), "FACTOR_X509");
 		}
 	}
 

+ 9 - 1
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/client/OAuth2LoginConfigurer.java

@@ -40,6 +40,7 @@ import org.springframework.security.config.annotation.web.HttpSecurityBuilder;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
 import org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer;
 import org.springframework.security.context.DelegatingApplicationListener;
 import org.springframework.security.core.Authentication;
@@ -556,11 +557,18 @@ public final class OAuth2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 		RequestMatcher loginUrlMatcher = new AndRequestMatcher(notXRequestedWith,
 				new NegatedRequestMatcher(defaultLoginPageMatcher), formLoginNotEnabled);
 		// @formatter:off
-		return DelegatingAuthenticationEntryPoint.builder()
+		AuthenticationEntryPoint loginEntryPoint = DelegatingAuthenticationEntryPoint.builder()
 			.addEntryPointFor(loginUrlEntryPoint, loginUrlMatcher)
 			.defaultEntryPoint(getAuthenticationEntryPoint())
 			.build();
 		// @formatter:on
+		ExceptionHandlingConfigurer<B> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
+		if (exceptions != null) {
+			RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
+			exceptions.defaultDeniedHandlerForMissingAuthority(
+					(ep) -> ep.addEntryPointFor(loginEntryPoint, requestMatcher), "FACTOR_AUTHORIZATION_CODE");
+		}
+		return loginEntryPoint;
 	}
 
 	private RequestMatcher getFormLoginNotEnabledRequestMatcher(B http) {

+ 2 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java

@@ -327,6 +327,8 @@ public final class OAuth2ResourceServerConfigurer<H extends HttpSecurityBuilder<
 			RequestMatcher preferredMatcher = new OrRequestMatcher(
 					Arrays.asList(this.requestMatcher, X_REQUESTED_WITH, restNotHtmlMatcher, allMatcher));
 			exceptionHandling.defaultAuthenticationEntryPointFor(this.authenticationEntryPoint, preferredMatcher);
+			exceptionHandling.defaultDeniedHandlerForMissingAuthority(
+					(ep) -> ep.addEntryPointFor(this.authenticationEntryPoint, preferredMatcher), "FACTOR_BEARER");
 		}
 	}
 

+ 9 - 0
config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java

@@ -35,8 +35,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.web.authentication.AuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
@@ -134,6 +136,13 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 		AuthenticationProvider authenticationProvider = getAuthenticationProvider();
 		http.authenticationProvider(postProcess(authenticationProvider));
 		intiDefaultLoginFilter(http);
+		ExceptionHandlingConfigurer<H> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
+		if (exceptions != null) {
+			AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint();
+			RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
+			exceptions.defaultDeniedHandlerForMissingAuthority((ep) -> ep.addEntryPointFor(entryPoint, requestMatcher),
+					"FACTOR_OTT");
+		}
 	}
 
 	private void intiDefaultLoginFilter(H http) {

+ 9 - 1
config/src/main/java/org/springframework/security/config/annotation/web/configurers/saml2/Saml2LoginConfigurer.java

@@ -33,6 +33,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer;
 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
 import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
+import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.saml2.provider.service.authentication.AbstractSaml2AuthenticationRequest;
 import org.springframework.security.saml2.provider.service.authentication.OpenSaml5AuthenticationProvider;
@@ -343,11 +344,18 @@ public final class Saml2LoginConfigurer<B extends HttpSecurityBuilder<B>>
 		RequestMatcher loginUrlMatcher = new AndRequestMatcher(notXRequestedWith,
 				new NegatedRequestMatcher(defaultLoginPageMatcher));
 		// @formatter:off
-		return DelegatingAuthenticationEntryPoint.builder()
+		AuthenticationEntryPoint loginEntryPoint = DelegatingAuthenticationEntryPoint.builder()
 				.addEntryPointFor(loginUrlEntryPoint, loginUrlMatcher)
 				.defaultEntryPoint(getAuthenticationEntryPoint())
 				.build();
 		// @formatter:on
+		ExceptionHandlingConfigurer<B> exceptions = http.getConfigurer(ExceptionHandlingConfigurer.class);
+		if (exceptions != null) {
+			RequestMatcher requestMatcher = getAuthenticationEntryPointMatcher(http);
+			exceptions.defaultDeniedHandlerForMissingAuthority(
+					(ep) -> ep.addEntryPointFor(loginEntryPoint, requestMatcher), "FACTOR_SAML_RESPONSE");
+		}
+		return loginEntryPoint;
 	}
 
 	private void setAuthenticationRequestRepository(B http,

+ 177 - 0
config/src/test/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurerTests.java

@@ -22,9 +22,18 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.authorization.AllAuthoritiesAuthorizationManager;
+import org.springframework.security.authorization.AuthenticatedAuthorizationManager;
+import org.springframework.security.authorization.AuthorityAuthorizationManager;
+import org.springframework.security.authorization.AuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationManager;
+import org.springframework.security.authorization.AuthorizationManagers;
+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.method.configuration.EnableMethodSecurity;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
@@ -34,17 +43,25 @@ import org.springframework.security.config.users.AuthenticationTestConfiguration
 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.User;
+import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.core.userdetails.UserDetailsService;
 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.access.intercept.RequestAuthorizationContext;
 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.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.servlet.config.annotation.EnableWebMvc;
 
 import static org.mockito.ArgumentMatchers.any;
@@ -57,6 +74,7 @@ import static org.springframework.security.config.Customizer.withDefaults;
 import static org.springframework.security.config.annotation.SecurityContextChangedListenerArgumentMatchers.setAuthentication;
 import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
 import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.logout;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
 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;
@@ -378,6 +396,62 @@ public class FormLoginConfigurerTests {
 		verify(ObjectPostProcessorConfig.objectPostProcessor).postProcess(any(ExceptionTranslationFilter.class));
 	}
 
+	@Test
+	void requestWhenUnauthenticatedThenRequiresTwoSteps() throws Exception {
+		this.spring.register(MfaDslConfig.class, UserConfig.class).autowire();
+		UserDetails user = PasswordEncodedUser.user();
+		this.mockMvc.perform(get("/profile").with(user(user)))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=password"));
+		this.mockMvc
+			.perform(post("/ott/generate").param("username", "rod")
+				.with(user(user))
+				.with(SecurityMockMvcRequestPostProcessors.csrf()))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("/ott/sent"));
+		this.mockMvc
+			.perform(post("/login").param("username", "rod")
+				.param("password", "password")
+				.with(SecurityMockMvcRequestPostProcessors.csrf()))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("/"));
+		user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_OTT").build();
+		this.mockMvc.perform(get("/profile").with(user(user)))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=password"));
+		user = PasswordEncodedUser.withUserDetails(user).authorities("profile:read", "FACTOR_PASSWORD").build();
+		this.mockMvc.perform(get("/profile").with(user(user)))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=ott"));
+		user = PasswordEncodedUser.withUserDetails(user)
+			.authorities("profile:read", "FACTOR_PASSWORD", "FACTOR_OTT")
+			.build();
+		this.mockMvc.perform(get("/profile").with(user(user))).andExpect(status().isNotFound());
+	}
+
+	@Test
+	void requestWhenUnauthenticatedX509ThenRequiresTwoSteps() throws Exception {
+		this.spring.register(MfaDslX509Config.class, UserConfig.class, BasicMfaController.class).autowire();
+		this.mockMvc.perform(get("/profile")).andExpect(status().is3xxRedirection());
+		this.mockMvc.perform(get("/profile").with(user(User.withUsername("rod").authorities("profile:read").build())))
+			.andExpect(status().isForbidden());
+		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"));
+		this.mockMvc
+			.perform(post("/login").param("username", "rod")
+				.param("password", "password")
+				.with(SecurityMockMvcRequestPostProcessors.x509("rod.cer"))
+				.with(SecurityMockMvcRequestPostProcessors.csrf()))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("/"));
+		UserDetails authorized = PasswordEncodedUser.withUsername("rod")
+			.authorities("profile:read", "FACTOR_X509", "FACTOR_PASSWORD")
+			.build();
+		this.mockMvc.perform(get("/profile").with(user(authorized))).andExpect(status().isOk());
+	}
+
 	@Configuration
 	@EnableWebSecurity
 	static class RequestCacheConfig {
@@ -714,4 +788,107 @@ public class FormLoginConfigurerTests {
 
 	}
 
+	@Configuration
+	@EnableWebSecurity
+	static class MfaDslConfig {
+
+		@Bean
+		SecurityFilterChain filterChain(HttpSecurity http,
+				AuthorizationManagerFactory<RequestAuthorizationContext> authz) throws Exception {
+			// @formatter:off
+			http
+				.formLogin(Customizer.withDefaults())
+				.oneTimeTokenLogin(Customizer.withDefaults())
+				.authorizeHttpRequests((authorize) -> authorize
+					.requestMatchers("/profile").access(authz.hasAuthority("profile:read"))
+					.anyRequest().access(authz.authenticated())
+				);
+			return http.build();
+			// @formatter:on
+		}
+
+		@Bean
+		OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
+			return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
+		}
+
+		@Bean
+		AuthorizationManagerFactory<?> authz() {
+			return new AuthorizationManagerFactory<>("FACTOR_PASSWORD", "FACTOR_OTT");
+		}
+
+	}
+
+	@Configuration
+	@EnableWebSecurity
+	@EnableMethodSecurity
+	static class MfaDslX509Config {
+
+		@Bean
+		SecurityFilterChain filterChain(HttpSecurity http,
+				AuthorizationManagerFactory<RequestAuthorizationContext> authz) throws Exception {
+			// @formatter:off
+			http
+				.x509(Customizer.withDefaults())
+				.formLogin(Customizer.withDefaults())
+				.authorizeHttpRequests((authorize) -> authorize
+					.anyRequest().access(authz.authenticated())
+				);
+			return http.build();
+			// @formatter:on
+		}
+
+		@Bean
+		AuthorizationManagerFactory<?> authz() {
+			return new AuthorizationManagerFactory<>("FACTOR_X509", "FACTOR_PASSWORD");
+		}
+
+	}
+
+	@Configuration
+	static class UserConfig {
+
+		@Bean
+		UserDetails rod() {
+			return PasswordEncodedUser.withUsername("rod").password("password").build();
+		}
+
+		@Bean
+		UserDetailsService users(UserDetails user) {
+			return new InMemoryUserDetailsManager(user);
+		}
+
+	}
+
+	@RestController
+	static class BasicMfaController {
+
+		@GetMapping("/profile")
+		@PreAuthorize("@authz.hasAuthority('profile:read')")
+		String profile() {
+			return "profile";
+		}
+
+	}
+
+	public static class AuthorizationManagerFactory<T> {
+
+		private final AuthorizationManager<T> authorities;
+
+		AuthorizationManagerFactory(String... authorities) {
+			this.authorities = AllAuthoritiesAuthorizationManager.hasAllAuthorities(authorities);
+		}
+
+		public AuthorizationManager<T> authenticated() {
+			AuthenticatedAuthorizationManager<T> authenticated = AuthenticatedAuthorizationManager.authenticated();
+			return AuthorizationManagers.allOf(new AuthorizationDecision(false), this.authorities, authenticated);
+		}
+
+		public AuthorizationManager<T> hasAuthority(String authority) {
+			AuthorityAuthorizationManager<T> authorized = AuthorityAuthorizationManager.hasAuthority(authority);
+			return AuthorizationManagers.allOf(new AuthorizationDecision(false), this.authorities, authorized);
+		}
+
+	}
+
 }

+ 1 - 0
docs/modules/ROOT/nav.adoc

@@ -49,6 +49,7 @@
 ***** xref:servlet/authentication/passwords/password-encoder.adoc[PasswordEncoder]
 ***** xref:servlet/authentication/passwords/dao-authentication-provider.adoc[DaoAuthenticationProvider]
 ***** xref:servlet/authentication/passwords/ldap.adoc[LDAP]
+*** xref:servlet/authentication/adaptive.adoc[Multifactor Authentication]
 *** xref:servlet/authentication/persistence.adoc[Persistence]
 *** xref:servlet/authentication/passkeys.adoc[Passkeys]
 *** xref:servlet/authentication/onetimetoken.adoc[One-Time Token]

+ 101 - 0
docs/modules/ROOT/pages/servlet/authentication/adaptive.adoc

@@ -0,0 +1,101 @@
+= Adaptive Authentication
+
+Since authentication needs can vary from person-to-person and even from one login attempt to the next, Spring Security supports adapting authentication requirements to each situation.
+
+Some of the most common applications of this principal are:
+
+1. *Re-authentication* - Users need to provide authentication again in order to enter an area of elevated security
+2. *Multi-factor Authentication* - Users need more than one authentication mechanism to pass in order to access secured resources
+3. *Authorizing More Scopes* - Users are allowed to consent to a subset of scopes from an OAuth 2.0 Authorization Server.
+Then, if later on a scope that they did not grant is needed, consent can be re-requested for just that scope.
+4. *Opting-in to Stronger Authentication Mechanisms* - Users may not be ready yet to start using MFA, but the application wants to allow the subset of security-minded users to opt-in.
+5. *Requiring Additional Steps for Suspicious Logins* - The application may notice that the user's IP address has changed, that they are behind a VPN, or some other consideration that requires additional verification
+
+[[re-authentication]]
+== Re-authentication
+
+The most common of these is re-authentication.
+Imagine an application configured in the following way:
+
+include-code::./SimpleConfiguration[tag=httpSecurity,indent=0]
+
+By default, this application has two authentication mechanisms that it allows, meaning that the user could use either one and be fully-authenticated.
+
+If there is a set of endpoints that require a specific factor, we can specify that in `authorizeHttpRequests` as follows:
+
+include-code::./RequireOttConfiguration[tag=httpSecurity,indent=0]
+<1> - States that all `/profile/**` endpoints require one-time-token login to be authorized
+
+Given the above configuration, users can log in with any mechanism that you support.
+And, if they want to visit the profile page, then Spring Security will redirect them to the One-Time-Token Login page to obtain it.
+
+In this way, the authority given to a user is directly proportional to the amount of proof given.
+This adaptive approach allows users to give only the proof needed to perform their intended operations.
+
+[[multi-factor-authentication]]
+== Multi-Factor Authentication
+
+You may require that all users require both One-Time-Token login and Username/Password login to access any part of your site.
+
+To require both, you can state an authorization rule with `anyRequest` like so:
+
+include-code::./ListAuthoritiesConfiguration[tag=httpSecurity,indent=0]
+<1> - This states that both `FACTOR_PASSWORD` and `FACTOR_OTT` are needed to use any part of the application
+
+Spring Security behind the scenes knows which endpoint to go to depending on which authority is missing.
+If the user logged in initially with their username and password, then Spring Security redirects to the One-Time-Token Login page.
+If the user logged in initially with a token, then Spring Security redirects to the Username/Password Login page.
+
+[[authorization-manager-factory]]
+=== Requiring MFA For All Endpoints
+
+Specifying all authorities for each request pattern could be unwanted boilerplate:
+
+include-code::./ListAuthoritiesEverywhereConfiguration[tag=httpSecurity,indent=0]
+<1> - Since all authorities need to be specified for each endpoint, deploying MFA in this way can create unwanted boilerplate
+
+This can be remedied by publishing an `AuthorizationManagerFactory` bean like so:
+
+include-code::./UseAuthorizationManagerFactoryConfiguration[tag=authorizationManagerFactoryBean,indent=0]
+
+This yields a more familiar configuration:
+
+include-code::./UseAuthorizationManagerFactoryConfiguration[tag=httpSecurity,indent=0]
+
+[[obtaining-more-authorization]]
+== Authorizing More Scopes
+
+You can also configure exception handling to direct Spring Security on how to obtain a missing scope.
+
+Consider an application that requires a specific OAuth 2.0 scope for a given endpoint:
+
+include-code::./ScopeConfiguration[tag=httpSecurity,indent=0]
+
+If this is also configured with an `AuthorizationManagerFactory` bean like this one:
+
+include-code::./MissingAuthorityConfiguration[tag=authorizationManagerFactoryBean,indent=0]
+
+Then the application will require an X.509 certificate as well as authorization from an OAuth 2.0 authorization server.
+
+In the event that the user does not consent to `profile:read`, this application as it stands will issue a 403.
+However, if you have a way for the application to re-ask for consent, then you can implement this in an `AuthenticationEntryPoint` like the following:
+
+include-code::./MissingAuthorityConfiguration[tag=authenticationEntryPoint,indent=0]
+
+Then, your filter chain declaration can bind this entry point to the given authority like so:
+
+include-code::./MissingAuthorityConfiguration[tag=httpSecurity,indent=0]
+
+[[custom-authorization-manager-factory]]
+== Programmatically Decide Which Authorities Are Required
+
+`AuthorizationManager` is the core interface for making authorization decisions.
+Consider an authorization manager that looks at the logged in user to decide which factors are necessary:
+
+include-code::./CustomAuthorizationManagerFactory[tag=authorizationManager,indent=0]
+
+In this case, using One-Time-Token is only required for those who have opted in.
+
+This can then be enforced by a custom `AuthorizationManagerFactory` implementation:
+
+include-code::./CustomAuthorizationManagerFactory[tag=authorizationManagerFactory,indent=0]

+ 1 - 0
docs/modules/ROOT/pages/whats-new.adoc

@@ -15,6 +15,7 @@ Each section that follows will indicate the more notable removals as well as the
 
 == Core
 
+* Added Support for xref:servlet/authentication/adaptive.adoc[Multi-factor Authentication]
 * Removed `AuthorizationManager#check` in favor of `AuthorizationManager#authorize`
 * Added javadoc:org.springframework.security.authorization.AllAuthoritiesAuthorizationManager[] and javadoc:org.springframework.security.authorization.AllAuthoritiesReactiveAuthorizationManager[] along with corresponding methods for xref:servlet/authorization/authorize-http-requests.adoc#authorize-requests[Authorizing `HttpServletRequests`] and xref:servlet/authorization/method-security.adoc#using-authorization-expression-fields-and-methods[method security expressions].
 * Added xref:servlet/authorization/architecture.adoc#authz-authorization-manager-factory[`AuthorizationManagerFactory`] for creating `AuthorizationManager` instances in xref:servlet/authorization/authorize-http-requests.adoc#customizing-authorization-managers[request-based] and xref:servlet/authorization/method-security.adoc#customizing-authorization-managers[method-based] authorization components

+ 114 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.java

@@ -0,0 +1,114 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.servlet.authentication.authorizationmanagerfactory;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
+import org.springframework.test.context.TestExecutionListeners;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+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.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Tests {@link CustomX509Configuration}.
+ *
+ * @author Rob Winch
+ */
+@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
+@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
+public class AuthorizationManagerFactoryTests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	MockMvc mockMvc;
+
+	@Test
+	@WithMockUser(authorities = { "FACTOR_PASSWORD", "FACTOR_OTT" })
+	void getWhenAuthenticatedWithPasswordAndOttThenPermits() throws Exception {
+		this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().isOk())
+			.andExpect(authenticated().withUsername("user"));
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser(authorities = "FACTOR_PASSWORD")
+	void getWhenAuthenticatedWithPasswordThenRedirectsToOtt() throws Exception {
+		this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=ott"));
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser(authorities = "FACTOR_OTT")
+	void getWhenAuthenticatedWithOttThenRedirectsToPassword() throws Exception {
+		this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=password"));
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser
+	void getWhenAuthenticatedThenRedirectsToPassword() throws Exception {
+		this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=password"));
+		// @formatter:on
+	}
+
+	@Test
+	void getWhenUnauthenticatedThenRedirectsToBoth() throws Exception {
+		this.spring.register(UseAuthorizationManagerFactoryConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login"));
+		// @formatter:on
+	}
+
+	@RestController
+	static class Http200Controller {
+		@GetMapping("/**")
+		String ok() {
+			return "ok";
+		}
+	}
+}

+ 54 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.java

@@ -0,0 +1,54 @@
+package org.springframework.security.docs.servlet.authentication.authorizationmanagerfactory;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
+
+import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasAuthority;
+import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasRole;
+import static org.springframework.security.authorization.AuthorizationManagers.allOf;
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+public class ListAuthoritiesEverywhereConfiguration {
+
+	// tag::httpSecurity[]
+	@Bean
+	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+		// @formatter:off
+		http
+			.authorizeHttpRequests((authorize) -> authorize
+				.requestMatchers("/admin/**").access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"), hasRole("ADMIN"))) // <1>
+				.anyRequest().access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT")))
+			)
+			.formLogin(Customizer.withDefaults())
+			.oneTimeTokenLogin(Customizer.withDefaults());
+		// @formatter:on
+		return http.build();
+	}
+	// end::httpSecurity[]
+
+	@Bean
+	UserDetailsService userDetailsService() {
+		return new InMemoryUserDetailsManager(
+				User.withDefaultPasswordEncoder()
+						.username("user")
+						.password("password")
+						.authorities("app")
+						.build()
+		);
+	}
+
+	@Bean
+	OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
+		return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
+	}
+}

+ 60 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.java

@@ -0,0 +1,60 @@
+package org.springframework.security.docs.servlet.authentication.authorizationmanagerfactory;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authorization.AuthorizationManagerFactory;
+import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+class UseAuthorizationManagerFactoryConfiguration {
+
+	// tag::httpSecurity[]
+	@Bean
+	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+		// @formatter:off
+		http
+			.authorizeHttpRequests((authorize) -> authorize
+				.requestMatchers("/admin/**").hasRole("ADMIN")
+				.anyRequest().authenticated()
+			)
+			.formLogin(Customizer.withDefaults())
+			.oneTimeTokenLogin(Customizer.withDefaults());
+		// @formatter:on
+		return http.build();
+	}
+	// end::httpSecurity[]
+
+	// tag::authorizationManagerFactoryBean[]
+	@Bean
+	AuthorizationManagerFactory<Object> authz() {
+		return DefaultAuthorizationManagerFactory.builder()
+				.requireAdditionalAuthorities("FACTOR_PASSWORD", "FACTOR_OTT").build();
+	}
+	// end::authorizationManagerFactoryBean[]
+
+	@Bean
+	UserDetailsService userDetailsService() {
+		return new InMemoryUserDetailsManager(
+				User.withDefaultPasswordEncoder()
+						.username("user")
+						.password("password")
+						.authorities("app")
+						.build()
+		);
+	}
+
+	@Bean
+	OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
+		return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
+	}
+}

+ 103 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.java

@@ -0,0 +1,103 @@
+package org.springframework.security.docs.servlet.authentication.customauthorizationmanagerfactory;
+
+import java.util.Collection;
+import java.util.function.Supplier;
+
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.access.expression.SecurityExpressionOperations;
+import org.springframework.security.access.expression.SecurityExpressionRoot;
+import org.springframework.security.authorization.AuthorityAuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationManager;
+import org.springframework.security.authorization.AuthorizationManagerFactory;
+import org.springframework.security.authorization.AuthorizationResult;
+import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
+import org.springframework.stereotype.Component;
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+class CustomAuthorizationManagerFactory {
+	// tag::httpSecurity[]
+	@Bean
+	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+		// @formatter:off
+		http
+			.authorizeHttpRequests((authorize) -> authorize
+				.requestMatchers("/admin/**").hasRole("ADMIN")
+				.anyRequest().authenticated()
+			)
+			.formLogin(Customizer.withDefaults())
+			.oneTimeTokenLogin(Customizer.withDefaults());
+		// @formatter:on
+		return http.build();
+	}
+	// end::httpSecurity[]
+
+	// tag::authorizationManager[]
+	@Component
+	class OptInToMfaAuthorizationManager implements AuthorizationManager<Object> {
+		@Override
+		public AuthorizationResult authorize(Supplier<? extends @Nullable Authentication> authentication, Object context) {
+			MyPrincipal principal = (MyPrincipal) authentication.get().getPrincipal();
+			if (principal.optedIn()) {
+				SecurityExpressionOperations sec = new SecurityExpressionRoot<>(authentication, context) {};
+				return new AuthorityAuthorizationDecision(sec.hasAuthority("FACTOR_OTT"),
+						AuthorityUtils.createAuthorityList("FACTOR_OTT"));
+			}
+			return new AuthorizationDecision(true);
+		}
+	}
+	// end::authorizationManager[]
+
+	// tag::authorizationManagerFactory[]
+	@Bean
+	AuthorizationManagerFactory<Object> authorizationManagerFactory(OptInToMfaAuthorizationManager optIn) {
+		DefaultAuthorizationManagerFactory<Object> defaults = new DefaultAuthorizationManagerFactory<>();
+		defaults.setAdditionalAuthorization(optIn);
+		return defaults;
+	}
+	// end::authorizationManagerFactory[]
+
+	@NullMarked
+	record MyPrincipal(String username, boolean optedIn) implements UserDetails {
+		@Override
+		public Collection<? extends GrantedAuthority> getAuthorities() {
+			return AuthorityUtils.createAuthorityList("app");
+		}
+
+		@Override
+		public @Nullable String getPassword() {
+			return null;
+		}
+
+		@Override
+		public String getUsername() {
+			return this.username;
+		}
+	}
+
+	@Bean
+	UserDetailsService users() {
+		return (username) -> new MyPrincipal(username, username.equals("optedin"));
+	}
+
+	@Bean
+	OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
+		return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
+	}
+}

+ 97 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.java

@@ -0,0 +1,97 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.servlet.authentication.customauthorizationmanagerfactory;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+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.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Tests {@link CustomX509Configuration}.
+ *
+ * @author Rob Winch
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class CustomAuthorizationManagerFactoryTests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	MockMvc mockMvc;
+
+	@Autowired
+	UserDetailsService users;
+
+	@Test
+	void getWhenOptedInThenRedirectsToOtt() throws Exception {
+		this.spring.register(CustomAuthorizationManagerFactory.class, Http200Controller.class).autowire();
+		UserDetails user = this.users.loadUserByUsername("optedin");
+		// @formatter:off
+		this.mockMvc.perform(get("/").with(user(user)))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=ott"));
+		// @formatter:on
+	}
+
+	@Test
+	void getWhenNotOptedInThenAllows() throws Exception {
+		this.spring.register(CustomAuthorizationManagerFactory.class, Http200Controller.class).autowire();
+		UserDetails user = this.users.loadUserByUsername("user");
+		// @formatter:off
+		this.mockMvc.perform(get("/").with(user(user)))
+			.andExpect(status().isOk())
+			.andExpect(authenticated().withUsername("user"));
+		// @formatter:on
+	}
+
+	@Test
+	void getWhenOptedAndHasFactorThenAllows() throws Exception {
+		this.spring.register(CustomAuthorizationManagerFactory.class, Http200Controller.class).autowire();
+		UserDetails user = this.users.loadUserByUsername("optedin");
+		TestingAuthenticationToken token = new TestingAuthenticationToken(user, "", "FACTOR_OTT");
+		// @formatter:off
+		this.mockMvc.perform(get("/").with(authentication(token)))
+			.andExpect(status().isOk())
+			.andExpect(authenticated().withUsername("optedin"));
+		// @formatter:on
+	}
+
+	@RestController
+	static class Http200Controller {
+		@GetMapping("/**")
+		String ok() {
+			return "ok";
+		}
+	}
+}

+ 52 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.java

@@ -0,0 +1,52 @@
+package org.springframework.security.docs.servlet.authentication.multifactorauthentication;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
+
+import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasAuthority;
+import static org.springframework.security.authorization.AuthorizationManagers.allOf;
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+class ListAuthoritiesConfiguration {
+
+	// tag::httpSecurity[]
+	@Bean
+	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+		// @formatter:off
+		http
+			.authorizeHttpRequests((authorize) -> authorize
+				.anyRequest().access(allOf(hasAuthority("FACTOR_PASSWORD"), hasAuthority("FACTOR_OTT"))) // <1>
+			)
+			.formLogin(Customizer.withDefaults())
+			.oneTimeTokenLogin(Customizer.withDefaults());
+		// @formatter:on
+		return http.build();
+	}
+	// end::httpSecurity[]
+
+	@Bean
+	UserDetailsService userDetailsService() {
+		return new InMemoryUserDetailsManager(
+				User.withDefaultPasswordEncoder()
+						.username("user")
+						.password("password")
+						.authorities("app")
+						.build()
+		);
+	}
+
+	@Bean
+	OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
+		return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
+	}
+}

+ 114 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.java

@@ -0,0 +1,114 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.servlet.authentication.multifactorauthentication;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
+import org.springframework.test.context.TestExecutionListeners;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+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.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Tests {@link CustomX509Configuration}.
+ *
+ * @author Rob Winch
+ */
+@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
+@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
+public class MultiFactorAuthenticationTests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	MockMvc mockMvc;
+
+	@Test
+	@WithMockUser(authorities = { "FACTOR_PASSWORD", "FACTOR_OTT" })
+	void getWhenAuthenticatedWithPasswordAndOttThenPermits() throws Exception {
+		this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().isOk())
+			.andExpect(authenticated().withUsername("user"));
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser(authorities = "FACTOR_PASSWORD")
+	void getWhenAuthenticatedWithPasswordThenRedirectsToOtt() throws Exception {
+		this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=ott"));
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser(authorities = "FACTOR_OTT")
+	void getWhenAuthenticatedWithOttThenRedirectsToPassword() throws Exception {
+		this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=password"));
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser
+	void getWhenAuthenticatedThenRedirectsToPassword() throws Exception {
+		this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=password"));
+		// @formatter:on
+	}
+
+	@Test
+	void getWhenUnauthenticatedThenRedirectsToBoth() throws Exception {
+		this.spring.register(ListAuthoritiesConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login"));
+		// @formatter:on
+	}
+
+	@RestController
+	static class Http200Controller {
+		@GetMapping("/**")
+		String ok() {
+			return "ok";
+		}
+	}
+}

+ 147 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.java

@@ -0,0 +1,147 @@
+package org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization;
+
+import java.io.IOException;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.authorization.AuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationManager;
+import org.springframework.security.authorization.AuthorizationManagerFactory;
+import org.springframework.security.authorization.DefaultAuthorizationManagerFactory;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.access.intercept.RequestAuthorizationContext;
+import org.springframework.stereotype.Component;
+
+import static org.springframework.security.authorization.AllAuthoritiesAuthorizationManager.hasAllAuthorities;
+import static org.springframework.security.authorization.AuthorizationManagers.allOf;
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+class MissingAuthorityConfiguration {
+
+	// tag::httpSecurity[]
+	@Bean
+	SecurityFilterChain securityFilterChain(HttpSecurity http, ScopeRetrievingAuthenticationEntryPoint oauth2) throws Exception {
+		// @formatter:off
+		http
+			.authorizeHttpRequests((authorize) -> authorize
+				.requestMatchers("/profile/**").hasAuthority("SCOPE_profile:read")
+				.anyRequest().authenticated()
+			)
+			.x509(Customizer.withDefaults())
+			.oauth2Login(Customizer.withDefaults())
+			.exceptionHandling((exceptions) -> exceptions
+				.defaultDeniedHandlerForMissingAuthority(oauth2, "SCOPE_profile:read")
+			);
+		// @formatter:on
+		return http.build();
+	}
+	// end::httpSecurity[]
+
+	// tag::authorizationManagerFactoryBean[]
+	@Bean
+	AuthorizationManagerFactory<RequestAuthorizationContext> authz() {
+		return new FactorAuthorizationManagerFactory(hasAllAuthorities("FACTOR_X509", "FACTOR_AUTHORIZATION_CODE"));
+	}
+	// end::authorizationManagerFactoryBean[]
+
+	// tag::authorizationManagerFactory[]
+	class FactorAuthorizationManagerFactory implements AuthorizationManagerFactory<RequestAuthorizationContext> {
+		private final AuthorizationManager<RequestAuthorizationContext> hasAuthorities;
+		private final DefaultAuthorizationManagerFactory<RequestAuthorizationContext> delegate =
+				new DefaultAuthorizationManagerFactory<>();
+
+		FactorAuthorizationManagerFactory(AuthorizationManager<RequestAuthorizationContext> hasAuthorities) {
+			this.hasAuthorities = hasAuthorities;
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> permitAll() {
+			return this.delegate.permitAll();
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> denyAll() {
+			return this.delegate.denyAll();
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> hasRole(String role) {
+			return hasAnyRole(role);
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> hasAnyRole(String... roles) {
+			return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAnyRole(roles));
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> hasAllRoles(String... roles) {
+			return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAllRoles(roles));
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> hasAuthority(String authority) {
+			return hasAnyAuthority(authority);
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> hasAnyAuthority(String... authorities) {
+			return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAnyAuthority(authorities));
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> hasAllAuthorities(String... authorities) {
+			return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.hasAllAuthorities(authorities));
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> authenticated() {
+			return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.authenticated());
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> fullyAuthenticated() {
+			return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.fullyAuthenticated());
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> rememberMe() {
+			return allOf(new AuthorizationDecision(false), this.hasAuthorities, this.delegate.rememberMe());
+		}
+
+		@Override
+		public AuthorizationManager<RequestAuthorizationContext> anonymous() {
+			return this.delegate.anonymous();
+		}
+	}
+	// end::authorizationManagerFactory[]
+
+	// tag::authenticationEntryPoint[]
+	@Component
+	class ScopeRetrievingAuthenticationEntryPoint implements AuthenticationEntryPoint {
+		@Override
+		public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
+				throws IOException, ServletException {
+			response.sendRedirect("https://authz.example.org/authorize?scope=profile:read");
+		}
+	}
+	// end::authenticationEntryPoint[]
+
+	@Bean
+	ClientRegistrationRepository clients() {
+		return new InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build());
+	}
+}

+ 102 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.java

@@ -0,0 +1,102 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
+import org.springframework.test.context.TestExecutionListeners;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+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.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Tests {@link CustomX509Configuration}.
+ *
+ * @author Rob Winch
+ */
+@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
+@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
+public class ObtainingMoreAuthorizationTests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	MockMvc mockMvc;
+
+	@Test
+	@WithMockUser
+	void profileWhenScopeConfigurationThenDenies() throws Exception {
+		this.spring.register(ScopeConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/profile"))
+			.andExpect(status().isForbidden());
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser(authorities = { "FACTOR_X509", "FACTOR_AUTHORIZATION_CODE" })
+	void profileWhenMissingAuthorityConfigurationThenRedirectsToAuthorizationServer() throws Exception {
+		this.spring.register(MissingAuthorityConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/profile"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("https://authz.example.org/authorize?scope=profile:read"));
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser(authorities = { "SCOPE_profile:read" })
+	void profileWhenMissingX509WithOttThenForbidden() throws Exception {
+		this.spring.register(MissingAuthorityConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/profile"))
+			.andExpect(status().isForbidden());
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser(authorities = { "FACTOR_X509", "FACTOR_AUTHORIZATION_CODE", "SCOPE_profile:read" })
+	void profileWhenAuthenticatedAndHasScopeThenPermits() throws Exception {
+		this.spring.register(MissingAuthorityConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/profile"))
+			.andExpect(status().isOk())
+			.andExpect(authenticated().withUsername("user"));
+		// @formatter:on
+	}
+
+	@RestController
+	static class Http200Controller {
+		@GetMapping("/**")
+		String ok() {
+			return "ok";
+		}
+	}
+}

+ 37 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.java

@@ -0,0 +1,37 @@
+package org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations;
+import org.springframework.security.web.SecurityFilterChain;
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+public class ScopeConfiguration {
+
+	// tag::httpSecurity[]
+	@Bean
+	SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+		// @formatter:off
+		http
+			.authorizeHttpRequests((authorize) -> authorize
+				.requestMatchers("/profile/**").hasAuthority("SCOPE_profile:read")
+				.anyRequest().authenticated()
+			)
+			.x509(Customizer.withDefaults())
+			.oauth2Login(Customizer.withDefaults());
+		// @formatter:on
+		return http.build();
+	}
+	// end::httpSecurity[]
+
+	@Bean
+	ClientRegistrationRepository clients() {
+		return new InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build());
+	}
+}

+ 93 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/ReauthenticationTests.java

@@ -0,0 +1,93 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.docs.servlet.authentication.reauthentication;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.docs.servlet.authentication.servletx509config.CustomX509Configuration;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
+import org.springframework.test.context.TestExecutionListeners;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+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.result.MockMvcResultMatchers.redirectedUrl;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Tests {@link CustomX509Configuration}.
+ *
+ * @author Rob Winch
+ */
+@ExtendWith({ SpringExtension.class, SpringTestContextExtension.class })
+@TestExecutionListeners(WithSecurityContextTestExecutionListener.class)
+public class ReauthenticationTests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	@Autowired
+	MockMvc mockMvc;
+
+	@Test
+	@WithMockUser
+	void formLoginWhenSimpleConfigurationThenPermits() throws Exception {
+		this.spring.register(SimpleConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/"))
+			.andExpect(status().isOk())
+			.andExpect(authenticated().withUsername("user"));
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser
+	void formLoginWhenRequireOttConfigurationThenRedirectsToOtt() throws Exception {
+		this.spring.register(RequireOttConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/profile"))
+			.andExpect(status().is3xxRedirection())
+			.andExpect(redirectedUrl("http://localhost/login?factor=ott"));
+		// @formatter:on
+	}
+
+	@Test
+	@WithMockUser(authorities = "FACTOR_OTT")
+	void ottWhenRequireOttConfigurationThenAllows() throws Exception {
+		this.spring.register(RequireOttConfiguration.class, Http200Controller.class).autowire();
+		// @formatter:off
+		this.mockMvc.perform(get("/profile"))
+			.andExpect(status().isOk())
+			.andExpect(authenticated().withUsername("user"));
+		// @formatter:on
+	}
+
+	@RestController
+	static class Http200Controller {
+		@GetMapping("/**")
+		String ok() {
+			return "ok";
+		}
+	}
+}

+ 50 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/RequireOttConfiguration.java

@@ -0,0 +1,50 @@
+package org.springframework.security.docs.servlet.authentication.reauthentication;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+public class RequireOttConfiguration {
+
+	// tag::httpSecurity[]
+	@Bean
+	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+		// @formatter:off
+		http
+			.authorizeHttpRequests((authorize) -> authorize
+				.requestMatchers("/profile/**").hasAuthority("FACTOR_OTT") // <1>
+				.anyRequest().authenticated()
+			)
+			.formLogin(Customizer.withDefaults())
+			.oneTimeTokenLogin(Customizer.withDefaults());
+		// @formatter:on
+		return http.build();
+	}
+	// end::httpSecurity[]
+
+	@Bean
+	UserDetailsService userDetailsService() {
+		return new InMemoryUserDetailsManager(
+				User.withDefaultPasswordEncoder()
+						.username("user")
+						.password("password")
+						.authorities("app")
+						.build()
+		);
+	}
+
+	@Bean
+	OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
+		return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
+	}
+}

+ 46 - 0
docs/src/test/java/org/springframework/security/docs/servlet/authentication/reauthentication/SimpleConfiguration.java

@@ -0,0 +1,46 @@
+package org.springframework.security.docs.servlet.authentication.reauthentication;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.provisioning.InMemoryUserDetailsManager;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+public class SimpleConfiguration {
+	// tag::httpSecurity[]
+	@Bean
+	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+		// @formatter:off
+		http
+			.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
+			.formLogin(Customizer.withDefaults())
+			.oneTimeTokenLogin(Customizer.withDefaults());
+		// @formatter:on
+		return http.build();
+	}
+	// end::httpSecurity[]
+
+	@Bean
+	UserDetailsService userDetailsService() {
+		return new InMemoryUserDetailsManager(
+				User.withDefaultPasswordEncoder()
+						.username("user")
+						.password("password")
+						.authorities("app")
+						.build()
+		);
+	}
+
+	@Bean
+	OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler() {
+		return new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");
+	}
+}

+ 119 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/AuthorizationManagerFactoryTests.kt

@@ -0,0 +1,119 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.kt.docs.servlet.authentication.authorizationmanagerfactory
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.security.config.test.SpringTestContext
+import org.springframework.security.config.test.SpringTestContextExtension
+import org.springframework.security.test.context.support.WithMockUser
+import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
+import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
+import org.springframework.test.context.TestExecutionListeners
+import org.springframework.test.context.junit.jupiter.SpringExtension
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RestController
+
+/**
+ * Tests [CustomX509Configuration].
+ *
+ * @author Rob Winch
+ */
+@ExtendWith(SpringExtension::class, SpringTestContextExtension::class)
+@TestExecutionListeners(WithSecurityContextTestExecutionListener::class)
+class AuthorizationManagerFactoryTests {
+    @JvmField
+    val spring: SpringTestContext = SpringTestContext(this)
+
+    @Autowired
+    var mockMvc: MockMvc? = null
+
+    @Test
+    @WithMockUser(authorities = ["FACTOR_PASSWORD", "FACTOR_OTT"])
+    @Throws(Exception::class)
+    fun getWhenAuthenticatedWithPasswordAndOttThenPermits() {
+        this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
+            .autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().isOk())
+        .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser(authorities = ["FACTOR_PASSWORD"])
+    @Throws(Exception::class)
+    fun getWhenAuthenticatedWithPasswordThenRedirectsToOtt() {
+        this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
+            .autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott"))
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser(authorities = ["FACTOR_OTT"])
+    @Throws(Exception::class)
+    fun getWhenAuthenticatedWithOttThenRedirectsToPassword() {
+        this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
+            .autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password"))
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser
+    @Throws(Exception::class)
+    fun getWhenAuthenticatedThenRedirectsToPassword() {
+        this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
+            .autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password"))
+    		// @formatter:on
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun getWhenUnauthenticatedThenRedirectsToBoth() {
+        this.spring.register(UseAuthorizationManagerFactoryConfiguration::class.java, Http200Controller::class.java)
+            .autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login"))
+    		// @formatter:on
+    }
+
+    @RestController
+    internal class Http200Controller {
+        @GetMapping("/**")
+        fun ok(): String {
+            return "ok"
+        }
+    }
+}

+ 53 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/ListAuthoritiesEverywhereConfiguration.kt

@@ -0,0 +1,53 @@
+package org.springframework.security.kt.docs.servlet.authentication.authorizationmanagerfactory
+
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.annotation.web.invoke
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.provisioning.InMemoryUserDetailsManager
+import org.springframework.security.web.SecurityFilterChain
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+class ListAuthoritiesEverywhereConfiguration {
+
+    // tag::httpSecurity[]
+    @Bean
+    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
+        // @formatter:off
+        http {
+            authorizeHttpRequests {
+                authorize("/admin/**", hasAllAuthorities("FACTOR_PASSWORD", "FACTOR_OTT", "ROLE_ADMIN")) // <1>
+                authorize(anyRequest, hasAllAuthorities("FACTOR_PASSWORD", "FACTOR_OTT"))
+            }
+            formLogin { }
+            oneTimeTokenLogin {  }
+        }
+        // @formatter:on
+        return http.build()
+    }
+    // end::httpSecurity[]
+
+
+    // end::httpSecurity[]
+    @Bean
+    fun userDetailsService(): UserDetailsService {
+        return InMemoryUserDetailsManager(
+            User.withDefaultPasswordEncoder()
+                .username("user")
+                .password("password")
+                .authorities("app")
+                .build()
+        )
+    }
+
+    @Bean
+    fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
+        return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
+    }
+}

+ 60 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/authorizationmanagerfactory/UseAuthorizationManagerFactoryConfiguration.kt

@@ -0,0 +1,60 @@
+package org.springframework.security.kt.docs.servlet.authentication.authorizationmanagerfactory
+
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.authorization.AuthorizationManagerFactory
+import org.springframework.security.authorization.DefaultAuthorizationManagerFactory
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.annotation.web.invoke
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.provisioning.InMemoryUserDetailsManager
+import org.springframework.security.web.SecurityFilterChain
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+internal class UseAuthorizationManagerFactoryConfiguration {
+    // tag::httpSecurity[]
+    @Bean
+    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
+        // @formatter:off
+        http {
+            authorizeHttpRequests {
+                authorize("/admin/**", hasRole("ADMIN"))
+                authorize(anyRequest, authenticated)
+            }
+            formLogin { }
+            oneTimeTokenLogin { }
+        }
+        // @formatter:on
+        return http.build()
+    }
+    // end::httpSecurity[]
+
+    // tag::authorizationManagerFactoryBean[]
+    @Bean
+    fun authz(): AuthorizationManagerFactory<Object> {
+        return DefaultAuthorizationManagerFactory.builder<Object>()
+            .requireAdditionalAuthorities("FACTOR_PASSWORD", "FACTOR_OTT").build()
+    }
+    // end::authorizationManagerFactoryBean[]
+
+    @Bean
+    fun userDetailsService(): UserDetailsService {
+        return InMemoryUserDetailsManager(
+            User.withDefaultPasswordEncoder()
+                .username("user")
+                .password("password")
+                .authorities("app")
+                .build()
+        )
+    }
+
+    @Bean
+    fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
+        return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
+    }
+}

+ 95 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactory.kt

@@ -0,0 +1,95 @@
+package org.springframework.security.kt.docs.servlet.authentication.customauthorizationmanagerfactory
+
+import org.jspecify.annotations.NullMarked
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.access.expression.SecurityExpressionRoot
+import org.springframework.security.authorization.*
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.annotation.web.invoke
+import org.springframework.security.core.Authentication
+import org.springframework.security.core.GrantedAuthority
+import org.springframework.security.core.authority.AuthorityUtils
+import org.springframework.security.core.userdetails.UserDetails
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.web.SecurityFilterChain
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
+import org.springframework.stereotype.Component
+import java.util.function.Supplier
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+internal class CustomAuthorizationManagerFactory {
+
+    // tag::httpSecurity[]
+    @Bean
+    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
+        // @formatter:off
+        http {
+            authorizeHttpRequests {
+                authorize("/admin/**", hasRole("ADMIN"))
+                authorize(anyRequest, authenticated)
+            }
+            formLogin { }
+            oneTimeTokenLogin { }
+        }
+        // @formatter:on
+        return http.build()
+    }
+    // end::httpSecurity[]
+
+    // tag::authorizationManager[]
+    @Component
+    internal open class OptInToMfaAuthorizationManager : AuthorizationManager<Object> {
+        override fun authorize(
+            authentication: Supplier<out Authentication?>, context: Object): AuthorizationResult {
+            val principal = authentication.get().getPrincipal() as MyPrincipal?
+            if (principal!!.optedIn) {
+                val root = object : SecurityExpressionRoot<Object>(authentication, context) { }
+                return AuthorityAuthorizationDecision(
+                    root.hasAuthority("FACTOR_OTT"),
+                    AuthorityUtils.createAuthorityList("FACTOR_OTT")
+                )
+            }
+            return AuthorizationDecision(true)
+        }
+    }
+    // end::authorizationManager[]
+
+    // tag::authorizationManagerFactory[]
+    @Bean
+    fun authorizationManagerFactory(optIn: OptInToMfaAuthorizationManager?): AuthorizationManagerFactory<Object> {
+        val defaults = DefaultAuthorizationManagerFactory<Object>()
+        defaults.setAdditionalAuthorization(optIn)
+        return defaults
+    }
+    // end::authorizationManagerFactory[]
+
+    @NullMarked
+    class MyPrincipal(val user: String, val optedIn: Boolean) : UserDetails {
+        override fun getAuthorities(): MutableCollection<out GrantedAuthority> {
+            return AuthorityUtils.createAuthorityList("app")
+        }
+
+        override fun getPassword(): String? {
+            return null
+        }
+
+        override fun getUsername(): String {
+            return this.user
+        }
+
+    }
+
+    @Bean
+    fun users(): UserDetailsService {
+        return UserDetailsService { username: String? -> MyPrincipal(username!!, username == "optedin") }
+    }
+
+    @Bean
+    fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
+        return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
+    }
+}

+ 93 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/customauthorizationmanagerfactory/CustomAuthorizationManagerFactoryTests.kt

@@ -0,0 +1,93 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.kt.docs.servlet.authentication.customauthorizationmanagerfactory
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.security.authentication.TestingAuthenticationToken
+import org.springframework.security.config.test.SpringTestContext
+import org.springframework.security.config.test.SpringTestContextExtension
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
+import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RestController
+
+/**
+ * Tests [CustomX509Configuration].
+ *
+ * @author Rob Winch
+ */
+@ExtendWith(SpringTestContextExtension::class)
+class CustomAuthorizationManagerFactoryTests {
+    @JvmField
+    val spring: SpringTestContext = SpringTestContext(this)
+
+    @Autowired
+    var mockMvc: MockMvc? = null
+
+    @Autowired
+    var users: UserDetailsService? = null
+
+    @Test
+    @Throws(Exception::class)
+    fun getWhenOptedInThenRedirectsToOtt() {
+        this.spring.register(CustomAuthorizationManagerFactory::class.java, Http200Controller::class.java).autowire()
+        val user = this.users!!.loadUserByUsername("optedin")
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.user(user)))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott"))
+    		// @formatter:on
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun getWhenNotOptedInThenAllows() {
+        this.spring.register(CustomAuthorizationManagerFactory::class.java, Http200Controller::class.java).autowire()
+        val user = this.users!!.loadUserByUsername("user")
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.user(user)))
+        .andExpect(MockMvcResultMatchers.status().isOk())
+        .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
+    		// @formatter:on
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun getWhenOptedAndHasFactorThenAllows() {
+        this.spring.register(CustomAuthorizationManagerFactory::class.java, Http200Controller::class.java).autowire()
+        val user = this.users!!.loadUserByUsername("optedin")
+        val token = TestingAuthenticationToken(user, "", "FACTOR_OTT")
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/").with(SecurityMockMvcRequestPostProcessors.authentication(token)))
+        .andExpect(MockMvcResultMatchers.status().isOk())
+        .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("optedin"))
+    		// @formatter:on
+    }
+
+    @RestController
+    internal class Http200Controller {
+        @GetMapping("/**")
+        fun ok(): String {
+            return "ok"
+        }
+    }
+}

+ 52 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/ListAuthoritiesConfiguration.kt

@@ -0,0 +1,52 @@
+package org.springframework.security.kt.docs.servlet.authentication.multifactorauthentication
+
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.annotation.web.invoke
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.provisioning.InMemoryUserDetailsManager
+import org.springframework.security.web.SecurityFilterChain
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+internal class ListAuthoritiesConfiguration {
+
+    // tag::httpSecurity[]
+    @Bean
+    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
+        // @formatter:off
+        http {
+            authorizeHttpRequests {
+                authorize(anyRequest, hasAllAuthorities("FACTOR_PASSWORD", "FACTOR_OTT"))
+            }
+            formLogin { }
+            oneTimeTokenLogin {  }
+        }
+        // @formatter:on
+        return http.build()
+    }
+    // end::httpSecurity[]
+
+
+    // end::httpSecurity[]
+    @Bean
+    fun userDetailsService(): UserDetailsService {
+        return InMemoryUserDetailsManager(
+            User.withDefaultPasswordEncoder()
+                .username("user")
+                .password("password")
+                .authorities("app")
+                .build()
+        )
+    }
+
+    @Bean
+    fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
+        return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
+    }
+}

+ 114 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/multifactorauthentication/MultiFactorAuthenticationTests.kt

@@ -0,0 +1,114 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.kt.docs.servlet.authentication.multifactorauthentication
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.security.config.test.SpringTestContext
+import org.springframework.security.config.test.SpringTestContextExtension
+import org.springframework.security.test.context.support.WithMockUser
+import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
+import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
+import org.springframework.test.context.TestExecutionListeners
+import org.springframework.test.context.junit.jupiter.SpringExtension
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RestController
+
+/**
+ * Tests [CustomX509Configuration].
+ *
+ * @author Rob Winch
+ */
+@ExtendWith(SpringExtension::class, SpringTestContextExtension::class)
+@TestExecutionListeners(WithSecurityContextTestExecutionListener::class)
+class MultiFactorAuthenticationTests {
+    @JvmField
+    val spring: SpringTestContext = SpringTestContext(this)
+
+    @Autowired
+    var mockMvc: MockMvc? = null
+
+    @Test
+    @WithMockUser(authorities = ["FACTOR_PASSWORD", "FACTOR_OTT"])
+    @Throws(Exception::class)
+    fun getWhenAuthenticatedWithPasswordAndOttThenPermits() {
+        this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().isOk())
+        .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser(authorities = ["FACTOR_PASSWORD"])
+    @Throws(Exception::class)
+    fun getWhenAuthenticatedWithPasswordThenRedirectsToOtt() {
+        this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott"))
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser(authorities = ["FACTOR_OTT"])
+    @Throws(Exception::class)
+    fun getWhenAuthenticatedWithOttThenRedirectsToPassword() {
+        this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password"))
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser
+    @Throws(Exception::class)
+    fun getWhenAuthenticatedThenRedirectsToPassword() {
+        this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=password"))
+    		// @formatter:on
+    }
+
+    @Test
+    @Throws(Exception::class)
+    fun getWhenUnauthenticatedThenRedirectsToBoth() {
+        this.spring.register(ListAuthoritiesConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login"))
+    		// @formatter:on
+    }
+
+    @RestController
+    internal class Http200Controller {
+        @GetMapping("/**")
+        fun ok(): String {
+            return "ok"
+        }
+    }
+}

+ 129 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/MissingAuthorityConfiguration.kt

@@ -0,0 +1,129 @@
+package org.springframework.security.kt.docs.servlet.authentication.obtainingmoreauthorization
+
+import jakarta.servlet.http.HttpServletRequest
+import jakarta.servlet.http.HttpServletResponse
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.authorization.AllAuthoritiesAuthorizationManager.hasAllAuthorities
+import org.springframework.security.authorization.AuthorizationDecision
+import org.springframework.security.authorization.AuthorizationManager
+import org.springframework.security.authorization.AuthorizationManagerFactory
+import org.springframework.security.authorization.AuthorizationManagers.allOf
+import org.springframework.security.authorization.DefaultAuthorizationManagerFactory
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer
+import org.springframework.security.config.annotation.web.invoke
+import org.springframework.security.core.AuthenticationException
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations
+import org.springframework.security.web.AuthenticationEntryPoint
+import org.springframework.security.web.DefaultSecurityFilterChain
+import org.springframework.security.web.access.intercept.RequestAuthorizationContext
+import org.springframework.stereotype.Component
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+internal class MissingAuthorityConfiguration {
+
+    // tag::httpSecurity[]
+    @Bean
+    fun securityFilterChain(http: HttpSecurity, oauth2: ScopeRetrievingAuthenticationEntryPoint): DefaultSecurityFilterChain? {
+        http {
+            authorizeHttpRequests {
+                authorize("/profile/**", hasAuthority("SCOPE_profile:read"))
+                authorize(anyRequest, authenticated)
+            }
+            x509 { }
+            oauth2Login { }
+        }
+
+        http.exceptionHandling { e: ExceptionHandlingConfigurer<HttpSecurity> -> e
+            .defaultDeniedHandlerForMissingAuthority(oauth2, "SCOPE_profile:read")
+        }
+        return http.build()
+    }
+    // end::httpSecurity[]
+
+    // tag::authenticationEntryPoint[]
+    @Component
+    internal class ScopeRetrievingAuthenticationEntryPoint : AuthenticationEntryPoint {
+        override fun commence(request: HttpServletRequest, response: HttpServletResponse, authException: AuthenticationException) {
+            response.sendRedirect("https://authz.example.org/authorize?scope=profile:read")
+        }
+    }
+    // end::authenticationEntryPoint[]
+
+    // tag::authorizationManagerFactoryBean[]
+    @Bean
+    fun authz(): AuthorizationManagerFactory<RequestAuthorizationContext> {
+        return FactorAuthorizationManagerFactory(hasAllAuthorities("FACTOR_X509", "FACTOR_AUTHORIZATION_CODE"))
+    }
+    // end::authorizationManagerFactoryBean[]
+
+    // tag::authorizationManagerFactory[]
+    internal inner class FactorAuthorizationManagerFactory(private val hasAuthorities: AuthorizationManager<RequestAuthorizationContext>) :
+        AuthorizationManagerFactory<RequestAuthorizationContext> {
+        private val delegate = DefaultAuthorizationManagerFactory<RequestAuthorizationContext>()
+
+        override fun permitAll(): AuthorizationManager<RequestAuthorizationContext> {
+            return this.delegate.permitAll()
+        }
+
+        override fun denyAll(): AuthorizationManager<RequestAuthorizationContext> {
+            return this.delegate.denyAll()
+        }
+
+        override fun hasRole(role: String): AuthorizationManager<RequestAuthorizationContext> {
+            return hasAnyRole(role)
+        }
+
+        override fun hasAnyRole(vararg roles: String): AuthorizationManager<RequestAuthorizationContext> {
+            return addFactors(this.delegate.hasAnyRole(*roles))
+        }
+
+        override fun hasAllRoles(vararg roles: String): AuthorizationManager<RequestAuthorizationContext> {
+            return addFactors(this.delegate.hasAllRoles(*roles))
+        }
+
+        override fun hasAuthority(authority: String): AuthorizationManager<RequestAuthorizationContext> {
+            return hasAnyAuthority(authority)
+        }
+
+        override fun hasAnyAuthority(vararg authorities: String): AuthorizationManager<RequestAuthorizationContext> {
+            return addFactors(this.delegate.hasAnyAuthority(*authorities))
+        }
+
+        override fun hasAllAuthorities(vararg authorities: String): AuthorizationManager<RequestAuthorizationContext> {
+            return addFactors(this.delegate.hasAllAuthorities(*authorities))
+        }
+
+        override fun authenticated(): AuthorizationManager<RequestAuthorizationContext> {
+            return addFactors(this.delegate.authenticated())
+        }
+
+        override fun fullyAuthenticated(): AuthorizationManager<RequestAuthorizationContext> {
+            return addFactors(this.delegate.fullyAuthenticated())
+        }
+
+        override fun rememberMe(): AuthorizationManager<RequestAuthorizationContext> {
+            return addFactors(this.delegate.rememberMe())
+        }
+
+        override fun anonymous(): AuthorizationManager<RequestAuthorizationContext> {
+            return this.delegate.anonymous()
+        }
+
+        private fun addFactors(delegate: AuthorizationManager<RequestAuthorizationContext>): AuthorizationManager<RequestAuthorizationContext> {
+            return allOf(AuthorizationDecision(false), this.hasAuthorities, delegate)
+        }
+    }
+    // end::authorizationManagerFactory[]
+
+    // end::authenticationEntryPoint[]
+    @Bean
+    fun clients(): ClientRegistrationRepository {
+        return InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build())
+    }
+}

+ 104 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ObtainingMoreAuthorizationTests.kt

@@ -0,0 +1,104 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.kt.docs.servlet.authentication.obtainingmoreauthorization
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.security.config.test.SpringTestContext
+import org.springframework.security.config.test.SpringTestContextExtension
+import org.springframework.security.docs.servlet.authentication.obtainingmoreauthorization.ScopeConfiguration
+import org.springframework.security.test.context.support.WithMockUser
+import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
+import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
+import org.springframework.test.context.TestExecutionListeners
+import org.springframework.test.context.junit.jupiter.SpringExtension
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RestController
+
+/**
+ * Tests [CustomX509Configuration].
+ *
+ * @author Rob Winch
+ */
+@ExtendWith(SpringExtension::class, SpringTestContextExtension::class)
+@TestExecutionListeners(WithSecurityContextTestExecutionListener::class)
+class ObtainingMoreAuthorizationTests {
+    @JvmField
+    val spring: SpringTestContext = SpringTestContext(this)
+
+    @Autowired
+    var mockMvc: MockMvc? = null
+
+    @Test
+    @WithMockUser
+    @Throws(Exception::class)
+    fun profileWhenScopeConfigurationThenDenies() {
+        this.spring.register(ScopeConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
+        .andExpect(MockMvcResultMatchers.status().isForbidden())
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser(authorities = ["FACTOR_X509", "FACTOR_AUTHORIZATION_CODE"])
+    @Throws(Exception::class)
+    fun profileWhenMissingAuthorityConfigurationThenRedirectsToAuthorizationServer() {
+        this.spring.register(MissingAuthorityConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("https://authz.example.org/authorize?scope=profile:read"))
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser(authorities = ["SCOPE_profile:read"])
+    @Throws(Exception::class)
+    fun profileWhenMissingX509WithOttThenForbidden() {
+        this.spring.register(MissingAuthorityConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
+        .andExpect(MockMvcResultMatchers.status().isForbidden())
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser(authorities = ["FACTOR_X509", "FACTOR_AUTHORIZATION_CODE", "SCOPE_profile:read"])
+    @Throws(
+        Exception::class
+    )
+    fun profileWhenAuthenticatedAndHasScopeThenPermits() {
+        this.spring.register(MissingAuthorityConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
+        .andExpect(MockMvcResultMatchers.status().isOk())
+        .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
+    		// @formatter:on
+    }
+
+    @RestController
+    internal class Http200Controller {
+        @GetMapping("/**")
+        fun ok(): String {
+            return "ok"
+        }
+    }
+}

+ 38 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/obtainingmoreauthorization/ScopeConfiguration.kt

@@ -0,0 +1,38 @@
+package org.springframework.security.kt.docs.servlet.authentication.obtainingmoreauthorization
+
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.annotation.web.invoke
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
+import org.springframework.security.oauth2.client.registration.TestClientRegistrations
+import org.springframework.security.web.SecurityFilterChain
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+class ScopeConfiguration {
+    // tag::httpSecurity[]
+    @Bean
+    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
+        // @formatter:off
+        http {
+            authorizeHttpRequests {
+                authorize("/profile/**", hasAuthority("SCOPE_profile:read"))
+                authorize(anyRequest, authenticated)
+            }
+            x509 { }
+            oauth2Login { }
+        }
+        // @formatter:on
+        return http.build()
+    }
+    // end::httpSecurity[]
+
+    // end::httpSecurity[]
+    @Bean
+    fun clients(): ClientRegistrationRepository {
+        return InMemoryClientRegistrationRepository(TestClientRegistrations.clientRegistration().build())
+    }
+}

+ 93 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/ReauthenticationTests.kt

@@ -0,0 +1,93 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.kt.docs.servlet.authentication.reauthentication
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.security.config.test.SpringTestContext
+import org.springframework.security.config.test.SpringTestContextExtension
+import org.springframework.security.docs.servlet.authentication.reauthentication.RequireOttConfiguration
+import org.springframework.security.docs.servlet.authentication.reauthentication.SimpleConfiguration
+import org.springframework.security.test.context.support.WithMockUser
+import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener
+import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
+import org.springframework.test.context.TestExecutionListeners
+import org.springframework.test.context.junit.jupiter.SpringExtension
+import org.springframework.test.web.servlet.MockMvc
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
+import org.springframework.test.web.servlet.result.MockMvcResultMatchers
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.RestController
+
+/**
+ * Tests [CustomX509Configuration].
+ *
+ * @author Rob Winch
+ */
+@ExtendWith(SpringExtension::class, SpringTestContextExtension::class)
+@TestExecutionListeners(WithSecurityContextTestExecutionListener::class)
+class ReauthenticationTests {
+    @JvmField
+    val spring: SpringTestContext = SpringTestContext(this)
+
+    @Autowired
+    var mockMvc: MockMvc? = null
+
+    @Test
+    @WithMockUser
+    @Throws(Exception::class)
+    fun formLoginWhenSimpleConfigurationThenPermits() {
+        this.spring.register(SimpleConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/"))
+        .andExpect(MockMvcResultMatchers.status().isOk())
+        .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser
+    @Throws(Exception::class)
+    fun formLoginWhenRequireOttConfigurationThenRedirectsToOtt() {
+        this.spring.register(RequireOttConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
+        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
+        .andExpect(MockMvcResultMatchers.redirectedUrl("http://localhost/login?factor=ott"))
+    		// @formatter:on
+    }
+
+    @Test
+    @WithMockUser(authorities = ["FACTOR_OTT"])
+    @Throws(Exception::class)
+    fun ottWhenRequireOttConfigurationThenAllows() {
+        this.spring.register(RequireOttConfiguration::class.java, Http200Controller::class.java).autowire()
+        // @formatter:off
+        this.mockMvc!!.perform(MockMvcRequestBuilders.get("/profile"))
+        .andExpect(MockMvcResultMatchers.status().isOk())
+        .andExpect(SecurityMockMvcResultMatchers.authenticated().withUsername("user"))
+    		// @formatter:on
+    }
+
+    @RestController
+    internal class Http200Controller {
+        @GetMapping("/**")
+        fun ok(): String {
+            return "ok"
+        }
+    }
+}

+ 52 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/RequireOttConfiguration.kt

@@ -0,0 +1,52 @@
+package org.springframework.security.kt.docs.servlet.authentication.reauthentication
+
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.annotation.web.invoke
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.provisioning.InMemoryUserDetailsManager
+import org.springframework.security.web.SecurityFilterChain
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+class RequireOttConfiguration {
+
+    // tag::httpSecurity[]
+    @Bean
+    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
+        // @formatter:off
+        http {
+            authorizeHttpRequests {
+                authorize("/profile/**", hasAuthority("FACTOR_OTT")) // <1>
+                authorize(anyRequest, authenticated)
+            }
+            formLogin { }
+            oneTimeTokenLogin { }
+        }
+        // @formatter:on
+        return http.build()
+    }
+    // end::httpSecurity[]
+
+    // end::httpSecurity[]
+    @Bean
+    fun userDetailsService(): UserDetailsService {
+        return InMemoryUserDetailsManager(
+            User.withDefaultPasswordEncoder()
+                .username("user")
+                .password("password")
+                .authorities("app")
+                .build()
+        )
+    }
+
+    @Bean
+    fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
+        return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
+    }
+}

+ 50 - 0
docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/reauthentication/SimpleConfiguration.kt

@@ -0,0 +1,50 @@
+package org.springframework.security.kt.docs.servlet.authentication.reauthentication
+
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.security.config.annotation.web.builders.HttpSecurity
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
+import org.springframework.security.config.annotation.web.invoke
+import org.springframework.security.core.userdetails.User
+import org.springframework.security.core.userdetails.UserDetailsService
+import org.springframework.security.provisioning.InMemoryUserDetailsManager
+import org.springframework.security.web.SecurityFilterChain
+import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
+import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
+
+@EnableWebSecurity
+@Configuration(proxyBeanMethods = false)
+class SimpleConfiguration {
+    // tag::httpSecurity[]
+    @Bean
+    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain? {
+        // @formatter:off
+        http {
+            authorizeHttpRequests {
+                authorize(anyRequest, authenticated)
+            }
+            formLogin { }
+            oneTimeTokenLogin { }
+        }
+        // @formatter:on
+        return http.build()
+    }
+    // end::httpSecurity[]
+
+    // end::httpSecurity[]
+    @Bean
+    fun userDetailsService(): UserDetailsService {
+        return InMemoryUserDetailsManager(
+            User.withDefaultPasswordEncoder()
+                .username("user")
+                .password("password")
+                .authorities("app")
+                .build()
+        )
+    }
+
+    @Bean
+    fun tokenGenerationSuccessHandler(): OneTimeTokenGenerationSuccessHandler {
+        return RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent")
+    }
+}

+ 14 - 0
web/src/main/java/org/springframework/security/web/WebAttributes.java

@@ -16,6 +16,9 @@
 
 package org.springframework.security.web;
 
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.web.access.WebInvocationPrivilegeEvaluator;
 
 /**
@@ -52,6 +55,17 @@ public final class WebAttributes {
 	public static final String WEB_INVOCATION_PRIVILEGE_EVALUATOR_ATTRIBUTE = WebAttributes.class.getName()
 			+ ".WEB_INVOCATION_PRIVILEGE_EVALUATOR_ATTRIBUTE";
 
+	/**
+	 * Used to set a {@code Collection} of {@link GrantedAuthority} 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";
+
 	private WebAttributes() {
 	}
 

+ 209 - 0
web/src/main/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandler.java

@@ -0,0 +1,209 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+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 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.core.AuthenticationException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.WebAttributes;
+import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
+import org.springframework.security.web.savedrequest.NullRequestCache;
+import org.springframework.security.web.savedrequest.RequestCache;
+import org.springframework.security.web.util.ThrowableAnalyzer;
+import org.springframework.security.web.util.matcher.AnyRequestMatcher;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link AccessDeniedHandler} that adapts {@link AuthenticationEntryPoint}s based on
+ * missing {@link GrantedAuthority}s. These authorities are specified in an
+ * {@link AuthorityAuthorizationDecision} inside an {@link AuthorizationDeniedException}.
+ *
+ * <p>
+ * This is helpful in adaptive authentication scenarios where an
+ * {@link org.springframework.security.authorization.AuthorizationManager} indicates
+ * additional authorities needed to access a given resource.
+ * </p>
+ *
+ * <p>
+ * For example, if an
+ * {@link org.springframework.security.authorization.AuthorizationManager} states that to
+ * access the home page, the user needs the {@code FACTOR_OTT} authority, then this
+ * handler can be configured in the following way to redirect to the one-time-token login
+ * page:
+ * </p>
+ *
+ * <code>
+ *     AccessDeniedHandler handler = DelegatingMissingAuthorityAccessDeniedHandler.builder()
+ *         .addEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), "FACTOR_OTT")
+ *         .addEntryPointFor(new MyCustomEntryPoint(), "FACTOR_PASSWORD")
+ *         .build();
+ * </code>
+ *
+ * @author Josh Cummings
+ * @since 7.0
+ * @see AuthorizationDeniedException
+ * @see AuthorityAuthorizationDecision
+ * @see org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer
+ */
+public final class DelegatingMissingAuthorityAccessDeniedHandler implements AccessDeniedHandler {
+
+	private final ThrowableAnalyzer throwableAnalyzer = new ThrowableAnalyzer();
+
+	private final Map<String, AuthenticationEntryPoint> entryPoints;
+
+	private RequestCache requestCache = new NullRequestCache();
+
+	private AccessDeniedHandler defaultAccessDeniedHandler = new AccessDeniedHandlerImpl();
+
+	private DelegatingMissingAuthorityAccessDeniedHandler(Map<String, AuthenticationEntryPoint> entryPoints) {
+		Assert.notEmpty(entryPoints, "entryPoints cannot be empty");
+		this.entryPoints = entryPoints;
+	}
+
+	@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());
+			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));
+			AuthenticationException ex = new InsufficientAuthenticationException(message, denied);
+			entryPoint.commence(request, response, ex);
+			return;
+		}
+		this.defaultAccessDeniedHandler.handle(request, response, denied);
+	}
+
+	/**
+	 * Use this {@link AccessDeniedHandler} for {@link AccessDeniedException}s that this
+	 * handler doesn't support. By default, this uses {@link AccessDeniedHandlerImpl}.
+	 * @param defaultAccessDeniedHandler the default {@link AccessDeniedHandler} to use
+	 */
+	public void setDefaultAccessDeniedHandler(AccessDeniedHandler defaultAccessDeniedHandler) {
+		Assert.notNull(defaultAccessDeniedHandler, "defaultAccessDeniedHandler cannot be null");
+		this.defaultAccessDeniedHandler = defaultAccessDeniedHandler;
+	}
+
+	/**
+	 * Use this {@link RequestCache} to remember the current request.
+	 * <p>
+	 * Uses {@link NullRequestCache} by default
+	 * </p>
+	 * @param requestCache the {@link RequestCache} to use
+	 */
+	public void setRequestCache(RequestCache requestCache) {
+		Assert.notNull(requestCache, "requestCachgrantedaue cannot be null");
+		this.requestCache = requestCache;
+	}
+
+	private Collection<GrantedAuthority> missingAuthorities(AccessDeniedException ex) {
+		AuthorizationDeniedException denied = findAuthorizationDeniedException(ex);
+		if (denied == null) {
+			return List.of();
+		}
+		if (!(denied.getAuthorizationResult() instanceof AuthorityAuthorizationDecision authorization)) {
+			return List.of();
+		}
+		return authorization.getAuthorities();
+	}
+
+	private @Nullable AuthorizationDeniedException findAuthorizationDeniedException(AccessDeniedException ex) {
+		if (ex instanceof AuthorizationDeniedException denied) {
+			return denied;
+		}
+		Throwable[] chain = this.throwableAnalyzer.determineCauseChain(ex);
+		return (AuthorizationDeniedException) this.throwableAnalyzer
+			.getFirstThrowableOfType(AuthorizationDeniedException.class, chain);
+	}
+
+	public static Builder builder() {
+		return new Builder();
+	}
+
+	/**
+	 * A builder for configuring the set of authority/entry-point pairs
+	 *
+	 * @author Josh Cummings
+	 * @since 7.0
+	 */
+	public static final class Builder {
+
+		private final Map<String, DelegatingAuthenticationEntryPoint.Builder> entryPointBuilderByAuthority = new LinkedHashMap<>();
+
+		private Builder() {
+
+		}
+
+		/**
+		 * Use this {@link AuthenticationEntryPoint} when the given
+		 * {@code missingAuthority} is missing from the authenticated user
+		 * @param entryPoint the {@link AuthenticationEntryPoint} for the given authority
+		 * @param missingAuthority the authority
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder addEntryPointFor(AuthenticationEntryPoint entryPoint, String missingAuthority) {
+			DelegatingAuthenticationEntryPoint.Builder builder = DelegatingAuthenticationEntryPoint.builder()
+				.addEntryPointFor(entryPoint, AnyRequestMatcher.INSTANCE);
+			this.entryPointBuilderByAuthority.put(missingAuthority, builder);
+			return this;
+		}
+
+		/**
+		 * Use this {@link AuthenticationEntryPoint} when the given
+		 * {@code missingAuthority} is missing from the authenticated user
+		 * @param entryPoint a consumer to configure the underlying
+		 * {@link DelegatingAuthenticationEntryPoint}
+		 * @param missingAuthority the authority
+		 * @return the {@link Builder} for further configurations
+		 */
+		public Builder addEntryPointFor(Consumer<DelegatingAuthenticationEntryPoint.Builder> entryPoint,
+				String missingAuthority) {
+			entryPoint.accept(this.entryPointBuilderByAuthority.computeIfAbsent(missingAuthority,
+					(k) -> DelegatingAuthenticationEntryPoint.builder()));
+			return this;
+		}
+
+		public DelegatingMissingAuthorityAccessDeniedHandler build() {
+			Map<String, AuthenticationEntryPoint> entryPointByAuthority = new LinkedHashMap<>();
+			this.entryPointBuilderByAuthority.forEach((key, value) -> entryPointByAuthority.put(key, value.build()));
+			return new DelegatingMissingAuthorityAccessDeniedHandler(entryPointByAuthority);
+		}
+
+	}
+
+}

+ 2 - 1
web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java

@@ -196,7 +196,8 @@ public class ExceptionTranslationFilter extends GenericFilterBean implements Mes
 			}
 			AuthenticationException ex = new InsufficientAuthenticationException(
 					this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
-							"Full authentication is required to access this resource"));
+							"Full authentication is required to access this resource"),
+					exception);
 			ex.setAuthenticationRequest(authentication);
 			sendStartAuthentication(request, response, chain, ex);
 		}

+ 29 - 1
web/src/main/java/org/springframework/security/web/authentication/LoginUrlAuthenticationEntryPoint.java

@@ -17,6 +17,8 @@
 package org.springframework.security.web.authentication;
 
 import java.io.IOException;
+import java.util.Collection;
+import java.util.Locale;
 
 import jakarta.servlet.RequestDispatcher;
 import jakarta.servlet.ServletException;
@@ -30,16 +32,20 @@ import org.jspecify.annotations.Nullable;
 import org.springframework.beans.factory.InitializingBean;
 import org.springframework.core.log.LogMessage;
 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;
 import org.springframework.security.web.PortMapperImpl;
 import org.springframework.security.web.RedirectStrategy;
+import org.springframework.security.web.WebAttributes;
 import org.springframework.security.web.access.ExceptionTranslationFilter;
 import org.springframework.security.web.util.RedirectUrlBuilder;
 import org.springframework.security.web.util.UrlUtils;
 import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponentsBuilder;
 
 /**
  * Used by the {@link ExceptionTranslationFilter} to commence a form login authentication
@@ -68,6 +74,8 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin
 
 	private static final Log logger = LogFactory.getLog(LoginUrlAuthenticationEntryPoint.class);
 
+	private static final String FACTOR_PREFIX = "FACTOR_";
+
 	private PortMapper portMapper = new PortMapperImpl();
 
 	private String loginFormUrl;
@@ -107,9 +115,29 @@ public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoin
 	 * @param exception the exception
 	 * @return the URL (cannot be null or empty; defaults to {@link #getLoginFormUrl()})
 	 */
+	@SuppressWarnings("unchecked")
 	protected String determineUrlToUseForThisRequest(HttpServletRequest request, HttpServletResponse response,
 			AuthenticationException exception) {
-		return getLoginFormUrl();
+		Collection<GrantedAuthority> authorities = getAttribute(request, WebAttributes.MISSING_AUTHORITIES,
+				Collection.class);
+		if (CollectionUtils.isEmpty(authorities)) {
+			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))
+			.toList();
+		return UriComponentsBuilder.fromUriString(getLoginFormUrl()).queryParam("factor", factors).toUriString();
+	}
+
+	private static <T> @Nullable T getAttribute(HttpServletRequest request, String name, Class<T> clazz) {
+		Object value = request.getAttribute(name);
+		if (value == null) {
+			return null;
+		}
+		String message = String.format("Found %s in %s, but expecting a %s", value.getClass(), name, clazz);
+		Assert.isInstanceOf(clazz, value, message);
+		return (T) value;
 	}
 
 	/**

+ 113 - 14
web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java

@@ -18,9 +18,12 @@ 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.function.Function;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 
 import jakarta.servlet.FilterChain;
@@ -31,10 +34,14 @@ import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import org.jspecify.annotations.Nullable;
 
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices;
 import org.springframework.util.Assert;
 import org.springframework.web.filter.GenericFilterBean;
+import org.springframework.web.util.UriComponentsBuilder;
 
 /**
  * For internal use with namespace configuration in the case where a user doesn't
@@ -52,6 +59,9 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 
 	public static final String ERROR_PARAMETER_NAME = "error";
 
+	private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
+		.getContextHolderStrategy();
+
 	private @Nullable String loginPageUrl;
 
 	private @Nullable String logoutSuccessUrl;
@@ -78,6 +88,10 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 
 	private @Nullable String rememberMeParameter;
 
+	private final String factorParameter = "factor";
+
+	private final Collection<String> allowedParameters = List.of(this.factorParameter);
+
 	@SuppressWarnings("NullAway.Init")
 	private Map<String, String> oauth2AuthenticationUrlToClientName;
 
@@ -109,6 +123,18 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 		}
 	}
 
+	/**
+	 * Use this {@link SecurityContextHolderStrategy} to retrieve authenticated users.
+	 * <p>
+	 * Uses {@link SecurityContextHolder#getContextHolderStrategy()} by default.
+	 * @param securityContextHolderStrategy the strategy to use
+	 * @since 7.0
+	 */
+	public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {
+		Assert.notNull(securityContextHolderStrategy, "securityContextHolderStrategy cannot be null");
+		this.securityContextHolderStrategy = securityContextHolderStrategy;
+	}
+
 	/**
 	 * Sets a Function used to resolve a Map of the hidden inputs where the key is the
 	 * name of the input and the value is the value of the input. Typically this is used
@@ -223,16 +249,43 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 		String errorMsg = "Invalid credentials";
 		String contextPath = request.getContextPath();
 
-		return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
+		HtmlTemplates.Builder builder = HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
 			.withRawHtml("contextPath", contextPath)
-			.withRawHtml("javaScript", renderJavaScript(request, contextPath))
-			.withRawHtml("formLogin", renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg))
-			.withRawHtml("oneTimeTokenLogin",
-					renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg))
-			.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath))
-			.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath))
-			.withRawHtml("passkeyLogin", renderPasskeyLogin())
-			.render();
+			.withRawHtml("javaScript", "")
+			.withRawHtml("formLogin", "")
+			.withRawHtml("oneTimeTokenLogin", "")
+			.withRawHtml("oauth2Login", "")
+			.withRawHtml("saml2Login", "")
+			.withRawHtml("passkeyLogin", "");
+
+		Predicate<String> wantsAuthority = wantsAuthority(request);
+		if (wantsAuthority.test("webauthn")) {
+			builder.withRawHtml("javaScript", renderJavaScript(request, contextPath))
+				.withRawHtml("passkeyLogin", renderPasskeyLogin());
+		}
+		if (wantsAuthority.test("password")) {
+			builder.withRawHtml("formLogin",
+					renderFormLogin(request, loginError, logoutSuccess, contextPath, errorMsg));
+		}
+		if (wantsAuthority.test("ott")) {
+			builder.withRawHtml("oneTimeTokenLogin",
+					renderOneTimeTokenLogin(request, loginError, logoutSuccess, contextPath, errorMsg));
+		}
+		if (wantsAuthority.test("authorization_code")) {
+			builder.withRawHtml("oauth2Login", renderOAuth2Login(loginError, logoutSuccess, errorMsg, contextPath));
+		}
+		if (wantsAuthority.test("saml_response")) {
+			builder.withRawHtml("saml2Login", renderSaml2Login(loginError, logoutSuccess, errorMsg, contextPath));
+		}
+		return builder.render();
+	}
+
+	private Predicate<String> wantsAuthority(HttpServletRequest request) {
+		String[] authorities = request.getParameterValues(this.factorParameter);
+		if (authorities == null) {
+			return (authority) -> true;
+		}
+		return List.of(authorities)::contains;
 	}
 
 	private String renderJavaScript(HttpServletRequest request, String contextPath) {
@@ -271,6 +324,13 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			return "";
 		}
 
+		String username = getUsername();
+		String usernameInput = ((username != null)
+				? HtmlTemplates.fromTemplate(FORM_READONLY_USERNAME_INPUT).withValue("username", username)
+				: HtmlTemplates.fromTemplate(FORM_USERNAME_INPUT))
+			.withValue("usernameParameter", this.usernameParameter)
+			.render();
+
 		String hiddenInputs = this.resolveHiddenInputs.apply(request)
 			.entrySet()
 			.stream()
@@ -281,7 +341,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			.withValue("loginUrl", contextPath + this.authenticationUrl)
 			.withRawHtml("errorMessage", renderError(loginError, errorMsg))
 			.withRawHtml("logoutMessage", renderSuccess(logoutSuccess))
-			.withValue("usernameParameter", this.usernameParameter)
+			.withRawHtml("usernameInput", usernameInput)
 			.withValue("passwordParameter", this.passwordParameter)
 			.withRawHtml("rememberMeInput", renderRememberMe(this.rememberMeParameter))
 			.withRawHtml("hiddenInputs", hiddenInputs)
@@ -301,11 +361,17 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			.map((inputKeyValue) -> renderHiddenInput(inputKeyValue.getKey(), inputKeyValue.getValue()))
 			.collect(Collectors.joining("\n"));
 
+		String username = getUsername();
+		String usernameInput = (username != null)
+				? HtmlTemplates.fromTemplate(ONE_TIME_READONLY_USERNAME_INPUT).withValue("username", username).render()
+				: ONE_TIME_USERNAME_INPUT;
+
 		return HtmlTemplates.fromTemplate(ONE_TIME_TEMPLATE)
 			.withValue("generateOneTimeTokenUrl", contextPath + this.generateOneTimeTokenUrl)
 			.withRawHtml("errorMessage", renderError(loginError, errorMsg))
 			.withRawHtml("logoutMessage", renderSuccess(logoutSuccess))
 			.withRawHtml("hiddenInputs", hiddenInputs)
+			.withRawHtml("usernameInput", usernameInput)
 			.render();
 	}
 
@@ -374,6 +440,14 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			.render();
 	}
 
+	private @Nullable String getUsername() {
+		Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
+		if (authentication != null && authentication.isAuthenticated()) {
+			return authentication.getName();
+		}
+		return null;
+	}
+
 	private boolean isLogoutSuccess(HttpServletRequest request) {
 		return this.logoutSuccessUrl != null && matches(request, this.logoutSuccessUrl);
 	}
@@ -413,10 +487,19 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 		if (request.getQueryString() != null) {
 			uri += "?" + request.getQueryString();
 		}
+		UriComponentsBuilder addAllowed = UriComponentsBuilder.fromUriString(url);
+		for (String parameter : this.allowedParameters) {
+			String[] values = request.getParameterValues(parameter);
+			if (values != null) {
+				for (String value : values) {
+					addAllowed.queryParam(parameter, value);
+				}
+			}
+		}
 		if ("".equals(request.getContextPath())) {
-			return uri.equals(url);
+			return uri.equals(addAllowed.toUriString());
 		}
-		return uri.equals(request.getContextPath() + url);
+		return uri.equals(request.getContextPath() + addAllowed.toUriString());
 	}
 
 	private static final String CSRF_HEADERS = """
@@ -466,7 +549,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			{{errorMessage}}{{logoutMessage}}
 			        <p>
 			          <label for="username" class="screenreader">Username</label>
-			          <input type="text" id="username" name="{{usernameParameter}}" placeholder="Username" required autofocus>
+			          {{usernameInput}}
 			        </p>
 			        <p>
 			          <label for="password" class="screenreader">Password</label>
@@ -477,6 +560,14 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			        <button type="submit" class="primary">Sign in</button>
 			      </form>""";
 
+	private static final String FORM_READONLY_USERNAME_INPUT = """
+			<input type="text" id="username" name="{{usernameParameter}}" value="{{username}}" placeholder="Username" required readonly>
+			""";
+
+	private static final String FORM_USERNAME_INPUT = """
+			<input type="text" id="username" name="{{usernameParameter}}" placeholder="Username" required autofocus>
+			""";
+
 	private static final String HIDDEN_HTML_INPUT_TEMPLATE = """
 			<input name="{{name}}" type="hidden" value="{{value}}" />
 			""";
@@ -509,11 +600,19 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 			{{errorMessage}}{{logoutMessage}}
 			        <p>
 			          <label for="ott-username" class="screenreader">Username</label>
-			          <input type="text" id="ott-username" name="username" placeholder="Username" required>
+			          {{usernameInput}}
 			        </p>
 			{{hiddenInputs}}
 			        <button class="primary" type="submit" form="ott-form">Send Token</button>
 			      </form>
 			""";
 
+	private static final String ONE_TIME_READONLY_USERNAME_INPUT = """
+			<input type="text" id="ott-username" name="username" value="{{username}}" placeholder="Username" required readonly>
+			""";
+
+	private static final String ONE_TIME_USERNAME_INPUT = """
+			<input type="text" id="ott-username" name="username" placeholder="Username" required>
+			""";
+
 }

+ 145 - 0
web/src/test/java/org/springframework/security/web/access/DelegatingMissingAuthorityAccessDeniedHandlerTests.java

@@ -0,0 +1,145 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.access;
+
+import java.util.Collection;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.authorization.AuthorityAuthorizationDecision;
+import org.springframework.security.authorization.AuthorizationDeniedException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.security.web.savedrequest.RequestCache;
+import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+@ExtendWith(MockitoExtension.class)
+class DelegatingMissingAuthorityAccessDeniedHandlerTests {
+
+	DelegatingMissingAuthorityAccessDeniedHandler.Builder builder;
+
+	MockHttpServletRequest request;
+
+	MockHttpServletResponse response;
+
+	@Mock
+	AuthenticationEntryPoint factorEntryPoint;
+
+	@Mock
+	AccessDeniedHandler defaultAccessDeniedHandler;
+
+	@BeforeEach
+	void setUp() {
+		this.builder = DelegatingMissingAuthorityAccessDeniedHandler.builder();
+		this.builder.addEntryPointFor(this.factorEntryPoint, "FACTOR");
+		this.request = new MockHttpServletRequest();
+		this.response = new MockHttpServletResponse();
+	}
+
+	@Test
+	void whenKnownAuthorityThenCommences() throws Exception {
+		AccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR"));
+		verify(this.factorEntryPoint).commence(any(), any(), any());
+	}
+
+	@Test
+	void whenUnknownAuthorityThenDefaultCommences() throws Exception {
+		DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.setDefaultAccessDeniedHandler(this.defaultAccessDeniedHandler);
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("ROLE_USER"));
+		verify(this.defaultAccessDeniedHandler).handle(any(), any(), any());
+		verifyNoInteractions(this.factorEntryPoint);
+	}
+
+	@Test
+	void whenNoAuthoritiesFoundThenDefaultCommences() throws Exception {
+		DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.setDefaultAccessDeniedHandler(this.defaultAccessDeniedHandler);
+		accessDeniedHandler.handle(this.request, this.response, new AccessDeniedException("access denied"));
+		verify(this.defaultAccessDeniedHandler).handle(any(), any(), any());
+	}
+
+	@Test
+	void whenMultipleAuthoritiesThenFirstMatchCommences() throws Exception {
+		AuthenticationEntryPoint passwordEntryPoint = mock(AuthenticationEntryPoint.class);
+		this.builder.addEntryPointFor(passwordEntryPoint, "PASSWORD");
+		AccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD", "FACTOR"));
+		verify(passwordEntryPoint).commence(any(), any(), any());
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR", "PASSWORD"));
+		verify(this.factorEntryPoint).commence(any(), any(), any());
+	}
+
+	@Test
+	void whenCustomRequestCacheThenUses() throws Exception {
+		RequestCache requestCache = mock(RequestCache.class);
+		DelegatingMissingAuthorityAccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.setRequestCache(requestCache);
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("FACTOR"));
+		verify(requestCache).saveRequest(any(), any());
+		verify(this.factorEntryPoint).commence(any(), any(), any());
+	}
+
+	@Test
+	void whenKnownAuthorityButNoRequestMatchThenCommences() throws Exception {
+		AuthenticationEntryPoint passwordEntryPoint = mock(AuthenticationEntryPoint.class);
+		RequestMatcher xhr = new RequestHeaderRequestMatcher("X-Requested-With");
+		this.builder.addEntryPointFor((ep) -> ep.addEntryPointFor(passwordEntryPoint, xhr), "PASSWORD");
+		AccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD"));
+		verify(passwordEntryPoint).commence(any(), any(), any());
+	}
+
+	@Test
+	void whenMultipleEntryPointsThenFirstRequestMatchCommences() throws Exception {
+		AuthenticationEntryPoint basicPasswordEntryPoint = mock(AuthenticationEntryPoint.class);
+		AuthenticationEntryPoint formPasswordEntryPoint = mock(AuthenticationEntryPoint.class);
+		RequestMatcher xhr = new RequestHeaderRequestMatcher("X-Requested-With");
+		this.builder.addEntryPointFor(
+				(ep) -> ep.addEntryPointFor(basicPasswordEntryPoint, xhr).defaultEntryPoint(formPasswordEntryPoint),
+				"PASSWORD");
+		AccessDeniedHandler accessDeniedHandler = this.builder.build();
+		accessDeniedHandler.handle(this.request, this.response, missingAuthorities("PASSWORD"));
+		verify(formPasswordEntryPoint).commence(any(), any(), any());
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.addHeader("X-Requested-With", "XmlHttpRequest");
+		accessDeniedHandler.handle(request, this.response, missingAuthorities("PASSWORD"));
+		verify(basicPasswordEntryPoint).commence(any(), any(), any());
+	}
+
+	AuthorizationDeniedException missingAuthorities(String... authorities) {
+		Collection<GrantedAuthority> granted = AuthorityUtils.createAuthorityList(authorities);
+		AuthorityAuthorizationDecision decision = new AuthorityAuthorizationDecision(false, granted);
+		return new AuthorizationDeniedException("access denied", decision);
+	}
+
+}

+ 82 - 0
web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java

@@ -26,10 +26,15 @@ import org.junit.jupiter.api.Test;
 import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.mock.web.MockHttpServletResponse;
 import org.springframework.security.authentication.BadCredentialsException;
+import org.springframework.security.authentication.TestAuthentication;
+import org.springframework.security.core.context.SecurityContextHolderStrategy;
+import org.springframework.security.core.context.SecurityContextImpl;
 import org.springframework.security.web.WebAttributes;
 import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
+import org.springframework.security.web.servlet.TestMockHttpServletRequests;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.mock;
 
 /**
@@ -191,6 +196,83 @@ public class DefaultLoginPageGeneratingFilterTests {
 				""");
 	}
 
+	@Test
+	public void generateWhenOneTimeTokenRequestedThenOttForm() throws Exception {
+		DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter();
+		filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL);
+		filter.setFormLoginEnabled(true);
+		filter.setOneTimeTokenEnabled(true);
+		filter.setOneTimeTokenGenerationUrl("/ott/authenticate");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		filter.doFilter(TestMockHttpServletRequests.get("/login?factor=ott").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">
+				        <h2>Request a One-Time Token</h2>
+
+				        <p>
+				          <label for="ott-username" class="screenreader">Username</label>
+				          <input type="text" id="ott-username" name="username" placeholder="Username" required>
+				        </p>
+
+				        <button class="primary" type="submit" form="ott-form">Send Token</button>
+				      </form>
+				""");
+		assertThat(response.getContentAsString()).doesNotContain("Password");
+	}
+
+	@Test
+	public void generateWhenTwoAuthoritiesRequestedThenBothForms() throws Exception {
+		DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter();
+		filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL);
+		filter.setFormLoginEnabled(true);
+		filter.setUsernameParameter("username");
+		filter.setPasswordParameter("password");
+		filter.setOneTimeTokenEnabled(true);
+		filter.setOneTimeTokenGenerationUrl("/ott/authenticate");
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		filter.doFilter(TestMockHttpServletRequests.get("/login?factor=ott&factor=password").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">
+				        <h2>Request a One-Time Token</h2>
+
+				        <p>
+				          <label for="ott-username" class="screenreader">Username</label>
+				          <input type="text" id="ott-username" name="username" placeholder="Username" required>
+				        </p>
+
+				        <button class="primary" type="submit" form="ott-form">Send Token</button>
+				      </form>
+				""");
+		assertThat(response.getContentAsString()).contains("Password");
+	}
+
+	@Test
+	public void generateWhenAuthenticatedThenReadOnlyUsername() throws Exception {
+		SecurityContextHolderStrategy strategy = mock(SecurityContextHolderStrategy.class);
+		DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter();
+		filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL);
+		filter.setFormLoginEnabled(true);
+		filter.setUsernameParameter("username");
+		filter.setPasswordParameter("password");
+		filter.setOneTimeTokenEnabled(true);
+		filter.setOneTimeTokenGenerationUrl("/ott/authenticate");
+		filter.setSecurityContextHolderStrategy(strategy);
+		given(strategy.getContext()).willReturn(new SecurityContextImpl(TestAuthentication.authenticatedUser()));
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		filter.doFilter(TestMockHttpServletRequests.get("/login").build(), response, this.chain);
+		assertThat(response.getContentAsString()).contains("Request a One-Time Token");
+		assertThat(response.getContentAsString()).contains(
+				"""
+						<input type="text" id="ott-username" name="username" value="user" placeholder="Username" required readonly>
+						""");
+		assertThat(response.getContentAsString()).contains("""
+				<input type="text" id="username" name="username" value="user" placeholder="Username" required readonly>
+				""");
+	}
+
 	@Test
 	void generatesThenRenders() throws ServletException, IOException {
 		DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(