浏览代码

Add Reactive One-Time Token Login support

Closes gh-15699
Max Batischev 10 月之前
父节点
当前提交
2ca2e56383
共有 20 个文件被更改,包括 2430 次插入1 次删除
  1. 11 1
      config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java
  2. 350 0
      config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java
  3. 406 0
      config/src/test/java/org/springframework/security/config/web/server/OneTimeTokenLoginSpecTests.java
  4. 60 0
      core/src/main/java/org/springframework/security/authentication/ott/reactive/InMemoryReactiveOneTimeTokenService.java
  5. 71 0
      core/src/main/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManager.java
  6. 49 0
      core/src/main/java/org/springframework/security/authentication/ott/reactive/ReactiveOneTimeTokenService.java
  7. 132 0
      core/src/test/java/org/springframework/security/authentication/ott/reactive/InMemoryReactiveOneTimeTokenServiceTests.java
  8. 141 0
      core/src/test/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManagerTests.java
  9. 341 0
      docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc
  10. 78 0
      web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java
  11. 41 0
      web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerGeneratedOneTimeTokenHandler.java
  12. 77 0
      web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerOneTimeTokenAuthenticationConverter.java
  13. 52 0
      web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerRedirectGeneratedOneTimeTokenHandler.java
  14. 55 0
      web/src/main/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilter.java
  15. 150 0
      web/src/main/java/org/springframework/security/web/server/ui/OneTimeTokenSubmitPageGeneratingWebFilter.java
  16. 103 0
      web/src/test/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilterTests.java
  17. 93 0
      web/src/test/java/org/springframework/security/web/server/authentication/ott/ServerOneTimeTokenAuthenticationConverterTests.java
  18. 74 0
      web/src/test/java/org/springframework/security/web/server/authentication/ott/ServerRedirectGeneratedOneTimeTokenHandlerTests.java
  19. 17 0
      web/src/test/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilterTests.java
  20. 129 0
      web/src/test/java/org/springframework/security/web/server/ui/OneTimeTokenSubmitPageGeneratingWebFilterTests.java

+ 11 - 1
config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java

@@ -1,5 +1,5 @@
 /*
 /*
- * Copyright 2002-2017 the original author or authors.
+ * Copyright 2002-2024 the original author or authors.
  *
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * you may not use this file except in compliance with the License.
@@ -67,6 +67,16 @@ public enum SecurityWebFiltersOrder {
 
 
 	LOGOUT_PAGE_GENERATING,
 	LOGOUT_PAGE_GENERATING,
 
 
+	/**
+	 * {@link org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter}
+	 */
+	ONE_TIME_TOKEN,
+
+	/**
+	 * {@link org.springframework.security.web.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter}
+	 */
+	ONE_TIME_TOKEN_SUBMIT_PAGE_GENERATING,
+
 	/**
 	/**
 	 * {@link org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter}
 	 * {@link org.springframework.security.web.server.context.SecurityContextServerWebExchangeWebFilter}
 	 */
 	 */

+ 350 - 0
config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java

@@ -53,6 +53,10 @@ import org.springframework.security.authentication.AbstractAuthenticationToken;
 import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager;
 import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager;
 import org.springframework.security.authentication.ReactiveAuthenticationManager;
 import org.springframework.security.authentication.ReactiveAuthenticationManager;
 import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
 import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver;
+import org.springframework.security.authentication.ott.OneTimeToken;
+import org.springframework.security.authentication.ott.reactive.InMemoryReactiveOneTimeTokenService;
+import org.springframework.security.authentication.ott.reactive.OneTimeTokenReactiveAuthenticationManager;
+import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService;
 import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager;
 import org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager;
 import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager;
 import org.springframework.security.authorization.AuthorityReactiveAuthorizationManager;
 import org.springframework.security.authorization.AuthorizationDecision;
 import org.springframework.security.authorization.AuthorizationDecision;
@@ -152,6 +156,9 @@ import org.springframework.security.web.server.authentication.logout.LogoutWebFi
 import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler;
 import org.springframework.security.web.server.authentication.logout.SecurityContextServerLogoutHandler;
 import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
 import org.springframework.security.web.server.authentication.logout.ServerLogoutHandler;
 import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
 import org.springframework.security.web.server.authentication.logout.ServerLogoutSuccessHandler;
+import org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter;
+import org.springframework.security.web.server.authentication.ott.ServerGeneratedOneTimeTokenHandler;
+import org.springframework.security.web.server.authentication.ott.ServerOneTimeTokenAuthenticationConverter;
 import org.springframework.security.web.server.authorization.AuthorizationContext;
 import org.springframework.security.web.server.authorization.AuthorizationContext;
 import org.springframework.security.web.server.authorization.AuthorizationWebFilter;
 import org.springframework.security.web.server.authorization.AuthorizationWebFilter;
 import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager;
 import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager;
@@ -197,6 +204,7 @@ import org.springframework.security.web.server.transport.HttpsRedirectWebFilter;
 import org.springframework.security.web.server.ui.DefaultResourcesWebFilter;
 import org.springframework.security.web.server.ui.DefaultResourcesWebFilter;
 import org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter;
 import org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter;
 import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
 import org.springframework.security.web.server.ui.LogoutPageGeneratingWebFilter;
+import org.springframework.security.web.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter;
 import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.AndServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.MediaTypeServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
 import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
@@ -348,6 +356,8 @@ public class ServerHttpSecurity {
 
 
 	private AnonymousSpec anonymous;
 	private AnonymousSpec anonymous;
 
 
+	private OneTimeTokenLoginSpec oneTimeTokenLogin;
+
 	protected ServerHttpSecurity() {
 	protected ServerHttpSecurity() {
 	}
 	}
 
 
@@ -1549,6 +1559,43 @@ public class ServerHttpSecurity {
 		return this;
 		return this;
 	}
 	}
 
 
