Переглянути джерело

One Time Token login registers the default login page

closes gh-16414

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Daniel Garnier-Moiroux 7 місяців тому
батько
коміт
238f47ce5e

+ 1 - 1
config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2024 the original author or authors.
+ * Copyright 2002-2025 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.

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

@@ -23,7 +23,6 @@ import jakarta.servlet.http.HttpServletRequest;
 
 import org.springframework.context.ApplicationContext;
 import org.springframework.http.HttpMethod;
-import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.authentication.AuthenticationProvider;
 import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
 import org.springframework.security.authentication.ott.InMemoryOneTimeTokenService;
@@ -32,6 +31,9 @@ import org.springframework.security.authentication.ott.OneTimeTokenAuthenticatio
 import org.springframework.security.authentication.ott.OneTimeTokenService;
 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.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.core.Authentication;
 import org.springframework.security.core.userdetails.UserDetailsService;
@@ -49,34 +51,70 @@ import org.springframework.security.web.authentication.ott.OneTimeTokenGeneratio
 import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
 import org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter;
 import org.springframework.security.web.authentication.ui.DefaultResourcesFilter;
-import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
-import org.springframework.security.web.context.SecurityContextRepository;
 import org.springframework.security.web.csrf.CsrfToken;
+import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 
 import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher;
 
