diff --git a/config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java b/config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java
index 101d5b15c52..c32b78ad231 100644
--- a/config/src/main/java/org/springframework/security/config/web/server/SecurityWebFiltersOrder.java
+++ b/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");
* you may not use this file except in compliance with the License.
@@ -67,6 +67,16 @@ public enum SecurityWebFiltersOrder {
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}
*/
diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java
index 8c50ecee2b4..c3e98dae1a0 100644
--- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java
+++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java
@@ -53,6 +53,10 @@
import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
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.AuthorityReactiveAuthorizationManager;
import org.springframework.security.authorization.AuthorizationDecision;
@@ -152,6 +156,9 @@
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.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.AuthorizationWebFilter;
import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager;
@@ -197,6 +204,7 @@
import org.springframework.security.web.server.ui.DefaultResourcesWebFilter;
import org.springframework.security.web.server.ui.LoginPageGeneratingWebFilter;
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.MediaTypeServerWebExchangeMatcher;
import org.springframework.security.web.server.util.matcher.NegatedServerWebExchangeMatcher;
@@ -348,6 +356,8 @@ public class ServerHttpSecurity {
private AnonymousSpec anonymous;
+ private OneTimeTokenLoginSpec oneTimeTokenLogin;
+
protected ServerHttpSecurity() {
}
@@ -1549,6 +1559,43 @@ public ServerHttpSecurity authenticationManager(ReactiveAuthenticationManager ma
return this;
}
+ /**
+ * Configures One-Time Token Login Support.
+ *
+ *
Example Configuration
+ *
+ *
+ * @Configuration
+ * @EnableWebFluxSecurity
+ * public class SecurityConfig {
+ *
+ * @Bean
+ * public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {
+ * http
+ * // ...
+ * .oneTimeTokenLogin(Customizer.withDefaults());
+ * return http.build();
+ * }
+ *
+ * @Bean
+ * public ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler() {
+ * return new MyMagicLinkServerGeneratedOneTimeTokenHandler();
+ * }
+ *
+ * }
+ *
+ * @param oneTimeTokenLoginCustomizer the {@link Customizer} to provide more options
+ * for the {@link OneTimeTokenLoginSpec}
+ * @return the {@link ServerHttpSecurity} for further customizations
+ */
+ public ServerHttpSecurity oneTimeTokenLogin(Customizer oneTimeTokenLoginCustomizer) {
+ if (this.oneTimeTokenLogin == null) {
+ this.oneTimeTokenLogin = new OneTimeTokenLoginSpec();
+ }
+ oneTimeTokenLoginCustomizer.customize(this.oneTimeTokenLogin);
+ return this;
+ }
+
/**
* Builds the {@link SecurityWebFilterChain}
* @return the {@link SecurityWebFilterChain}
@@ -1641,6 +1688,18 @@ else if (this.securityContextRepository != null) {
this.logout.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(),
SecurityWebFiltersOrder.SECURITY_CONTEXT_SERVER_WEB_EXCHANGE);
if (this.authorizeExchange != null) {
@@ -5850,4 +5909,295 @@ private AnonymousSpec() {
}
+ /**
+ * 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 defaultSuccessHandlers = new ArrayList<>(
+ List.of(this.defaultSuccessHandler));
+
+ private final List 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> 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;
+ }
+
+ }
+
}
diff --git a/config/src/test/java/org/springframework/security/config/web/server/OneTimeTokenLoginSpecTests.java b/config/src/test/java/org/springframework/security/config/web/server/OneTimeTokenLoginSpecTests.java
new file mode 100644
index 00000000000..e816337e147
--- /dev/null
+++ b/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 = """
+
+
+
+
+
+
+
+ Please sign in
+
+
+ """;
+
+ private static final String LOGIN_PART = """
+
+
Login with OAuth 2.0
@@ -94,4 +95,20 @@ void filtersThenRendersPage() {
""");
}
+ @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("""
+