+	/**
+	 * Configures One-Time Token Login Support.
+	 *
+	 * <h2>Example Configuration</h2>
+	 *
+	 * <pre>
+	 * &#064;Configuration
+	 * &#064;EnableWebFluxSecurity
+	 * public class SecurityConfig {
+	 *
+	 * 	&#064;Bean
+	 * 	public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {
+	 * 		http
+	 * 			// ...
+	 * 			.oneTimeTokenLogin(Customizer.withDefaults());
+	 * 		return http.build();
+	 * 	}
+	 *
+	 * 	&#064;Bean
+	 * 	public ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler() {
+	 * 		return new MyMagicLinkServerGeneratedOneTimeTokenHandler();
+	 * 	}
+	 *
+	 * }
+	 * </pre>
+	 * @param oneTimeTokenLoginCustomizer the {@link Customizer} to provide more options
+	 * for the {@link OneTimeTokenLoginSpec}
+	 * @return the {@link ServerHttpSecurity} for further customizations
+	 */
+	public ServerHttpSecurity oneTimeTokenLogin(Customizer<OneTimeTokenLoginSpec> oneTimeTokenLoginCustomizer) {
+		if (this.oneTimeTokenLogin == null) {
+			this.oneTimeTokenLogin = new OneTimeTokenLoginSpec();
+		}
+		oneTimeTokenLoginCustomizer.customize(this.oneTimeTokenLogin);
+		return this;
+	}
+
 	/**
 	/**
 	 * Builds the {@link SecurityWebFilterChain}
 	 * Builds the {@link SecurityWebFilterChain}
 	 * @return the {@link SecurityWebFilterChain}
 	 * @return the {@link SecurityWebFilterChain}
@@ -1641,6 +1688,18 @@ public class ServerHttpSecurity {
 			this.logout.configure(this);
 			this.logout.configure(this);
 		}
 		}
 		this.requestCache.configure(this);
 		this.requestCache.configure(this);
+		if (this.oneTimeTokenLogin != null) {
+			if (this.oneTimeTokenLogin.securityContextRepository != null) {
+				this.oneTimeTokenLogin.securityContextRepository(this.oneTimeTokenLogin.securityContextRepository);
+			}
+			else if (this.securityContextRepository != null) {
+				this.oneTimeTokenLogin.securityContextRepository(this.securityContextRepository);
+			}
+			else {
+				this.oneTimeTokenLogin.securityContextRepository(new WebSessionServerSecurityContextRepository());
+			}
+			this.oneTimeTokenLogin.configure(this);
+		}
 		this.addFilterAt(new SecurityContextServerWebExchangeWebFilter(),
 		this.addFilterAt(new SecurityContextServerWebExchangeWebFilter(),
 				SecurityWebFiltersOrder.SECURITY_CONTEXT_SERVER_WEB_EXCHANGE);
 				SecurityWebFiltersOrder.SECURITY_CONTEXT_SERVER_WEB_EXCHANGE);
 		if (this.authorizeExchange != null) {
 		if (this.authorizeExchange != null) {
@@ -5850,4 +5909,295 @@ public class ServerHttpSecurity {
 
 
 	}
 	}
 
 
+	/**
+	 * Configures One-Time Token Login Support
+	 *
+	 * @author Max Batischev
+	 * @since 6.4
+	 * @see #oneTimeTokenLogin(Customizer)
+	 */
+	public final class OneTimeTokenLoginSpec {
+
+		private ReactiveAuthenticationManager authenticationManager;
+
+		private ReactiveOneTimeTokenService oneTimeTokenService;
+
+		private ServerAuthenticationConverter authenticationConverter = new ServerOneTimeTokenAuthenticationConverter();
+
+		private ServerAuthenticationFailureHandler authenticationFailureHandler;
+
+		private final RedirectServerAuthenticationSuccessHandler defaultSuccessHandler = new RedirectServerAuthenticationSuccessHandler(
+				"/");
+
+		private final List<ServerAuthenticationSuccessHandler> defaultSuccessHandlers = new ArrayList<>(
+				List.of(this.defaultSuccessHandler));
+
+		private final List<ServerAuthenticationSuccessHandler> authenticationSuccessHandlers = new ArrayList<>();
+
+		private ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler;
+
+		private ServerSecurityContextRepository securityContextRepository;
+
+		private String loginProcessingUrl = "/login/ott";
+
+		private String defaultSubmitPageUrl = "/login/ott";
+
+		private String generateTokenUrl = "/ott/generate";
+
+		private boolean submitPageEnabled = true;
+
+		protected void configure(ServerHttpSecurity http) {
+			configureSubmitPage(http);
+			configureOttGenerateFilter(http);
+			configureOttAuthenticationFilter(http);
+			configureDefaultLoginPage(http);
+		}
+
+		private void configureOttAuthenticationFilter(ServerHttpSecurity http) {
+			AuthenticationWebFilter ottWebFilter = new AuthenticationWebFilter(getAuthenticationManager());
+			ottWebFilter.setServerAuthenticationConverter(this.authenticationConverter);
+			ottWebFilter.setAuthenticationFailureHandler(getAuthenticationFailureHandler());
+			ottWebFilter.setAuthenticationSuccessHandler(getAuthenticationSuccessHandler());
+			ottWebFilter.setRequiresAuthenticationMatcher(
+					ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, this.loginProcessingUrl));
+			ottWebFilter.setSecurityContextRepository(this.securityContextRepository);
+			http.addFilterAt(ottWebFilter, SecurityWebFiltersOrder.AUTHENTICATION);
+		}
+
+		private void configureSubmitPage(ServerHttpSecurity http) {
+			if (!this.submitPageEnabled) {
+				return;
+			}
+			OneTimeTokenSubmitPageGeneratingWebFilter submitPage = new OneTimeTokenSubmitPageGeneratingWebFilter();
+			submitPage.setLoginProcessingUrl(this.loginProcessingUrl);
+
+			if (StringUtils.hasText(this.defaultSubmitPageUrl)) {
+				submitPage.setRequestMatcher(
+						ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, this.defaultSubmitPageUrl));
+			}
+			http.addFilterAt(submitPage, SecurityWebFiltersOrder.ONE_TIME_TOKEN_SUBMIT_PAGE_GENERATING);
+		}
+
+		private void configureOttGenerateFilter(ServerHttpSecurity http) {
+			GenerateOneTimeTokenWebFilter generateFilter = new GenerateOneTimeTokenWebFilter(getOneTimeTokenService(),
+					getGeneratedOneTimeTokenHandler());
+			generateFilter
+				.setRequestMatcher(ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, this.generateTokenUrl));
+			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.generateTokenUrl);
+						break;
+					}
+				}
+			}
+		}
+
+		/**
+		 * Allows customizing the list of {@link ServerAuthenticationSuccessHandler}. The
+		 * default list contains a {@link RedirectServerAuthenticationSuccessHandler} that
+		 * redirects to "/".
+		 * @param handlersConsumer the handlers consumer
+		 * @return the {@link OneTimeTokenLoginSpec} to continue configuring
+		 */
+		public OneTimeTokenLoginSpec authenticationSuccessHandler(
+				Consumer<List<ServerAuthenticationSuccessHandler>> handlersConsumer) {
+			Assert.notNull(handlersConsumer, "handlersConsumer cannot be null");
+			handlersConsumer.accept(this.authenticationSuccessHandlers);
+			return this;
+		}
+
+		/**
+		 * Specifies the {@link ServerAuthenticationSuccessHandler}
+		 * @param authenticationSuccessHandler the
+		 * {@link ServerAuthenticationSuccessHandler}.
+		 */
+		public OneTimeTokenLoginSpec authenticationSuccessHandler(
+				ServerAuthenticationSuccessHandler authenticationSuccessHandler) {
+			Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null");
+			authenticationSuccessHandler((handlers) -> {
+				handlers.clear();
+				handlers.add(authenticationSuccessHandler);
+			});
+			return this;
+		}
+
+		private ServerAuthenticationSuccessHandler getAuthenticationSuccessHandler() {
+			if (this.authenticationSuccessHandlers.isEmpty()) {
+				return new DelegatingServerAuthenticationSuccessHandler(this.defaultSuccessHandlers);
+			}
+			return new DelegatingServerAuthenticationSuccessHandler(this.authenticationSuccessHandlers);
+		}
+
+		/**
+		 * Specifies the {@link ServerAuthenticationFailureHandler} to use when
+		 * authentication fails. The default is redirecting to "/login?error" using
+		 * {@link RedirectServerAuthenticationFailureHandler}
+		 * @param authenticationFailureHandler the
+		 * {@link ServerAuthenticationFailureHandler} to use when authentication fails.
+		 */
+		public OneTimeTokenLoginSpec authenticationFailureHandler(
+				ServerAuthenticationFailureHandler authenticationFailureHandler) {
+			Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null");
+			this.authenticationFailureHandler = authenticationFailureHandler;
+			return this;
+		}
+
+		ServerAuthenticationFailureHandler getAuthenticationFailureHandler() {
+			if (this.authenticationFailureHandler == null) {
+				this.authenticationFailureHandler = new RedirectServerAuthenticationFailureHandler("/login?error");
+			}
+			return this.authenticationFailureHandler;
+		}
+
+		/**
+		 * Specifies {@link ReactiveAuthenticationManager} for one time tokens. Default
+		 * implementation is {@link OneTimeTokenReactiveAuthenticationManager}
+		 * @param authenticationManager
+		 */
+		public OneTimeTokenLoginSpec authenticationManager(ReactiveAuthenticationManager authenticationManager) {
+			Assert.notNull(authenticationManager, "authenticationManager cannot be null");
+			this.authenticationManager = authenticationManager;
+			return this;
+		}
+
+		ReactiveAuthenticationManager getAuthenticationManager() {
+			if (this.authenticationManager == null) {
+				ReactiveUserDetailsService userDetailsService = getBean(ReactiveUserDetailsService.class);
+				return new OneTimeTokenReactiveAuthenticationManager(getOneTimeTokenService(), userDetailsService);
+			}
+			return this.authenticationManager;
+		}
+
+		/**
+		 * Configures the {@link ReactiveOneTimeTokenService} used to generate and consume
+		 * {@link OneTimeToken}
+		 * @param oneTimeTokenService
+		 */
+		public OneTimeTokenLoginSpec oneTimeTokenService(ReactiveOneTimeTokenService oneTimeTokenService) {
+			Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null");
+			this.oneTimeTokenService = oneTimeTokenService;
+			return this;
+		}
+
+		ReactiveOneTimeTokenService getOneTimeTokenService() {
+			if (this.oneTimeTokenService != null) {
+				return this.oneTimeTokenService;
+			}
+			ReactiveOneTimeTokenService oneTimeTokenService = getBeanOrNull(ReactiveOneTimeTokenService.class);
+			if (oneTimeTokenService != null) {
+				return oneTimeTokenService;
+			}
+			this.oneTimeTokenService = new InMemoryReactiveOneTimeTokenService();
+			return this.oneTimeTokenService;
+		}
+
+		/**
+		 * Use this {@link ServerAuthenticationConverter} when converting incoming
+		 * requests to an {@link Authentication}. By default, the
+		 * {@link ServerOneTimeTokenAuthenticationConverter} is used.
+		 * @param authenticationConverter the {@link ServerAuthenticationConverter} to use
+		 */
+		public OneTimeTokenLoginSpec authenticationConverter(ServerAuthenticationConverter authenticationConverter) {
+			Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
+			this.authenticationConverter = authenticationConverter;
+			return this;
+		}
+
+		/**
+		 * Specifies the URL to process the login request, defaults to {@code /login/ott}.
+		 * Only POST requests are processed, for that reason make sure that you pass a
+		 * valid CSRF token if CSRF protection is enabled.
+		 * @param loginProcessingUrl
+		 */
+		public OneTimeTokenLoginSpec loginProcessingUrl(String loginProcessingUrl) {
+			Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty");
+			this.loginProcessingUrl = loginProcessingUrl;
+			return this;
+		}
+
+		/**
+		 * Configures whether the default one-time token submit page should be shown. This
+		 * will prevent the {@link OneTimeTokenSubmitPageGeneratingWebFilter} to be
+		 * configured.
+		 * @param show
+		 */
+		public OneTimeTokenLoginSpec showDefaultSubmitPage(boolean show) {
+			this.submitPageEnabled = show;
+			return this;
+		}
+
+		/**
+		 * Sets the URL that the default submit page will be generated. Defaults to
+		 * {@code /login/ott}. If you don't want to generate the default submit page you
+		 * should use {@link #showDefaultSubmitPage(boolean)}. Note that this method
+		 * always invoke {@link #showDefaultSubmitPage(boolean)} passing {@code true}.
+		 * @param submitPageUrl
+		 */
+		public OneTimeTokenLoginSpec defaultSubmitPageUrl(String submitPageUrl) {
+			Assert.hasText(submitPageUrl, "submitPageUrl cannot be null or empty");
+			this.defaultSubmitPageUrl = submitPageUrl;
+			showDefaultSubmitPage(true);
+			return this;
+		}
+
+		/**
+		 * Specifies strategy to be used to handle generated one-time tokens.
+		 * @param generatedOneTimeTokenHandler
+		 */
+		public OneTimeTokenLoginSpec generatedOneTimeTokenHandler(
+				ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler) {
+			Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null");
+			this.generatedOneTimeTokenHandler = generatedOneTimeTokenHandler;
+			return this;
+		}
+
+		/**
+		 * Specifies the URL that a One-Time Token generate request will be processed.
+		 * Defaults to {@code /ott/generate}.
+		 * @param generateTokenUrl
+		 */
+		public OneTimeTokenLoginSpec generateTokenUrl(String generateTokenUrl) {
+			Assert.hasText(generateTokenUrl, "generateTokenUrl cannot be null or empty");
+			this.generateTokenUrl = generateTokenUrl;
+			return this;
+		}
+
+		/**
+		 * The {@link ServerSecurityContextRepository} used to save the
+		 * {@code Authentication}. Defaults to
+		 * {@link WebSessionServerSecurityContextRepository}. For the
+		 * {@code SecurityContext} to be loaded on subsequent requests the
+		 * {@link ReactorContextWebFilter} must be configured to be able to load the value
+		 * (they are not implicitly linked).
+		 * @param securityContextRepository the repository to use
+		 * @return the {@link OneTimeTokenLoginSpec} to continue configuring
+		 */
+		public OneTimeTokenLoginSpec securityContextRepository(
+				ServerSecurityContextRepository securityContextRepository) {
+			this.securityContextRepository = securityContextRepository;
+			return this;
+		}
+
+		private ServerGeneratedOneTimeTokenHandler getGeneratedOneTimeTokenHandler() {
+			if (this.generatedOneTimeTokenHandler == null) {
+				this.generatedOneTimeTokenHandler = getBeanOrNull(ServerGeneratedOneTimeTokenHandler.class);
+			}
+			if (this.generatedOneTimeTokenHandler == null) {
+				throw new IllegalStateException("""
+						A ServerGeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin().
+						Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL.
+						""");
+			}
+			return this.generatedOneTimeTokenHandler;
+		}
+
+	}
+
 }
 }

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