-public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
-		extends AbstractHttpConfigurer<OneTimeTokenLoginConfigurer<H>, H> {
+/**
+ * An {@link AbstractHttpConfigurer} for One-Time Token Login.
+ *
+ * <p>
+ * One-Time Token Login provides an application with the capability to have users log in
+ * by obtaining a single-use token out of band, for example through email.
+ *
+ * <p>
+ * Defaults are provided for all configuration options, with the only required
+ * configuration being
+ * {@link #tokenGenerationSuccessHandler(OneTimeTokenGenerationSuccessHandler)}.
+ * Alternatively, a {@link OneTimeTokenGenerationSuccessHandler} {@code @Bean} may be
+ * registered instead.
+ *
+ * <h2>Security Filters</h2>
+ *
+ * The following {@code Filter}s are populated:
+ *
+ * <ul>
+ * <li>{@link DefaultOneTimeTokenSubmitPageGeneratingFilter}</li>
+ * <li>{@link GenerateOneTimeTokenFilter}</li>
+ * <li>{@link OneTimeTokenAuthenticationFilter}</li>
+ * </ul>
+ *
+ * <h2>Shared Objects Used</h2>
+ *
+ * The following shared objects are used:
+ *
+ * <ul>
+ * <li>{@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not
+ * configured and {@code DefaultLoginPageGeneratingFilter} is available, then a default
+ * login page will be made available</li>
+ * </ul>
+ *
+ * @author Marcus Da Coregio
+ * @author Daniel Garnier-Moiroux
+ * @since 6.4
+ * @see HttpSecurity#oneTimeTokenLogin(Customizer)
+ * @see DefaultOneTimeTokenSubmitPageGeneratingFilter
+ * @see GenerateOneTimeTokenFilter
+ * @see OneTimeTokenAuthenticationFilter
+ * @see AbstractAuthenticationFilterConfigurer
+ */
+public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>> extends
+		AbstractAuthenticationFilterConfigurer<H, OneTimeTokenLoginConfigurer<H>, OneTimeTokenAuthenticationFilter> {
 
 	private final ApplicationContext context;
 
 	private OneTimeTokenService oneTimeTokenService;
 
-	private AuthenticationConverter authenticationConverter = new OneTimeTokenAuthenticationConverter();
-
-	private AuthenticationFailureHandler authenticationFailureHandler;
-
-	private AuthenticationSuccessHandler authenticationSuccessHandler = new SavedRequestAwareAuthenticationSuccessHandler();
-
-	private String defaultSubmitPageUrl = "/login/ott";
+	private String defaultSubmitPageUrl = DefaultOneTimeTokenSubmitPageGeneratingFilter.DEFAULT_SUBMIT_PAGE_URL;
 
 	private boolean submitPageEnabled = true;
 
 	private String loginProcessingUrl = OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL;
 
-	private String tokenGeneratingUrl = "/ott/generate";
+	private String tokenGeneratingUrl = GenerateOneTimeTokenFilter.DEFAULT_GENERATE_URL;
 
 	private OneTimeTokenGenerationSuccessHandler oneTimeTokenGenerationSuccessHandler;
 
@@ -85,58 +123,41 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 	private GenerateOneTimeTokenRequestResolver requestResolver;
 
 	public OneTimeTokenLoginConfigurer(ApplicationContext context) {
+		super(new OneTimeTokenAuthenticationFilter(), OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL);
 		this.context = context;
 	}
 
 	@Override
-	public void init(H http) {
+	public void init(H http) throws Exception {
+		super.init(http);
 		AuthenticationProvider authenticationProvider = getAuthenticationProvider();
 		http.authenticationProvider(postProcess(authenticationProvider));
-		configureDefaultLoginPage(http);
+		intiDefaultLoginFilter(http);
 	}
 
-	private void configureDefaultLoginPage(H http) {
+	private void intiDefaultLoginFilter(H http) {
 		DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
 			.getSharedObject(DefaultLoginPageGeneratingFilter.class);
-		if (loginPageGeneratingFilter == null) {
+		if (loginPageGeneratingFilter == null || isCustomLoginPage()) {
 			return;
 		}
 		loginPageGeneratingFilter.setOneTimeTokenEnabled(true);
 		loginPageGeneratingFilter.setOneTimeTokenGenerationUrl(this.tokenGeneratingUrl);
-		if (this.authenticationFailureHandler == null
-				&& StringUtils.hasText(loginPageGeneratingFilter.getLoginPageUrl())) {
-			this.authenticationFailureHandler = new SimpleUrlAuthenticationFailureHandler(
-					loginPageGeneratingFilter.getLoginPageUrl() + "?error");
+
+		if (!StringUtils.hasText(loginPageGeneratingFilter.getLoginPageUrl())) {
+			loginPageGeneratingFilter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL);
+			loginPageGeneratingFilter.setFailureUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL + "?"
+					+ DefaultLoginPageGeneratingFilter.ERROR_PARAMETER_NAME);
+			loginPageGeneratingFilter
+				.setLogoutSuccessUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL + "?logout");
 		}
 	}
 
 	@Override
-	public void configure(H http) {
+	public void configure(H http) throws Exception {
+		super.configure(http);
 		configureSubmitPage(http);
 		configureOttGenerateFilter(http);
-		configureOttAuthenticationFilter(http);
-	}
-
-	private void configureOttAuthenticationFilter(H http) {
-		AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
-		OneTimeTokenAuthenticationFilter oneTimeTokenAuthenticationFilter = new OneTimeTokenAuthenticationFilter();
-		oneTimeTokenAuthenticationFilter.setAuthenticationManager(authenticationManager);
-		if (this.loginProcessingUrl != null) {
-			oneTimeTokenAuthenticationFilter
-				.setRequiresAuthenticationRequestMatcher(antMatcher(HttpMethod.POST, this.loginProcessingUrl));
-		}
-		oneTimeTokenAuthenticationFilter.setAuthenticationSuccessHandler(this.authenticationSuccessHandler);
-		oneTimeTokenAuthenticationFilter.setAuthenticationFailureHandler(getAuthenticationFailureHandler());
-		oneTimeTokenAuthenticationFilter.setSecurityContextRepository(getSecurityContextRepository(http));
-		http.addFilter(postProcess(oneTimeTokenAuthenticationFilter));
-	}
-
-	private SecurityContextRepository getSecurityContextRepository(H http) {
-		SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
-		if (securityContextRepository != null) {
-			return securityContextRepository;
-		}
-		return new HttpSessionSecurityContextRepository();
 	}
 
 	private void configureOttGenerateFilter(H http) {
@@ -170,7 +191,7 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 		DefaultOneTimeTokenSubmitPageGeneratingFilter submitPage = new DefaultOneTimeTokenSubmitPageGeneratingFilter();
 		submitPage.setResolveHiddenInputs(this::hiddenInputs);
 		submitPage.setRequestMatcher(antMatcher(HttpMethod.GET, this.defaultSubmitPageUrl));
-		submitPage.setLoginProcessingUrl(this.loginProcessingUrl);
+		submitPage.setLoginProcessingUrl(this.getLoginProcessingUrl());
 		http.addFilter(postProcess(submitPage));
 	}
 
@@ -184,6 +205,11 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 		return this.authenticationProvider;
 	}
 
+	@Override
+	protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) {
+		return antMatcher(HttpMethod.POST, loginProcessingUrl);
+	}
+
 	/**
 	 * Specifies the {@link AuthenticationProvider} to use when authenticating the user.
 	 * @param authenticationProvider
@@ -221,14 +247,25 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 	 * Only POST requests are processed, for that reason make sure that you pass a valid
 	 * CSRF token if CSRF protection is enabled.
 	 * @param loginProcessingUrl
-	 * @see org.springframework.security.config.annotation.web.builders.HttpSecurity#csrf(Customizer)
+	 * @see HttpSecurity#csrf(Customizer)
 	 */
 	public OneTimeTokenLoginConfigurer<H> loginProcessingUrl(String loginProcessingUrl) {
 		Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty");
-		this.loginProcessingUrl = loginProcessingUrl;
+		super.loginProcessingUrl(loginProcessingUrl);
 		return this;
 	}
 
+	/**
+	 * Specifies the URL to send users to if login is required. If used with
+	 * {@link EnableWebSecurity} a default login page will be generated when this
+	 * attribute is not specified.
+	 * @param loginPage
+	 */
+	@Override
+	public OneTimeTokenLoginConfigurer<H> loginPage(String loginPage) {
+		return super.loginPage(loginPage);
+	}
+
 	/**
 	 * Configures whether the default one-time token submit page should be shown. This
 	 * will prevent the {@link DefaultOneTimeTokenSubmitPageGeneratingFilter} to be
@@ -273,7 +310,7 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 	 */
 	public OneTimeTokenLoginConfigurer<H> authenticationConverter(AuthenticationConverter authenticationConverter) {
 		Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
-		this.authenticationConverter = authenticationConverter;
+		this.getAuthenticationFilter().setAuthenticationConverter(authenticationConverter);
 		return this;
 	}
 
@@ -283,11 +320,13 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 	 * {@link SimpleUrlAuthenticationFailureHandler}
 	 * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} to use
 	 * when authentication fails.
+	 * @deprecated Use {@link #failureHandler(AuthenticationFailureHandler)} instead
 	 */
+	@Deprecated(since = "6.5")
 	public OneTimeTokenLoginConfigurer<H> authenticationFailureHandler(
 			AuthenticationFailureHandler authenticationFailureHandler) {
 		Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null");
-		this.authenticationFailureHandler = authenticationFailureHandler;
+		super.failureHandler(authenticationFailureHandler);
 		return this;
 	}
 
@@ -296,22 +335,16 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
 	 * {@link SavedRequestAwareAuthenticationSuccessHandler} with no additional properties
 	 * set.
 	 * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler}.
+	 * @deprecated Use {@link #successHandler(AuthenticationSuccessHandler)} instead
 	 */
+	@Deprecated(since = "6.5")
 	public OneTimeTokenLoginConfigurer<H> authenticationSuccessHandler(
 			AuthenticationSuccessHandler authenticationSuccessHandler) {
 		Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null");
-		this.authenticationSuccessHandler = authenticationSuccessHandler;
+		super.successHandler(authenticationSuccessHandler);
 		return this;
 	}
 
-	private AuthenticationFailureHandler getAuthenticationFailureHandler() {
-		if (this.authenticationFailureHandler != null) {
-			return this.authenticationFailureHandler;
-		}
-		this.authenticationFailureHandler = new SimpleUrlAuthenticationFailureHandler("/login?error");
-		return this.authenticationFailureHandler;
-	}
-
 	/**
 	 * Use this {@link GenerateOneTimeTokenRequestResolver} when resolving
 	 * {@link GenerateOneTimeTokenRequest} from {@link HttpServletRequest}. By default,

+ 46 - 11
config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java

@@ -3039,7 +3039,8 @@ public class ServerHttpSecurity {
 				return;
 			}
 			if (http.formLogin != null && http.formLogin.isEntryPointExplicit
-					|| http.oauth2Login != null && StringUtils.hasText(http.oauth2Login.loginPage)) {
+					|| http.oauth2Login != null && StringUtils.hasText(http.oauth2Login.loginPage)
+					|| http.oneTimeTokenLogin != null && StringUtils.hasText(http.oneTimeTokenLogin.loginPage)) {
 				return;
 			}
 			LoginPageGeneratingWebFilter loginPage = null;
@@ -3054,6 +3055,13 @@ public class ServerHttpSecurity {
 				}
 				loginPage.setOauth2AuthenticationUrlToClientName(urlToText);
 			}
+			if (http.oneTimeTokenLogin != null) {
+				if (loginPage == null) {
+					loginPage = new LoginPageGeneratingWebFilter();
+				}
+				loginPage.setOneTimeTokenEnabled(true);
+				loginPage.setGenerateOneTimeTokenUrl(http.oneTimeTokenLogin.tokenGeneratingUrl);
+			}
 			if (loginPage != null) {
 				http.addFilterAt(loginPage, SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING);
 				http.addFilterBefore(DefaultResourcesWebFilter.css(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING);
@@ -5954,11 +5962,13 @@ public class ServerHttpSecurity {
 
 		private boolean submitPageEnabled = true;
 
+		private String loginPage;
+
 		protected void configure(ServerHttpSecurity http) {
 			configureSubmitPage(http);
 			configureOttGenerateFilter(http);
 			configureOttAuthenticationFilter(http);
-			configureDefaultLoginPage(http);
+			configureDefaultEntryPoint(http);
 		}
 
 		private void configureOttAuthenticationFilter(ServerHttpSecurity http) {
@@ -5995,17 +6005,29 @@ public class ServerHttpSecurity {
 			http.addFilterAt(generateFilter, SecurityWebFiltersOrder.ONE_TIME_TOKEN);
 		}
 
-		private void configureDefaultLoginPage(ServerHttpSecurity http) {
-			if (http.formLogin != null) {
-				for (WebFilter webFilter : http.webFilters) {
-					OrderedWebFilter orderedWebFilter = (OrderedWebFilter) webFilter;
-					if (orderedWebFilter.webFilter instanceof LoginPageGeneratingWebFilter loginPageGeneratingFilter) {
-						loginPageGeneratingFilter.setOneTimeTokenEnabled(true);
-						loginPageGeneratingFilter.setGenerateOneTimeTokenUrl(this.tokenGeneratingUrl);
-						break;
-					}
+		private void configureDefaultEntryPoint(ServerHttpSecurity http) {
+			MediaTypeServerWebExchangeMatcher htmlMatcher = new MediaTypeServerWebExchangeMatcher(
+					MediaType.APPLICATION_XHTML_XML, new MediaType("image", "*"), MediaType.TEXT_HTML,
+					MediaType.TEXT_PLAIN);
+			htmlMatcher.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL));
+			ServerWebExchangeMatcher xhrMatcher = (exchange) -> {
+				if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With").contains("XMLHttpRequest")) {
+					return ServerWebExchangeMatcher.MatchResult.match();
 				}
+				return ServerWebExchangeMatcher.MatchResult.notMatch();
+			};
+			ServerWebExchangeMatcher notXhrMatcher = new NegatedServerWebExchangeMatcher(xhrMatcher);
+			ServerWebExchangeMatcher defaultEntryPointMatcher = new AndServerWebExchangeMatcher(notXhrMatcher,
+					htmlMatcher);
+			String loginPage = "/login";
+			if (this.loginPage != null) {
+				loginPage = this.loginPage;
 			}
+			RedirectServerAuthenticationEntryPoint defaultEntryPoint = new RedirectServerAuthenticationEntryPoint(
+					loginPage);
+			defaultEntryPoint.setRequestCache(http.requestCache.requestCache);
+			http.defaultEntryPoints.add(new DelegateEntry(defaultEntryPointMatcher, defaultEntryPoint));
+
 		}
 
 		/**
@@ -6233,6 +6255,19 @@ public class ServerHttpSecurity {
 			return this.tokenGenerationSuccessHandler;
 		}
 
+		/**
+		 * Specifies the URL to send users to if login is required. A default login page
+		 * will be generated when this attribute is not specified.
+		 * @param loginPage the URL to send users to if login is required
+		 * @return the {@link OAuth2LoginSpec} for further configuration
+		 * @since 6.5
+		 */
+		public OneTimeTokenLoginSpec loginPage(String loginPage) {
+			Assert.hasText(loginPage, "loginPage cannot be empty");
+			this.loginPage = loginPage;
+			return this;
+		}
+
 	}
 
 }

