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 = """ +