@@ -0,0 +1,406 @@
+/*
+ * Copyright 2002-2024 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.config.web.server;
+
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import reactor.core.publisher.Mono;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.ott.OneTimeToken;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
+import org.springframework.security.config.test.SpringTestContext;
+import org.springframework.security.config.test.SpringTestContextExtension;
+import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers;
+import org.springframework.security.web.server.SecurityWebFilterChain;
+import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler;
+import org.springframework.security.web.server.authentication.ott.ServerGeneratedOneTimeTokenHandler;
+import org.springframework.security.web.server.authentication.ott.ServerRedirectGeneratedOneTimeTokenHandler;
+import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.web.reactive.config.EnableWebFlux;
+import org.springframework.web.reactive.function.BodyInserters;
+import org.springframework.web.server.ServerWebExchange;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatException;
+
+/**
+ * Tests for {@link ServerHttpSecurity.OneTimeTokenLoginSpec}
+ *
+ * @author Max Batischev
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OneTimeTokenLoginSpecTests {
+
+	public final SpringTestContext spring = new SpringTestContext(this);
+
+	private WebTestClient client;
+
+	private static final String EXPECTED_HTML_HEAD = """
+			<!DOCTYPE html>
+			<html lang="en">
+			  <head>
+			    <meta charset="utf-8">
+			    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+			    <meta name="description" content="">
+			    <meta name="author" content="">
+			    <title>Please sign in</title>
+			    <link href="/default-ui.css" rel="stylesheet" />
+			  </head>
+			""";
+
+	private static final String LOGIN_PART = """
+			<form class="login-form" method="post" action="/login">
+			""";
+
+	private static final String GENERATE_OTT_PART = """
+			<form id="ott-form" class="login-form" method="post" action="/ott/generate">
+			""";
+
+	@Autowired
+	public void setApplicationContext(ApplicationContext context) {
+		this.client = WebTestClient.bindToApplicationContext(context).build();
+	}
+
+	@Test
+	void oneTimeTokenWhenCorrectTokenThenCanAuthenticate() {
+		this.spring.register(OneTimeTokenDefaultConfig.class).autowire();
+
+		// @formatter:off
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.post()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/ott/generate")
+						.build()
+				)
+				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+				.body(BodyInserters.fromFormData("username", "user"))
+				.exchange()
+				.expectStatus()
+				.is3xxRedirection()
+				.expectHeader().valueEquals("Location", "/login/ott");
+		// @formatter:on
+
+		String token = TestServerGeneratedOneTimeTokenHandler.lastToken.getTokenValue();
+
+		// @formatter:off
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.post()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/login/ott")
+						.queryParam("token", token)
+						.build()
+				)
+				.exchange()
+				.expectStatus()
+				.is3xxRedirection()
+				.expectHeader().valueEquals("Location", "/");
+		// @formatter:on
+	}
+
+	@Test
+	void oneTimeTokenWhenDifferentAuthenticationUrlsThenCanAuthenticate() {
+		this.spring.register(OneTimeTokenDifferentUrlsConfig.class).autowire();
+
+		// @formatter:off
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.post()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/generateurl")
+						.build()
+				)
+				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+				.body(BodyInserters.fromValue("username=user"))
+				.exchange()
+				.expectStatus()
+				.is3xxRedirection()
+				.expectHeader().valueEquals("Location", "/redirected");
+		// @formatter:on
+
+		String token = TestServerGeneratedOneTimeTokenHandler.lastToken.getTokenValue();
+
+		// @formatter:off
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.post()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/loginprocessingurl")
+						.queryParam("token", token)
+						.build()
+				)
+				.exchange()
+				.expectStatus()
+				.is3xxRedirection()
+				.expectHeader().valueEquals("Location", "/authenticated");
+		// @formatter:on
+	}
+
+	@Test
+	void oneTimeTokenWhenCorrectTokenUsedTwiceThenSecondTimeFails() {
+		this.spring.register(OneTimeTokenDefaultConfig.class).autowire();
+
+		// @formatter:off
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.post()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/ott/generate")
+						.build()
+				)
+				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+				.body(BodyInserters.fromValue("username=user"))
+				.exchange()
+				.expectStatus()
+				.is3xxRedirection()
+				.expectHeader().valueEquals("Location", "/login/ott");
+		// @formatter:on
+
+		String token = TestServerGeneratedOneTimeTokenHandler.lastToken.getTokenValue();
+
+		// @formatter:off
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.post()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/login/ott")
+						.queryParam("token", token)
+						.build()
+				)
+				.exchange()
+				.expectStatus()
+				.is3xxRedirection()
+				.expectHeader().valueEquals("Location", "/");
+
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.post()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/login/ott")
+						.queryParam("token", token)
+						.build()
+				)
+				.exchange()
+				.expectStatus()
+				.is3xxRedirection()
+				.expectHeader().valueEquals("Location", "/login?error");
+		// @formatter:on
+	}
+
+	@Test
+	void oneTimeTokenWhenWrongTokenThenAuthenticationFail() {
+		this.spring.register(OneTimeTokenDefaultConfig.class).autowire();
+
+		// @formatter:off
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.post()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/ott/generate")
+						.build()
+				)
+				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+				.body(BodyInserters.fromValue("username=user"))
+				.exchange()
+				.expectStatus()
+				.is3xxRedirection()
+				.expectHeader().valueEquals("Location", "/login/ott");
+		// @formatter:on
+
+		String token = "wrong";
+
+		// @formatter:off
+		this.client.mutateWith(SecurityMockServerConfigurers.csrf())
+				.post()
+				.uri((uriBuilder) -> uriBuilder
+						.path("/login/ott")
+						.queryParam("token", token)
+						.build()
+				)
+				.exchange()
+				.expectStatus()
+				.is3xxRedirection()
+				.expectHeader().valueEquals("Location", "/login?error");
+		// @formatter:on
+	}
+
+	@Test
+	void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm() {
+		this.spring.register(OneTimeTokenFormLoginConfig.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(LOGIN_PART)).isTrue();
+		assertThat(response.contains(GENERATE_OTT_PART)).isTrue();
+	}
+
+	@Test
+	void oneTimeTokenWhenNoGeneratedOneTimeTokenHandlerThenException() {
+		assertThatException()
+			.isThrownBy(() -> this.spring.register(OneTimeTokenNotGeneratedOttHandlerConfig.class).autowire())
+			.havingRootCause()
+			.isInstanceOf(IllegalStateException.class)
+			.withMessage("""
+					A ServerGeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin().
+					Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL.
+					""");
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@EnableWebFlux
+	@EnableWebFluxSecurity
+	@Import(UserDetailsServiceConfig.class)
+	static class OneTimeTokenDefaultConfig {
+
+		@Bean
+		SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
+			// @formatter:off
+			http
+					.authorizeExchange((authorize) -> authorize
+							.anyExchange()
+							.authenticated())
+					.oneTimeTokenLogin((ott) -> ott
+							.generatedOneTimeTokenHandler(new TestServerGeneratedOneTimeTokenHandler())
+					);
+			// @formatter:on
+			return http.build();
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@EnableWebFlux
+	@EnableWebFluxSecurity
+	@Import(UserDetailsServiceConfig.class)
+	static class OneTimeTokenDifferentUrlsConfig {
+
+		@Bean
+		SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
+			// @formatter:off
+			http
+					.authorizeExchange((authorize) -> authorize
+							.anyExchange()
+							.authenticated())
+					.oneTimeTokenLogin((ott) -> ott
+							.generateTokenUrl("/generateurl")
+							.generatedOneTimeTokenHandler(new TestServerGeneratedOneTimeTokenHandler("/redirected"))
+							.loginProcessingUrl("/loginprocessingurl")
+							.authenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler("/authenticated"))
+					);
+			// @formatter:on
+			return http.build();
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@EnableWebFlux
+	@EnableWebFluxSecurity
+	@Import(UserDetailsServiceConfig.class)
+	static class OneTimeTokenFormLoginConfig {
+
+		@Bean
+		SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
+			// @formatter:off
+			http
+					.authorizeExchange((authorize) -> authorize
+							.anyExchange()
+							.authenticated())
+					.formLogin(Customizer.withDefaults())
+					.oneTimeTokenLogin((ott) -> ott
+							.generatedOneTimeTokenHandler(new TestServerGeneratedOneTimeTokenHandler())
+					);
+			// @formatter:on
+			return http.build();
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	@EnableWebFlux
+	@EnableWebFluxSecurity
+	@Import(UserDetailsServiceConfig.class)
+	static class OneTimeTokenNotGeneratedOttHandlerConfig {
+
+		@Bean
+		SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
+			// @formatter:off
+			http
+					.authorizeExchange((authorize) -> authorize
+							.anyExchange()
+							.authenticated())
+					.oneTimeTokenLogin(Customizer.withDefaults());
+			// @formatter:on
+			return http.build();
+		}
+
+	}
+
+	@Configuration(proxyBeanMethods = false)
+	static class UserDetailsServiceConfig {
+
+		@Bean
+		ReactiveUserDetailsService userDetailsService() {
+			return new MapReactiveUserDetailsService(
+					Map.of("user", new User("user", "password", Collections.emptyList())));
+		}
+
+	}
+
+	private static class TestServerGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
+
+		private static OneTimeToken lastToken;
+
+		private final ServerGeneratedOneTimeTokenHandler delegate;
+
+		TestServerGeneratedOneTimeTokenHandler() {
+			this.delegate = new ServerRedirectGeneratedOneTimeTokenHandler("/login/ott");
+		}
+
+		TestServerGeneratedOneTimeTokenHandler(String redirectUrl) {
+			this.delegate = new ServerRedirectGeneratedOneTimeTokenHandler(redirectUrl);
+		}
+
+		@Override
+		public Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken) {
+			lastToken = oneTimeToken;
+			return this.delegate.handle(exchange, oneTimeToken);
+		}
+
+	}
+
+}

+ 60 - 0
core/src/main/java/org/springframework/security/authentication/ott/reactive/InMemoryReactiveOneTimeTokenService.java

@@ -0,0 +1,60 @@
+/*
+ * Copyright 2002-2024 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.authentication.ott.reactive;
+
+import java.time.Clock;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
+import org.springframework.security.authentication.ott.InMemoryOneTimeTokenService;
+import org.springframework.security.authentication.ott.OneTimeToken;
+import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
+import org.springframework.util.Assert;
+
+/**
+ * Reactive adapter for {@link InMemoryOneTimeTokenService}
+ *
+ * @author Max Batischev
+ * @since 6.4
+ * @see InMemoryOneTimeTokenService
+ */
+public final class InMemoryReactiveOneTimeTokenService implements ReactiveOneTimeTokenService {
+
+	private final InMemoryOneTimeTokenService oneTimeTokenService = new InMemoryOneTimeTokenService();
+
+	@Override
+	public Mono<OneTimeToken> generate(GenerateOneTimeTokenRequest request) {
+		return Mono.just(request).map(this.oneTimeTokenService::generate);
+	}
+
+	@Override
+	public Mono<OneTimeToken> consume(OneTimeTokenAuthenticationToken authenticationToken) {
+		return Mono.just(authenticationToken).mapNotNull(this.oneTimeTokenService::consume);
+	}
+
+	/**
+	 * Sets the {@link Clock} used when generating one-time token and checking token
+	 * expiry.
+	 * @param clock the clock
+	 */
+	public void setClock(Clock clock) {
+		Assert.notNull(clock, "clock cannot be null");
+		this.oneTimeTokenService.setClock(clock);
+	}
+
+}