+ 3 - 3
config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2024 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -62,12 +62,12 @@ class OneTimeTokenLoginDsl {
             tokenService?.also { oneTimeTokenLoginConfigurer.tokenService(tokenService) }
             authenticationConverter?.also { oneTimeTokenLoginConfigurer.authenticationConverter(authenticationConverter) }
             authenticationFailureHandler?.also {
-                oneTimeTokenLoginConfigurer.authenticationFailureHandler(
+                oneTimeTokenLoginConfigurer.failureHandler(
                     authenticationFailureHandler
                 )
             }
             authenticationSuccessHandler?.also {
-                oneTimeTokenLoginConfigurer.authenticationSuccessHandler(
+                oneTimeTokenLoginConfigurer.successHandler(
                     authenticationSuccessHandler
                 )
             }

+ 18 - 24
config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java

@@ -146,8 +146,8 @@ public class OneTimeTokenLoginConfigurerTests {
 	}
 
 	@Test
-	void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm() throws Exception {
-		this.spring.register(OneTimeTokenFormLoginConfig.class).autowire();
+	void oneTimeTokenWhenConfiguredThenRendersRequestTokenForm() throws Exception {
+		this.spring.register(OneTimeTokenDefaultConfig.class).autowire();
 		CsrfToken csrfToken = new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "BaseSpringSpec_CSRFTOKEN");
 		String csrfAttributeName = HttpSessionCsrfTokenRepository.class.getName().concat(".CSRF_TOKEN");
 		//@formatter:off
@@ -168,21 +168,7 @@ public class OneTimeTokenLoginConfigurerTests {
 						  </head>
 						  <body>
 						    <div class="content">
-						      <form class="login-form" method="post" action="/login">
-						        <h2>Please sign in</h2>
-
-						        <p>
-						          <label for="username" class="screenreader">Username</label>
-						          <input type="text" id="username" name="username" placeholder="Username" required autofocus>
-						        </p>
-						        <p>
-						          <label for="password" class="screenreader">Password</label>
-						          <input type="password" id="password" name="password" placeholder="Password" required>
-						        </p>
 
-						<input name="_csrf" type="hidden" value="%s" />
-						        <button type="submit" class="primary">Sign in</button>
-						      </form>
 						      <form id="ott-form" class="login-form" method="post" action="/ott/generate">
 						        <h2>Request a One-Time Token</h2>
 
@@ -202,6 +188,14 @@ public class OneTimeTokenLoginConfigurerTests {
 		//@formatter:on
 	}
 
+	@Test
+	void oneTimeTokenWhenLoginPageConfiguredThenRedirects() throws Exception {
+		this.spring.register(OneTimeTokenLoginPageConfig.class).autowire();
+		this.mvc.perform(get("/login"))
+			.andExpect(status().isFound())
+			.andExpect(redirectedUrl("http://localhost/custom-login"));
+	}
+
 	@Test
 	void oneTimeTokenWhenNoTokenGenerationSuccessHandlerThenException() {
 		assertThatException()
@@ -307,7 +301,7 @@ public class OneTimeTokenLoginConfigurerTests {
 	@Configuration(proxyBeanMethods = false)
 	@EnableWebSecurity
 	@Import(UserDetailsServiceConfig.class)
-	static class OneTimeTokenDifferentUrlsConfig {
+	static class OneTimeTokenLoginPageConfig {
 
 		@Bean
 		SecurityFilterChain securityFilterChain(HttpSecurity http,
@@ -318,10 +312,8 @@ public class OneTimeTokenLoginConfigurerTests {
 							.anyRequest().authenticated()
 					)
 					.oneTimeTokenLogin((ott) -> ott
-							.tokenGeneratingUrl("/generateurl")
 							.tokenGenerationSuccessHandler(ottSuccessHandler)
-							.loginProcessingUrl("/loginprocessingurl")
-							.authenticationSuccessHandler(new SimpleUrlAuthenticationSuccessHandler("/authenticated"))
+							.loginPage("/custom-login")
 					);
 			// @formatter:on
 			return http.build();
@@ -329,7 +321,7 @@ public class OneTimeTokenLoginConfigurerTests {
 
 		@Bean
 		TestOneTimeTokenGenerationSuccessHandler ottSuccessHandler() {
-			return new TestOneTimeTokenGenerationSuccessHandler("/redirected");
+			return new TestOneTimeTokenGenerationSuccessHandler();
 		}
 
 	}
@@ -337,7 +329,7 @@ public class OneTimeTokenLoginConfigurerTests {
 	@Configuration(proxyBeanMethods = false)
 	@EnableWebSecurity
 	@Import(UserDetailsServiceConfig.class)
-	static class OneTimeTokenFormLoginConfig {
+	static class OneTimeTokenDifferentUrlsConfig {
 
 		@Bean
 		SecurityFilterChain securityFilterChain(HttpSecurity http,
@@ -347,9 +339,11 @@ public class OneTimeTokenLoginConfigurerTests {
 					.authorizeHttpRequests((authz) -> authz
 							.anyRequest().authenticated()
 					)
-					.formLogin(Customizer.withDefaults())
 					.oneTimeTokenLogin((ott) -> ott
+							.tokenGeneratingUrl("/generateurl")
 							.tokenGenerationSuccessHandler(ottSuccessHandler)
+							.loginProcessingUrl("/loginprocessingurl")
+							.successHandler(new SimpleUrlAuthenticationSuccessHandler("/authenticated"))
 					);
 			// @formatter:on
 			return http.build();
@@ -357,7 +351,7 @@ public class OneTimeTokenLoginConfigurerTests {
 
 		@Bean
 		TestOneTimeTokenGenerationSuccessHandler ottSuccessHandler() {
-			return new TestOneTimeTokenGenerationSuccessHandler();
+			return new TestOneTimeTokenGenerationSuccessHandler("/redirected");
 		}
 
 	}

+ 48 - 0
config/src/test/java/org/springframework/security/config/web/server/OneTimeTokenLoginSpecTests.java

@@ -250,6 +250,41 @@ public class OneTimeTokenLoginSpecTests {
 		// @formatter:on
 	}
 
+	@Test
+	void oneTimeTokenWhenConfiguredThenRendersRequestTokenForm() {
+		this.spring.register(OneTimeTokenDefaultConfig.class).autowire();
+
+		//@formatter:off
+		byte[] responseByteArray = this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.get()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/login")
+						.build()
+				)
+				.exchange()
+				.expectBody()
+				.returnResult()
+				.getResponseBody();
+		// @formatter:on
+
+		String response = new String(responseByteArray);
+
+		assertThat(response.contains(EXPECTED_HTML_HEAD)).isTrue();
+		assertThat(response.contains(GENERATE_OTT_PART)).isTrue();
+	}
+
+	@Test
+	void oneTimeTokenWhenConfiguredThenRedirectsToLoginPage() {
+		this.spring.register(OneTimeTokenDefaultConfig.class).autowire();
+
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+			.get()
+			.uri((uriBuilder) -> uriBuilder.path("/").build())
+			.exchange()
+			.expectHeader()
+			.location("/login");
+	}
+
 	@Test
 	void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm() {
 		this.spring.register(OneTimeTokenFormLoginConfig.class).autowire();
@@ -280,6 +315,18 @@ public class OneTimeTokenLoginSpecTests {
 		return lastToken;
 	}
 
+	@Test
+	void oneTimeTokenWhenCustomLoginPageThenRedirects() {
+		this.spring.register(OneTimeTokenDifferentUrlsConfig.class).autowire();
+
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+			.get()
+			.uri((uriBuilder) -> uriBuilder.path("/login").build())
+			.exchange()
+			.expectHeader()
+			.location("/custom-login");
+	}
+
 	@Test
 	void oneTimeTokenWhenNoOneTimeTokenGenerationSuccessHandlerThenException() {
 		assertThatException()
@@ -362,6 +409,7 @@ public class OneTimeTokenLoginSpecTests {
 							.authenticated()
 					)
 					.oneTimeTokenLogin((ott) -> ott
+							.loginPage("/custom-login")
 							.tokenGeneratingUrl("/generateurl")
 							.tokenGenerationSuccessHandler(ottSuccessHandler)
 							.loginProcessingUrl("/loginprocessingurl")

+ 2 - 2
docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc

@@ -42,8 +42,8 @@ In the following sections we will explore how to configure OTT Login for your ne
 [[default-pages]]
 == Default Login Page and Default One-Time Token Submit Page
 
-The `oneTimeTokenLogin()` DSL can be used in conjunction with `formLogin()`, which will produce an additional One-Time Token Request Form in the xref:servlet/authentication/passwords/form.adoc[default generated login page].
-It will also set up the javadoc:org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter[] to generate a default One-Time Token submit page.
+When the `oneTimeTokenLogin()` DSL is used, by default the One-Time Token Login Page is auto-generated by the org.springframework.security.web.authentication.ui:DefaultLoginPageGeneratingFilter[].
+The DSL will also set up the javadoc:org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter[] to generate a default One-Time Token submit page.
 
 [[sending-token-to-user]]
 == Sending the Token to the User

+ 3 - 1
web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java

@@ -43,11 +43,13 @@ import static org.springframework.security.web.util.matcher.AntPathRequestMatche
  */
 public final class GenerateOneTimeTokenFilter extends OncePerRequestFilter {
 
+	public static final String DEFAULT_GENERATE_URL = "/ott/generate";
+
 	private final OneTimeTokenService tokenService;
 
 	private final OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler;
 
-	private RequestMatcher requestMatcher = antMatcher(HttpMethod.POST, "/ott/generate");
+	private RequestMatcher requestMatcher = antMatcher(HttpMethod.POST, DEFAULT_GENERATE_URL);
 
 	private GenerateOneTimeTokenRequestResolver requestResolver = new DefaultGenerateOneTimeTokenRequestResolver();
 

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

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2024 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -133,7 +133,7 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
 	}
 
 	public boolean isEnabled() {
-		return this.formLoginEnabled || this.oauth2LoginEnabled || this.saml2LoginEnabled;
+		return this.formLoginEnabled || this.oauth2LoginEnabled || this.saml2LoginEnabled || this.oneTimeTokenEnabled;
 	}
 
 	public void setLogoutSuccessUrl(String logoutSuccessUrl) {

+ 6 - 3
web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2002-2024 the original author or authors.
+ * Copyright 2002-2025 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.
@@ -28,6 +28,7 @@ import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 
+import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter;
 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
 import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.util.Assert;
@@ -43,11 +44,13 @@ import org.springframework.web.filter.OncePerRequestFilter;
  */
 public final class DefaultOneTimeTokenSubmitPageGeneratingFilter extends OncePerRequestFilter {
 
-	private RequestMatcher requestMatcher = new AntPathRequestMatcher("/login/ott", "GET");
+	public static final String DEFAULT_SUBMIT_PAGE_URL = "/login/ott";
+
+	private RequestMatcher requestMatcher = new AntPathRequestMatcher(DEFAULT_SUBMIT_PAGE_URL, "GET");
 
 	private Function<HttpServletRequest, Map<String, String>> resolveHiddenInputs = (request) -> Collections.emptyMap();
 
-	private String loginProcessingUrl = "/login/ott";
+	private String loginProcessingUrl = OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL;
 
 	@Override
 	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)