+ 71 - 0
core/src/main/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManager.java

@@ -0,0 +1,71 @@
+/*
+ * Copyright 2002-2024 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.authentication.ott.reactive;
+
+import java.util.function.Function;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.authentication.ott.InvalidOneTimeTokenException;
+import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link ReactiveAuthenticationManager} for one time tokens.
+ *
+ * @author Max Batischev
+ * @since 6.4
+ */
+public final class OneTimeTokenReactiveAuthenticationManager implements ReactiveAuthenticationManager {
+
+	private final ReactiveOneTimeTokenService oneTimeTokenService;
+
+	private final ReactiveUserDetailsService userDetailsService;
+
+	public OneTimeTokenReactiveAuthenticationManager(ReactiveOneTimeTokenService oneTimeTokenService,
+			ReactiveUserDetailsService userDetailsService) {
+		Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null");
+		Assert.notNull(userDetailsService, "userDetailsService cannot be null");
+		this.oneTimeTokenService = oneTimeTokenService;
+		this.userDetailsService = userDetailsService;
+	}
+
+	@Override
+	public Mono<Authentication> authenticate(Authentication authentication) {
+		if (!(authentication instanceof OneTimeTokenAuthenticationToken otpAuthenticationToken)) {
+			return Mono.empty();
+		}
+		return this.oneTimeTokenService.consume(otpAuthenticationToken)
+			.switchIfEmpty(Mono.error(new InvalidOneTimeTokenException("Invalid token")))
+			.flatMap((consumed) -> this.userDetailsService.findByUsername(consumed.getUsername()))
+			.map(onSuccess(otpAuthenticationToken));
+	}
+
+	private Function<UserDetails, OneTimeTokenAuthenticationToken> onSuccess(OneTimeTokenAuthenticationToken token) {
+		return (user) -> {
+			OneTimeTokenAuthenticationToken authenticated = OneTimeTokenAuthenticationToken.authenticated(user,
+					user.getAuthorities());
+			authenticated.setDetails(token.getDetails());
+			return authenticated;
+		};
+	}
+
+}

+ 49 - 0
core/src/main/java/org/springframework/security/authentication/ott/reactive/ReactiveOneTimeTokenService.java

@@ -0,0 +1,49 @@
+/*
+ * Copyright 2002-2024 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.authentication.ott.reactive;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
+import org.springframework.security.authentication.ott.OneTimeToken;
+import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
+
+/**
+ * Reactive interface for generating and consuming one-time tokens.
+ *
+ * @author Max Batischev
+ * @since 6.4
+ */
+public interface ReactiveOneTimeTokenService {
+
+	/**
+	 * Generates a one-time token based on the provided generate request.
+	 * @param request the generate request containing the necessary information to
+	 * generate the token
+	 * @return the generated {@link OneTimeToken}.
+	 */
+	Mono<OneTimeToken> generate(GenerateOneTimeTokenRequest request);
+
+	/**
+	 * Consumes a one-time token based on the provided authentication token.
+	 * @param authenticationToken the authentication token containing the one-time token
+	 * value to be consumed
+	 * @return the consumed {@link OneTimeToken} or empty Mono if the token is invalid
+	 */
+	Mono<OneTimeToken> consume(OneTimeTokenAuthenticationToken authenticationToken);
+
+}

+ 132 - 0
core/src/test/java/org/springframework/security/authentication/ott/reactive/InMemoryReactiveOneTimeTokenServiceTests.java

@@ -0,0 +1,132 @@
+/*
+ * Copyright 2002-2024 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.authentication.ott.reactive;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
+import org.springframework.security.authentication.ott.OneTimeToken;
+import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNoException;
+
+/**
+ * Tests for {@link InMemoryReactiveOneTimeTokenService}
+ *
+ * @author Max Batischev
+ */
+public class InMemoryReactiveOneTimeTokenServiceTests {
+
+	private final InMemoryReactiveOneTimeTokenService oneTimeTokenService = new InMemoryReactiveOneTimeTokenService();
+
+	private static final String USERNAME = "user";
+
+	private static final String TOKEN = "token";
+
+	@Test
+	void generateThenTokenValueShouldBeValidUuidAndProvidedUsernameIsUsed() {
+		GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME);
+
+		OneTimeToken oneTimeToken = this.oneTimeTokenService.generate(request).block();
+
+		assertThatNoException().isThrownBy(() -> UUID.fromString(oneTimeToken.getTokenValue()));
+		assertThat(oneTimeToken.getUsername()).isEqualTo(USERNAME);
+	}
+
+	@Test
+	void consumeWhenTokenDoesNotExistThenNull() {
+		OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken(TOKEN);
+
+		OneTimeToken oneTimeToken = this.oneTimeTokenService.consume(authenticationToken).block();
+
+		assertThat(oneTimeToken).isNull();
+	}
+
+	@Test
+	void consumeWhenTokenExistsThenReturnItself() {
+		GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME);
+		OneTimeToken generated = this.oneTimeTokenService.generate(request).block();
+		OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken(
+				generated.getTokenValue());
+
+		OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken).block();
+
+		assertThat(consumed.getTokenValue()).isEqualTo(generated.getTokenValue());
+		assertThat(consumed.getUsername()).isEqualTo(generated.getUsername());
+		assertThat(consumed.getExpiresAt()).isEqualTo(generated.getExpiresAt());
+	}
+
+	@Test
+	void consumeWhenTokenIsExpiredThenReturnNull() {
+		GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest(USERNAME);
+		OneTimeToken generated = this.oneTimeTokenService.generate(request).block();
+		OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken(
+				generated.getTokenValue());
+		Clock tenMinutesFromNow = Clock.fixed(Instant.now().plus(10, ChronoUnit.MINUTES), ZoneOffset.UTC);
+		this.oneTimeTokenService.setClock(tenMinutesFromNow);
+
+		OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken).block();
+
+		assertThat(consumed).isNull();
+	}
+
+	@Test
+	void generateWhenMoreThan100TokensThenClearExpired() {
+		// @formatter:off
+		List<OneTimeToken> toExpire = generate(50); // 50 tokens will expire in 5 minutes from now
+		Clock twoMinutesFromNow = Clock.fixed(Instant.now().plus(2, ChronoUnit.MINUTES), ZoneOffset.UTC);
+		this.oneTimeTokenService.setClock(twoMinutesFromNow);
+		List<OneTimeToken> toKeep = generate(50); // 50 tokens will expire in 7 minutes from now
+		Clock sixMinutesFromNow = Clock.fixed(Instant.now().plus(6, ChronoUnit.MINUTES), ZoneOffset.UTC);
+		this.oneTimeTokenService.setClock(sixMinutesFromNow);
+
+		assertThat(toExpire)
+				.extracting((token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getTokenValue()))
+								.block()
+				)
+				.containsOnlyNulls();
+
+		assertThat(toKeep)
+				.extracting((token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getTokenValue()))
+								.block()
+				)
+				.noneMatch(Objects::isNull);
+		// @formatter:on
+	}
+
+	private List<OneTimeToken> generate(int howMany) {
+		List<OneTimeToken> generated = new ArrayList<>(howMany);
+		for (int i = 0; i < howMany; i++) {
+			OneTimeToken oneTimeToken = this.oneTimeTokenService
+				.generate(new GenerateOneTimeTokenRequest("generated" + i))
+				.block();
+			generated.add(oneTimeToken);
+		}
+		return generated;
+	}
+
+}

+ 141 - 0
core/src/test/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManagerTests.java

@@ -0,0 +1,141 @@
+/*
+ * Copyright 2002-2024 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.authentication.ott.reactive;
+
+import java.time.Instant;
+import java.util.Collection;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.authentication.ReactiveAuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.authentication.ott.DefaultOneTimeToken;
+import org.springframework.security.authentication.ott.InvalidOneTimeTokenException;
+import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
+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.ReactiveUserDetailsService;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.util.CollectionUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link OneTimeTokenReactiveAuthenticationManager}
+ *
+ * @author Max Batischev
+ */
+public class OneTimeTokenReactiveAuthenticationManagerTests {
+
+	private ReactiveAuthenticationManager authenticationManager;
+
+	private static final String USERNAME = "user";
+
+	private static final String PASSWORD = "password";
+
+	private static final String TOKEN = "token";
+
+	@Test
+	public void constructorWhenOneTimeTokenServiceNullThenIllegalArgumentException() {
+		ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OneTimeTokenReactiveAuthenticationManager(null, userDetailsService));
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenUserDetailsServiceNullThenIllegalArgumentException() {
+		ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService, null));
+		// @formatter:on
+	}
+
+	@Test
+	void authenticateWhenOneTimeTokenAuthenticationTokenIsPresentThenSuccess() {
+		ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+		given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
+			.willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
+		ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
+		User testUser = new User(USERNAME, PASSWORD, AuthorityUtils.createAuthorityList("TEST"));
+		given(userDetailsService.findByUsername(eq(USERNAME))).willReturn(Mono.just(testUser));
+
+		this.authenticationManager = new OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService,
+				userDetailsService);
+
+		Authentication auth = this.authenticationManager
+			.authenticate(OneTimeTokenAuthenticationToken.unauthenticated(TOKEN))
+			.block();
+
+		OneTimeTokenAuthenticationToken token = (OneTimeTokenAuthenticationToken) auth;
+		UserDetails user = (UserDetails) token.getPrincipal();
+		Collection<GrantedAuthority> authorities = token.getAuthorities();
+
+		assertThat(user).isNotNull();
+		assertThat(user.getUsername()).isEqualTo(USERNAME);
+		assertThat(user.getPassword()).isEqualTo(PASSWORD);
+		assertThat(token.isAuthenticated()).isTrue();
+		assertThat(CollectionUtils.isEmpty(authorities)).isFalse();
+	}
+
+	@Test
+	void authenticateWhenInvalidOneTimeTokenAuthenticationTokenIsPresentThenFail() {
+		ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+		given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
+			.willReturn(Mono.empty());
+		ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
+
+		this.authenticationManager = new OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService,
+				userDetailsService);
+
+		// @formatter:off
+		assertThatExceptionOfType(InvalidOneTimeTokenException.class)
+				.isThrownBy(() -> this.authenticationManager.authenticate(OneTimeTokenAuthenticationToken.unauthenticated(TOKEN))
+						.block());
+		// @formatter:on
+	}
+
+	@Test
+	void authenticateWhenIncorrectTypeOfAuthenticationIsPresentThenFail() {
+		ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+		given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
+			.willReturn(Mono.empty());
+		ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
+
+		this.authenticationManager = new OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService,
+				userDetailsService);
+
+		// @formatter:off
+		Authentication authentication = this.authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(USERNAME, PASSWORD))
+				.block();
+		// @formatter:on
+
+		assertThat(authentication).isNull();
+	}
+
+}

+ 341 - 0
docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc

@@ -0,0 +1,341 @@
+[[one-time-token-login]]
+= One-Time Token Login
+
+Spring Security offers support for One-Time Token (OTT) authentication via the `oneTimeTokenLogin()` DSL.
+Before diving into implementation details, it's important to clarify the scope of the OTT feature within the framework, highlighting what is supported and what isn't.
+
+== Understanding One-Time Tokens vs. One-Time Passwords
+
+It's common to confuse One-Time Tokens (OTT) with https://en.wikipedia.org/wiki/One-time_password[One-Time Passwords] (OTP), but in Spring Security, these concepts differ in several key ways.
+For clarity, we'll assume OTP refers to https://en.wikipedia.org/wiki/Time-based_one-time_password[TOTP] (Time-Based One-Time Password) or https://en.wikipedia.org/wiki/HMAC-based_one-time_password[HOTP] (HMAC-Based One-Time Password).
+
+=== Setup Requirements
+
+- OTT: No initial setup is required. The user doesn't need to configure anything in advance.
+- OTP: Typically requires setup, such as generating and sharing a secret key with an external tool to produce the one-time passwords.
+
+=== Token Delivery
+
+- OTT: Usually a custom javadoc:org.springframework.security.web.server.authentication.ott.ServerGeneratedOneTimeTokenHandler[] must be implemented, responsible for delivering the token to the end user.
+- OTP: The token is often generated by an external tool, so there's no need to send it to the user via the application.
+
+=== Token Generation
+
+- OTT: The javadoc:org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService#generate(org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest)[] method requires a javadoc:org.springframework.security.authentication.ott.OneTimeToken[], wrapped in Mono, to be returned, emphasizing server-side generation.
+- OTP: The token is not necessarily generated on the server side, it's often created by the client using the shared secret.
+
+In summary, One-Time Tokens (OTT) provide a way to authenticate users without additional account setup, differentiating them from One-Time Passwords (OTP), which typically involve a more complex setup process and rely on external tools for token generation.
+
+The One-Time Token Login works in two major steps.
+
+1. User requests a token by submitting their user identifier, usually the username, and the token is delivered to them, often as a Magic Link, via e-mail, SMS, etc.
+2. User submits the token to the one-time token login endpoint and, if valid, the user gets logged in.
+
+In the following sections we will explore how to configure OTT Login for your needs.
+
+- <<default-pages,Understanding the integration with the default generated login page>>
+- <<sending-token-to-user,Sending the token to the user>>
+- <<changing-submit-page-url,Configuring the One-Time Token submit page>>
+- <<changing-generate-url,Changing the One-Time Token generate URL>>
+- <<customize-generate-consume-token,Customize how to generate and consume tokens>>
+
+[[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.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter[] to generate a default One-Time Token submit page.
+
+[[sending-token-to-user]]
+== Sending the Token to the User
+
+It is not possible for Spring Security to reasonably determine the way the token should be delivered to your users.
+Therefore, a custom javadoc:org.springframework.security.web.server.authentication.ott.ServerGeneratedOneTimeTokenHandler[] must be provided to deliver the token to the user based on your needs.
+One of the most common delivery strategies is a Magic Link, via e-mail, SMS, etc.
+In the following example, we are going to create a magic link and sent it to the user's email.
+
+.One-Time Token Login Configuration
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Configuration
+@EnableWebFluxSecurity
+public class SecurityConfig {
+
+    @Bean
+    public SecurityWebFilterChain filterChain(ServerHttpSecurity http, MagicLinkGeneratedOneTimeTokenHandler magicLinkSender) {
+        http
+            // ...
+            .formLogin(Customizer.withDefaults())
+            .oneTimeTokenLogin(Customizer.withDefaults());
+        return http.build();
+    }
+
+}
+
+import org.springframework.mail.SimpleMailMessage;
+import org.springframework.mail.javamail.JavaMailSender;
+
+@Component <1>
+public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
+
+    private final MailSender mailSender;
+
+    private final ServerGeneratedOneTimeTokenHandler redirectHandler = new ServerRedirectGeneratedOneTimeTokenHandler("/ott/sent");
+
+    // constructor omitted
+
+    @Override
+    public Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken) {
+        return Mono.just(exchange.getRequest())
+				.map((request) ->
+					UriComponentsBuilder.fromUri(request.getURI())
+						.replacePath(request.getPath().contextPath().value())
+						.replaceQuery(null)
+						.fragment(null)
+						.path("/login/ott")
+						.queryParam("token", oneTimeToken.getTokenValue())
+						.toUriString() <2>
+					)
+			.flatMap((uri) -> this.mailSender.send(getUserEmail(oneTimeToken.getUsername()), <3>
+					"Use the following link to sign in into the application: " + magicLink)) <4>
+			.then(this.redirectHandler.handle(exchange, oneTimeToken)); <5>
+    }
+
+    private String getUserEmail() {
+        // ...
+    }
+
+}
+
+@Controller
+class PageController {
+
+    @GetMapping("/ott/sent")
+    String ottSent() {
+        return "my-template";
+    }
+
+}
+----
+======
+
+<1> Make the `MagicLinkGeneratedOneTimeTokenHandler` a Spring bean
+<2> Create a login processing URL with the `token` as a query param
+<3> Retrieve the user's email based on the username
+<4> Use the `JavaMailSender` API to send the email to the user with the magic link
+<5> Use the `ServerRedirectGeneratedOneTimeTokenHandler` to perform a redirect to your desired URL
+
+The email content will look similar to:
+
+> Use the following link to sign in into the application: \http://localhost:8080/login/ott?token=a830c444-29d8-4d98-9b46-6aba7b22fe5b
+
+The default submit page will detect that the URL has the `token` query param and will automatically fill the form field with the token value.
+
+[[changing-generate-url]]
+== Changing the One-Time Token Generate URL
+
+By default, the javadoc:org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter[] listens to `POST /ott/generate` requests.
+That URL can be changed by using the `generateTokenUrl(String)` DSL method:
+
+.Changing the Generate URL
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Configuration
+@EnableWebFluxSecurity
+public class SecurityConfig {
+
+    @Bean
+    public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
+        http
+            // ...
+            .formLogin(Customizer.withDefaults())
+            .oneTimeTokenLogin((ott) -> ott
+                .generateTokenUrl("/ott/my-generate-url")
+            );
+        return http.build();
+    }
+
+}
+
+@Component
+public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
+    // ...
+}
+----
+======
+
+[[changing-submit-page-url]]
+== Changing the Default Submit Page URL
+
+The default One-Time Token submit page is generated by the javadoc:org.springframework.security.web.server.ui.OneTimeTokenSubmitPageGeneratingWebFilter[] and listens to `GET /login/ott`.
+The URL can also be changed, like so:
+
+.Configuring the Default Submit Page URL
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Configuration
+@EnableWebFluxSecurity
+public class SecurityConfig {
+
+    @Bean
+    public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
+        http
+            // ...
+            .formLogin(Customizer.withDefaults())
+            .oneTimeTokenLogin((ott) -> ott
+                .submitPageUrl("/ott/submit")
+            );
+        return http.build();
+    }
+
+}
+
+@Component
+public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
+    // ...
+}
+----
+======
+
+[[disabling-default-submit-page]]
+== Disabling the Default Submit Page
+
+If you want to use your own One-Time Token submit page, you can disable the default page and then provide your own endpoint.
+
+.Disabling the Default Submit Page
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Configuration
+@EnableWebFluxSecurity
+public class SecurityConfig {
+
+    @Bean
+    public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
+        http
+            .authorizeExchange((authorize) -> authorize
+                .pathMatchers("/my-ott-submit").permitAll()
+                .anyExchange().authenticated()
+            )
+            .formLogin(Customizer.withDefaults())
+            .oneTimeTokenLogin((ott) -> ott
+                .showDefaultSubmitPage(false)
+            );
+        return http.build();
+    }
+
+}
+
+@Controller
+public class MyController {
+
+    @GetMapping("/my-ott-submit")
+    public String ottSubmitPage() {
+        return "my-ott-submit";
+    }
+
+}
+
+@Component
+public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
+    // ...
+}
+----
+======
+
+[[customize-generate-consume-token]]
+== Customize How to Generate and Consume One-Time Tokens
+
+The interface that define the common operations for generating and consuming one-time tokens is the javadoc:org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService[].
+Spring Security uses the javadoc:org.springframework.security.authentication.ott.reactive.InMemoryReactiveOneTimeTokenService[] as the default implementation of that interface, if none is provided.
+
+Some of the most common reasons to customize the `ReactiveOneTimeTokenService` are, but not limited to:
+
+- Changing the one-time token expire time
+- Storing more information from the generate token request
+- Changing how the token value is created
+- Additional validation when consuming a one-time token
+
+There are two options to customize the `ReactiveOneTimeTokenService`.
+One option is to provide it as a bean, so it can be automatically be picked-up by the `oneTimeTokenLogin()` DSL:
+
+.Passing the ReactiveOneTimeTokenService as a Bean
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Configuration
+@EnableWebFluxSecurity
+public class SecurityConfig {
+
+    @Bean
+    public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
+        http
+            // ...
+            .formLogin(Customizer.withDefaults())
+            .oneTimeTokenLogin(Customizer.withDefaults());
+        return http.build();
+    }
+
+    @Bean
+    public ReactiveOneTimeTokenService oneTimeTokenService() {
+        return new MyCustomReactiveOneTimeTokenService();
+    }
+
+}
+
+@Component
+public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
+    // ...
+}
+----
+======
+
+The second option is to pass the `ReactiveOneTimeTokenService` instance to the DSL, which is useful if there are multiple ``SecurityWebFilterChain``s and a different ``ReactiveOneTimeTokenService``s is needed for each of them.
+
+.Passing the ReactiveOneTimeTokenService using the DSL
+[tabs]
+======
+Java::
++
+[source,java,role="primary"]
+----
+@Configuration
+@EnableWebFluxSecurity
+public class SecurityConfig {
+
+    @Bean
+    public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
+        http
+            // ...
+            .formLogin(Customizer.withDefaults())
+            .oneTimeTokenLogin((ott) -> ott
+                .oneTimeTokenService(new MyCustomReactiveOneTimeTokenService())
+            );
+        return http.build();
+    }
+
+}
+
+@Component
+public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
+    // ...
+}
+----
+======

+ 78 - 0
web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java

@@ -0,0 +1,78 @@
+/*
+ * Copyright 2002-2024 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.server.authentication.ott;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
+import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
+import org.springframework.util.Assert;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+
+/**
+ * {@link WebFilter} implementation that process a One-Time Token generation request.
+ *
+ * @author Max Batischev
+ * @since 6.4
+ * @see ReactiveOneTimeTokenService
+ */
+public final class GenerateOneTimeTokenWebFilter implements WebFilter {
+
+	private static final String USERNAME = "username";
+
+	private final ReactiveOneTimeTokenService oneTimeTokenService;
+
+	private ServerWebExchangeMatcher matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/ott/generate");
+
+	private final ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler;
+
+	public GenerateOneTimeTokenWebFilter(ReactiveOneTimeTokenService oneTimeTokenService,
+			ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler) {
+		Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null");
+		Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null");
+		this.generatedOneTimeTokenHandler = generatedOneTimeTokenHandler;
+		this.oneTimeTokenService = oneTimeTokenService;
+	}
+
+	@Override
+	public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
+		// @formatter:off
+		return this.matcher.matches(exchange)
+				.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
+				.flatMap((mathResult) -> exchange.getFormData())
+				.flatMap((data) -> Mono.justOrEmpty(data.getFirst(USERNAME)))
+				.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
+				.flatMap((username) -> this.oneTimeTokenService.generate(new GenerateOneTimeTokenRequest(username)))
+				.flatMap((token) -> this.generatedOneTimeTokenHandler.handle(exchange, token));
+		// @formatter:on
+	}
+
+	/**
+	 * Use the given {@link ServerWebExchangeMatcher} to match the request.
+	 * @param matcher
+	 */
+	public void setRequestMatcher(ServerWebExchangeMatcher matcher) {
+		Assert.notNull(matcher, "matcher cannot be null");
+		this.matcher = matcher;
+	}
+
+}

+ 41 - 0
web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerGeneratedOneTimeTokenHandler.java

@@ -0,0 +1,41 @@
+/*
+ * Copyright 2002-2024 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.server.authentication.ott;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.authentication.ott.OneTimeToken;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * Defines a reactive strategy to handle generated one-time tokens.
+ *
+ * @author Max Batischev
+ * @since 6.4
+ */
+@FunctionalInterface
+public interface ServerGeneratedOneTimeTokenHandler {
+
+	/**
+	 * Handles generated one-time tokens
+	 * @param exchange the {@link ServerWebExchange} to use
+	 * @param oneTimeToken the {@link OneTimeToken} to handle
+	 * @return a completion handling (success or error)
+	 */
+	Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken);
+
+}

+ 77 - 0
web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerOneTimeTokenAuthenticationConverter.java

@@ -0,0 +1,77 @@
+/*
+ * Copyright 2002-2024 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.server.authentication.ott;
+
+import java.util.List;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.web.server.authentication.ServerAuthenticationConverter;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * An implementation of {@link ServerAuthenticationConverter} for resolving
+ * {@link OneTimeTokenAuthenticationToken} from token parameter.
+ *
+ * @author Max Batischev
+ * @since 6.4
+ * @see GenerateOneTimeTokenWebFilter
+ */
+public final class ServerOneTimeTokenAuthenticationConverter implements ServerAuthenticationConverter {
+
+	private static final String TOKEN = "token";
+
+	@Override
+	public Mono<Authentication> convert(ServerWebExchange exchange) {
+		Assert.notNull(exchange, "exchange cannot be null");
+		if (isFormEncodedRequest(exchange.getRequest())) {
+			return exchange.getFormData()
+				.map((data) -> OneTimeTokenAuthenticationToken.unauthenticated(data.getFirst(TOKEN)));
+		}
+		String token = resolveTokenFromRequest(exchange.getRequest());
+		if (!StringUtils.hasText(token)) {
+			return Mono.empty();
+		}
+		return Mono.just(OneTimeTokenAuthenticationToken.unauthenticated(token));
+	}
+
+	private String resolveTokenFromRequest(ServerHttpRequest request) {
+		List<String> parameterTokens = request.getQueryParams().get(TOKEN);
+		if (CollectionUtils.isEmpty(parameterTokens)) {
+			return null;
+		}
+		if (parameterTokens.size() == 1) {
+			return parameterTokens.get(0);
+		}
+		return null;
+	}
+
+	private boolean isFormEncodedRequest(ServerHttpRequest request) {
+		return HttpMethod.POST.equals(request.getMethod()) && MediaType.APPLICATION_FORM_URLENCODED_VALUE
+			.equals(request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE));
+	}
+
+}

+ 52 - 0
web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerRedirectGeneratedOneTimeTokenHandler.java

@@ -0,0 +1,52 @@
+/*
+ * Copyright 2002-2024 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.server.authentication.ott;
+
+import java.net.URI;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.security.authentication.ott.OneTimeToken;
+import org.springframework.security.web.server.DefaultServerRedirectStrategy;
+import org.springframework.security.web.server.ServerRedirectStrategy;
+import org.springframework.util.Assert;
+import org.springframework.web.server.ServerWebExchange;
+
+/**
+ * A {@link ServerGeneratedOneTimeTokenHandler} that performs a redirect to a specific
+ * location
+ *
+ * @author Max Batischev
+ * @since 6.4
+ */
+public final class ServerRedirectGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler {
+
+	private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();
+
+	private final URI redirectUri;
+
+	public ServerRedirectGeneratedOneTimeTokenHandler(String redirectUri) {
+		Assert.hasText(redirectUri, "redirectUri cannot be empty or null");
+		this.redirectUri = URI.create(redirectUri);
+	}
+
+	@Override
+	public Mono<Void> handle(ServerWebExchange exchange, OneTimeToken oneTimeToken) {
+		return this.redirectStrategy.sendRedirect(exchange, this.redirectUri);
+	}
+
+}

+ 55 - 0
web/src/main/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilter.java

@@ -34,6 +34,7 @@ import org.springframework.security.web.server.util.matcher.ServerWebExchangeMat
 import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
 import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
 import org.springframework.util.Assert;
 import org.springframework.util.Assert;
 import org.springframework.util.MultiValueMap;
 import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
 import org.springframework.web.server.ServerWebExchange;
 import org.springframework.web.server.ServerWebExchange;
 import org.springframework.web.server.WebFilter;
 import org.springframework.web.server.WebFilter;
 import org.springframework.web.server.WebFilterChain;
 import org.springframework.web.server.WebFilterChain;
@@ -52,10 +53,32 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
 
 
 	private boolean formLoginEnabled;
 	private boolean formLoginEnabled;
 
 
+	private boolean oneTimeTokenEnabled = false;
+
+	private String generateOneTimeTokenUrl;
+
+	/**
+	 * Specifies the URL that a One-Time Token generate request will be processed.
+	 * @param generateOneTimeTokenUrl
+	 * @since 6.4
+	 */
+	public void setGenerateOneTimeTokenUrl(String generateOneTimeTokenUrl) {
+		Assert.isTrue(StringUtils.hasText(generateOneTimeTokenUrl), "generateOneTimeTokenUrl cannot be null or empty");
+		this.generateOneTimeTokenUrl = generateOneTimeTokenUrl;
+	}
+
 	public void setFormLoginEnabled(boolean enabled) {
 	public void setFormLoginEnabled(boolean enabled) {
 		this.formLoginEnabled = enabled;
 		this.formLoginEnabled = enabled;
 	}
 	}
 
 
+	/**
+	 * Set if one-time token login is supported. Defaults to {@code false}.
+	 * @param oneTimeTokenEnabled
+	 */
+	public void setOneTimeTokenEnabled(boolean oneTimeTokenEnabled) {
+		this.oneTimeTokenEnabled = oneTimeTokenEnabled;
+	}
+
 	public void setOauth2AuthenticationUrlToClientName(Map<String, String> oauth2AuthenticationUrlToClientName) {
 	public void setOauth2AuthenticationUrlToClientName(Map<String, String> oauth2AuthenticationUrlToClientName) {
 		Assert.notNull(oauth2AuthenticationUrlToClientName, "oauth2AuthenticationUrlToClientName cannot be null");
 		Assert.notNull(oauth2AuthenticationUrlToClientName, "oauth2AuthenticationUrlToClientName cannot be null");
 		this.oauth2AuthenticationUrlToClientName = oauth2AuthenticationUrlToClientName;
 		this.oauth2AuthenticationUrlToClientName = oauth2AuthenticationUrlToClientName;
@@ -92,6 +115,7 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
 		return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
 		return HtmlTemplates.fromTemplate(LOGIN_PAGE_TEMPLATE)
 			.withRawHtml("contextPath", contextPath)
 			.withRawHtml("contextPath", contextPath)
 			.withRawHtml("formLogin", formLogin(queryParams, contextPath, csrfTokenHtmlInput))
 			.withRawHtml("formLogin", formLogin(queryParams, contextPath, csrfTokenHtmlInput))
+			.withRawHtml("oneTimeTokenLogin", renderOneTimeTokenLogin(queryParams, contextPath, csrfTokenHtmlInput))
 			.withRawHtml("oauth2Login", oauth2Login(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName))
 			.withRawHtml("oauth2Login", oauth2Login(queryParams, contextPath, this.oauth2AuthenticationUrlToClientName))
 			.render()
 			.render()
 			.getBytes(Charset.defaultCharset());
 			.getBytes(Charset.defaultCharset());
@@ -113,6 +137,23 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
 			.render();
 			.render();
 	}
 	}
 
 
+	private String renderOneTimeTokenLogin(MultiValueMap<String, String> queryParams, String contextPath,
+			String csrfTokenHtmlInput) {
+		if (!this.oneTimeTokenEnabled) {
+			return "";
+		}
+
+		boolean isError = queryParams.containsKey("error");
+		boolean isLogoutSuccess = queryParams.containsKey("logout");
+
+		return HtmlTemplates.fromTemplate(ONE_TIME_TEMPLATE)
+			.withValue("generateOneTimeTokenUrl", contextPath + this.generateOneTimeTokenUrl)
+			.withRawHtml("errorMessage", createError(isError))
+			.withRawHtml("logoutMessage", createLogoutSuccess(isLogoutSuccess))
+			.withRawHtml("csrf", csrfTokenHtmlInput)
+			.render();
+	}
+
 	private static String oauth2Login(MultiValueMap<String, String> queryParams, String contextPath,
 	private static String oauth2Login(MultiValueMap<String, String> queryParams, String contextPath,
 			Map<String, String> oauth2AuthenticationUrlToClientName) {
 			Map<String, String> oauth2AuthenticationUrlToClientName) {
 		if (oauth2AuthenticationUrlToClientName.isEmpty()) {
 		if (oauth2AuthenticationUrlToClientName.isEmpty()) {
@@ -168,6 +209,7 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
 			  <body>
 			  <body>
 			    <div class="content">
 			    <div class="content">
 			{{formLogin}}
 			{{formLogin}}
+			{{oneTimeTokenLogin}}
 			{{oauth2Login}}
 			{{oauth2Login}}
 			    </div>
 			    </div>
 			  </body>
 			  </body>
@@ -203,4 +245,17 @@ public class LoginPageGeneratingWebFilter implements WebFilter {
 	private static final String OAUTH2_ROW_TEMPLATE = """
 	private static final String OAUTH2_ROW_TEMPLATE = """
 			<tr><td><a href="{{url}}">{{clientName}}</a></td></tr>""";
 			<tr><td><a href="{{url}}">{{clientName}}</a></td></tr>""";
 
 
+	private static final String ONE_TIME_TEMPLATE = """
+			      <form id="ott-form" class="login-form" method="post" action="{{generateOneTimeTokenUrl}}">
+			        <h2>Request a One-Time Token</h2>
+			      {{errorMessage}}{{logoutMessage}}
+			        <p>
+			          <label for="ott-username" class="screenreader">Username</label>
+			          <input type="text" id="ott-username" name="username" placeholder="Username" required>
+			        </p>
+			        {{csrf}}
+			        <button class="primary" type="submit" form="ott-form">Send Token</button>
+			      </form>
+			""";
+
 }
 }

+ 150 - 0
web/src/main/java/org/springframework/security/web/server/ui/OneTimeTokenSubmitPageGeneratingWebFilter.java

@@ -0,0 +1,150 @@
+/*
+ * Copyright 2002-2024 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.server.ui;
+
+import java.nio.charset.Charset;
+
+import reactor.core.publisher.Mono;
+
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferFactory;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.security.web.server.csrf.CsrfToken;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
+import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
+import org.springframework.util.Assert;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+import org.springframework.web.server.ServerWebExchange;
+import org.springframework.web.server.WebFilter;
+import org.springframework.web.server.WebFilterChain;
+
+/**
+ * Creates a default one-time token submit page. If the request contains a {@code token}
+ * query param the page will automatically fill the form with the token value.
+ *
+ * @author Max Batischev
+ * @since 6.4
+ */
+public final class OneTimeTokenSubmitPageGeneratingWebFilter implements WebFilter {
+
+	private ServerWebExchangeMatcher matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/login/ott");
+
+	private String loginProcessingUrl = "/login/ott";
+
+	@Override
+	public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
+		return this.matcher.matches(exchange)
+			.filter(ServerWebExchangeMatcher.MatchResult::isMatch)
+			.switchIfEmpty(chain.filter(exchange).then(Mono.empty()))
+			.flatMap((matchResult) -> render(exchange));
+	}
+
+	private Mono<Void> render(ServerWebExchange exchange) {
+		ServerHttpResponse result = exchange.getResponse();
+		result.setStatusCode(HttpStatus.OK);
+		result.getHeaders().setContentType(MediaType.TEXT_HTML);
+		return result.writeWith(createBuffer(exchange));
+	}
+
+	private Mono<DataBuffer> createBuffer(ServerWebExchange exchange) {
+		Mono<CsrfToken> token = exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty());
+		return token.map(OneTimeTokenSubmitPageGeneratingWebFilter::csrfToken)
+			.defaultIfEmpty("")
+			.map((csrfTokenHtmlInput) -> {
+				byte[] bytes = createPage(exchange, csrfTokenHtmlInput);
+				DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
+				return bufferFactory.wrap(bytes);
+			});
+	}
+
+	private byte[] createPage(ServerWebExchange exchange, String csrfTokenHtmlInput) {
+		MultiValueMap<String, String> queryParams = exchange.getRequest().getQueryParams();
+		String token = queryParams.getFirst("token");
+		String tokenValue = StringUtils.hasText(token) ? token : "";
+
+		String contextPath = exchange.getRequest().getPath().contextPath().value();
+
+		return HtmlTemplates.fromTemplate(ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE)
+			.withRawHtml("contextPath", contextPath)
+			.withValue("tokenValue", tokenValue)
+			.withRawHtml("csrf", csrfTokenHtmlInput.indent(8))
+			.withValue("loginProcessingUrl", contextPath + this.loginProcessingUrl)
+			.render()
+			.getBytes(Charset.defaultCharset());
+	}
+
+	private static String csrfToken(CsrfToken token) {
+		return HtmlTemplates.fromTemplate(CSRF_INPUT_TEMPLATE)
+			.withValue("name", token.getParameterName())
+			.withValue("value", token.getToken())
+			.render();
+	}
+
+	/**
+	 * Use this {@link ServerWebExchangeMatcher} to choose whether this filter will handle
+	 * the request. By default, it handles {@code /login/ott}.
+	 * @param requestMatcher {@link ServerWebExchangeMatcher} to use
+	 */
+	public void setRequestMatcher(ServerWebExchangeMatcher requestMatcher) {
+		Assert.notNull(requestMatcher, "requestMatcher cannot be null");
+		this.matcher = requestMatcher;
+	}
+
+	/**
+	 * Specifies the URL that the submit form should POST to. Defaults to
+	 * {@code /login/ott}.
+	 * @param loginProcessingUrl
+	 */
+	public void setLoginProcessingUrl(String loginProcessingUrl) {
+		Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty");
+		this.loginProcessingUrl = loginProcessingUrl;
+	}
+
+	private static final String ONE_TIME_TOKEN_SUBMIT_PAGE_TEMPLATE = """
+			<!DOCTYPE html>
+			<html lang="en">
+			  <head>
+			    <title>One-Time Token Login</title>
+			    <meta charset="utf-8"/>
+			    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
+			    <link href="{{contextPath}}/default-ui.css" rel="stylesheet" />
+			  </head>
+			  <body>
+			    <div class="container">
+			      <form class="login-form" action="{{loginProcessingUrl}}" method="post">
+			        <h2>Please input the token</h2>
+			        <p>
+			          <label for="token" class="screenreader">Token</label>
+			          <input type="text" id="token" name="token" value="{{tokenValue}}" placeholder="Token" required="true" autofocus="autofocus"/>
+			        </p>
+			{{csrf}}
+			        <button class="primary" type="submit">Sign in</button>
+			      </form>
+			    </div>
+			  </body>
+			</html>
+			""";
+
+	private static final String CSRF_INPUT_TEMPLATE = """
+			<input name="{{name}}" type="hidden" value="{{value}}" />
+			""";
+
+}

+ 103 - 0
web/src/test/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilterTests.java

@@ -0,0 +1,103 @@
+/*
+ * Copyright 2002-2024 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.server.authentication.ott;
+
+import java.time.Instant;
+
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import reactor.core.publisher.Mono;
+
+import org.springframework.http.MediaType;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.authentication.ott.DefaultOneTimeToken;
+import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
+import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService;
+
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link GenerateOneTimeTokenWebFilter}
+ *
+ * @author Max Batischev
+ */
+public class GenerateOneTimeTokenWebFilterTests {
+
+	private final ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+
+	private final ServerRedirectGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler = new ServerRedirectGeneratedOneTimeTokenHandler(
+			"/login/ott");
+
+	private static final String TOKEN = "token";
+
+	private static final String USERNAME = "user";
+
+	@Test
+	void filterWhenUsernameFormParamIsPresentThenSuccess() {
+		given(this.oneTimeTokenService.generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class)))
+			.willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
+		MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/ott/generate")
+			.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+			.body("username=user"));
+		GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService,
+				this.generatedOneTimeTokenHandler);
+
+		filter.filter(exchange, (e) -> Mono.empty()).block();
+
+		verify(this.oneTimeTokenService).generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class));
+		Assertions.assertThat(exchange.getResponse().getHeaders().getLocation()).hasPath("/login/ott");
+	}
+
+	@Test
+	void filterWhenUsernameFormParamIsEmptyThenNull() {
+		given(this.oneTimeTokenService.generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class)))
+			.willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
+		MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.post("/ott/generate");
+		MockServerWebExchange exchange = MockServerWebExchange.from(request);
+		GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService,
+				this.generatedOneTimeTokenHandler);
+
+		filter.filter(exchange, (e) -> Mono.empty()).block();
+
+		verify(this.oneTimeTokenService, never()).generate(ArgumentMatchers.any(GenerateOneTimeTokenRequest.class));
+	}
+
+	@Test
+	public void constructorWhenOneTimeTokenServiceNullThenIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new GenerateOneTimeTokenWebFilter(null, this.generatedOneTimeTokenHandler));
+		// @formatter:on
+	}
+
+	@Test
+	public void setWhenRequestMatcherNullThenIllegalArgumentException() {
+		GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService,
+				this.generatedOneTimeTokenHandler);
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> filter.setRequestMatcher(null));
+		// @formatter:on
+	}
+
+}

+ 93 - 0
web/src/test/java/org/springframework/security/web/server/authentication/ott/ServerOneTimeTokenAuthenticationConverterTests.java

@@ -0,0 +1,93 @@
+/*
+ * Copyright 2002-2024 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.server.authentication.ott;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
+import org.springframework.security.core.Authentication;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ServerOneTimeTokenAuthenticationConverter}
+ *
+ * @author Max Batischev
+ */
+public class ServerOneTimeTokenAuthenticationConverterTests {
+
+	private final ServerOneTimeTokenAuthenticationConverter converter = new ServerOneTimeTokenAuthenticationConverter();
+
+	private static final String TOKEN = "token";
+
+	private static final String USERNAME = "Max";
+
+	@Test
+	void convertWhenTokenParameterThenReturnOneTimeTokenAuthenticationToken() {
+		MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/").queryParam("token", TOKEN);
+
+		OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter
+			.convert(MockServerWebExchange.from(request))
+			.block();
+
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getTokenValue()).isEqualTo(TOKEN);
+		assertThat(authentication.getPrincipal()).isNull();
+	}
+
+	@Test
+	void convertWhenOnlyUsernameParameterThenReturnNull() {
+		MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/").queryParam("username", USERNAME);
+
+		OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter
+			.convert(MockServerWebExchange.from(request))
+			.block();
+
+		assertThat(authentication).isNull();
+	}
+
+	@Test
+	void convertWhenNoTokenParameterThenNull() {
+		MockServerHttpRequest.BaseBuilder<?> request = MockServerHttpRequest.get("/");
+
+		Authentication authentication = this.converter.convert(MockServerWebExchange.from(request)).block();
+
+		assertThat(authentication).isNull();
+	}
+
+	@Test
+	void convertWhenTokenEncodedFormParameterThenReturnOneTimeTokenAuthenticationToken() {
+		// @formatter:off
+		MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/")
+				.contentType(MediaType.APPLICATION_FORM_URLENCODED)
+				.body("token=token"));
+
+		// @formatter:on
+
+		OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter
+			.convert(exchange)
+			.block();
+
+		assertThat(authentication).isNotNull();
+		assertThat(authentication.getTokenValue()).isEqualTo(TOKEN);
+		assertThat(authentication.getPrincipal()).isNull();
+	}
+
+}

+ 74 - 0
web/src/test/java/org/springframework/security/web/server/authentication/ott/ServerRedirectGeneratedOneTimeTokenHandlerTests.java

@@ -0,0 +1,74 @@
+/*
+ * Copyright 2002-2024 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.server.authentication.ott;
+
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.security.authentication.ott.DefaultOneTimeToken;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link ServerRedirectGeneratedOneTimeTokenHandler}
+ *
+ * @author Max Batischev
+ */
+public class ServerRedirectGeneratedOneTimeTokenHandlerTests {
+
+	private static final String TOKEN = "token";
+
+	private static final String USERNAME = "Max";
+
+	private final MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
+
+	@Test
+	void handleThenRedirectToDefaultLocation() {
+		ServerGeneratedOneTimeTokenHandler handler = new ServerRedirectGeneratedOneTimeTokenHandler("/login/ott");
+		MockServerWebExchange webExchange = MockServerWebExchange.from(this.request);
+
+		handler.handle(webExchange, new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())).block();
+
+		assertThat(webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FOUND);
+		assertThat(webExchange.getResponse().getHeaders().getLocation()).hasPath("/login/ott");
+	}
+
+	@Test
+	void handleWhenUrlChangedThenRedirectToUrl() {
+		ServerGeneratedOneTimeTokenHandler handler = new ServerRedirectGeneratedOneTimeTokenHandler("/redirected");
+		MockServerWebExchange webExchange = MockServerWebExchange.from(this.request);
+
+		handler.handle(webExchange, new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())).block();
+
+		assertThat(webExchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FOUND);
+		assertThat(webExchange.getResponse().getHeaders().getLocation()).hasPath("/redirected");
+	}
+
+	@Test
+	void setRedirectUrlWhenNullOrEmptyThenException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new ServerRedirectGeneratedOneTimeTokenHandler(null))
+			.withMessage("redirectUri cannot be empty or null");
+		assertThatIllegalArgumentException().isThrownBy(() -> new ServerRedirectGeneratedOneTimeTokenHandler(""))
+			.withMessage("redirectUri cannot be empty or null");
+	}
+
+}

+ 17 - 0
web/src/test/java/org/springframework/security/web/server/ui/LoginPageGeneratingWebFilterTests.java

@@ -84,6 +84,7 @@ public class LoginPageGeneratingWebFilterTests {
 
 
 				        <button type="submit" class="primary">Sign in</button>
 				        <button type="submit" class="primary">Sign in</button>
 				      </form>
 				      </form>
+
 				<h2>Login with OAuth 2.0</h2>
 				<h2>Login with OAuth 2.0</h2>
 
 
 				<table class="table table-striped">
 				<table class="table table-striped">
@@ -94,4 +95,20 @@ public class LoginPageGeneratingWebFilterTests {
 				</html>""");
 				</html>""");
 	}
 	}
 
 
+	@Test
+	public void filterWhenOneTimeTokenLoginThenOttForm() {
+		LoginPageGeneratingWebFilter filter = new LoginPageGeneratingWebFilter();
+		filter.setOneTimeTokenEnabled(true);
+		filter.setGenerateOneTimeTokenUrl("/ott/authenticate");
+		filter.setFormLoginEnabled(true);
+		MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/login"));
+
+		filter.filter(exchange, (e) -> Mono.empty()).block();
+
+		assertThat(exchange.getResponse().getBodyAsString().block()).contains("Request a One-Time Token");
+		assertThat(exchange.getResponse().getBodyAsString().block()).contains("""
+				 <form id="ott-form" class="login-form" method="post" action="/ott/authenticate">
+				""");
+	}
+
 }
 }

+ 129 - 0
web/src/test/java/org/springframework/security/web/server/ui/OneTimeTokenSubmitPageGeneratingWebFilterTests.java

@@ -0,0 +1,129 @@
+/*
+ * Copyright 2002-2024 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.server.ui;
+
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OneTimeTokenSubmitPageGeneratingWebFilter}
+ *
+ * @author Max Batischev
+ */
+public class OneTimeTokenSubmitPageGeneratingWebFilterTests {
+
+	private final OneTimeTokenSubmitPageGeneratingWebFilter filter = new OneTimeTokenSubmitPageGeneratingWebFilter();
+
+	@Test
+	void filterWhenTokenQueryParamThenShouldIncludeJavascriptToAutoSubmitFormAndInputHasTokenValue() {
+		MockServerWebExchange exchange = MockServerWebExchange
+			.from(MockServerHttpRequest.get("/login/ott").queryParam("token", "test"));
+
+		this.filter.filter(exchange, (e) -> Mono.empty()).block();
+
+		assertThat(exchange.getResponse().getBodyAsString().block()).contains(
+				"<input type=\"text\" id=\"token\" name=\"token\" value=\"test\" placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>");
+	}
+
+	@Test
+	void setRequestMatcherWhenNullThenException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setRequestMatcher(null));
+	}
+
+	@Test
+	void setLoginProcessingUrlWhenNullOrEmptyThenException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setLoginProcessingUrl(null));
+		assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setLoginProcessingUrl(""));
+	}
+
+	@Test
+	void setLoginProcessingUrlThenUseItForFormAction() {
+		this.filter.setLoginProcessingUrl("/login/another");
+
+		MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/login/ott"));
+
+		this.filter.filter(exchange, (e) -> Mono.empty()).block();
+
+		assertThat(exchange.getResponse().getBodyAsString().block())
+			.contains("<form class=\"login-form\" action=\"/login/another\" method=\"post\">");
+	}
+
+	@Test
+	void setContextThenGenerates() {
+		MockServerWebExchange exchange = MockServerWebExchange
+			.from(MockServerHttpRequest.get("/test/login/ott").contextPath("/test"));
+		this.filter.setLoginProcessingUrl("/login/another");
+
+		this.filter.filter(exchange, (e) -> Mono.empty()).block();
+
+		assertThat(exchange.getResponse().getBodyAsString().block())
+			.contains("<form class=\"login-form\" action=\"/test/login/another\" method=\"post\">");
+	}
+
+	@Test
+	void filterWhenTokenQueryParamUsesSpecialCharactersThenValueIsEscaped() {
+		MockServerWebExchange exchange = MockServerWebExchange
+			.from(MockServerHttpRequest.get("/login/ott").queryParam("token", "this<>!@#\""));
+
+		this.filter.filter(exchange, (e) -> Mono.empty()).block();
+
+		assertThat(exchange.getResponse().getBodyAsString().block()).contains(
+				"<input type=\"text\" id=\"token\" name=\"token\" value=\"this&lt;&gt;!@#&quot;\" placeholder=\"Token\" required=\"true\" autofocus=\"autofocus\"/>");
+	}
+
+	@Test
+	void filterThenRenders() {
+		MockServerWebExchange exchange = MockServerWebExchange
+			.from(MockServerHttpRequest.get("/login/ott").queryParam("token", "this<>!@#\""));
+		this.filter.setLoginProcessingUrl("/login/another");
+
+		this.filter.filter(exchange, (e) -> Mono.empty()).block();
+
+		assertThat(exchange.getResponse().getBodyAsString().block()).isEqualTo(
+				"""
+						<!DOCTYPE html>
+						<html lang="en">
+						  <head>
+						    <title>One-Time Token Login</title>
+						    <meta charset="utf-8"/>
+						    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
+						    <link href="/default-ui.css" rel="stylesheet" />
+						  </head>
+						  <body>
+						    <div class="container">
+						      <form class="login-form" action="/login/another" method="post">
+						        <h2>Please input the token</h2>
+						        <p>
+						          <label for="token" class="screenreader">Token</label>
+						          <input type="text" id="token" name="token" value="this&lt;&gt;!@#&quot;" placeholder="Token" required="true" autofocus="autofocus"/>
+						        </p>
+
+						        <button class="primary" type="submit">Sign in</button>
+						      </form>
+						    </div>
+						  </body>
+						</html>
+						""");
+	}
+
+}