From ec314ee1b0927e93888547a64f033d1e988474c6 Mon Sep 17 00:00:00 2001 From: Max Batischev Date: Tue, 15 Oct 2024 14:03:54 +0300 Subject: [PATCH] Add Reactive One-Time Token Login Kotlin DSL Support Closes gh-15887 --- .../annotation/web/builders/HttpSecurity.java | 4 +- .../ott/OneTimeTokenLoginConfigurer.java | 8 +- .../config/web/server/ServerHttpSecurity.java | 14 +- .../config/annotation/web/HttpSecurityDsl.kt | 2 +- .../web/server/ServerHttpSecurityDsl.kt | 30 +++ .../web/server/ServerOneTimeTokenLoginDsl.kt | 85 +++++++ .../ott/OneTimeTokenLoginConfigurerTests.java | 2 +- .../server/OneTimeTokenLoginSpecTests.java | 4 +- .../web/OneTimeTokenLoginDslTests.kt | 10 +- .../server/ServerOneTimeTokenLoginDslTests.kt | 222 +++++++++++++++++ .../reactive/authentication/onetimetoken.adoc | 229 +++++++++++++++++- .../servlet/authentication/onetimetoken.adoc | 39 ++- .../ott/GenerateOneTimeTokenWebFilter.java | 10 +- .../GenerateOneTimeTokenWebFilterTests.java | 10 +- 14 files changed, 605 insertions(+), 64 deletions(-) create mode 100644 config/src/main/kotlin/org/springframework/security/config/web/server/ServerOneTimeTokenLoginDsl.kt create mode 100644 config/src/test/kotlin/org/springframework/security/config/web/server/ServerOneTimeTokenLoginDslTests.kt diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index fa9b239da1e..c152a89c703 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -3003,8 +3003,8 @@ public HttpSecurity oauth2ResourceServer( * } * * @Bean - * public GeneratedOneTimeTokenHandler generatedOneTimeTokenHandler() { - * return new MyMagicLinkGeneratedOneTimeTokenHandler(); + * public OneTimeTokenGenerationSuccessHandler oneTimeTokenGenerationSuccessHandler() { + * return new MyMagicLinkOneTimeTokenGenerationSuccessHandler(); * } * * } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java index 0e36f818ccb..15718bf51b5 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java @@ -133,19 +133,19 @@ private SecurityContextRepository getSecurityContextRepository(H http) { private void configureOttGenerateFilter(H http) { GenerateOneTimeTokenFilter generateFilter = new GenerateOneTimeTokenFilter(getOneTimeTokenService(http), - getGeneratedOneTimeTokenHandler(http)); + getOneTimeTokenGenerationSuccessHandler(http)); generateFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.tokenGeneratingUrl)); http.addFilter(postProcess(generateFilter)); http.addFilter(DefaultResourcesFilter.css()); } - private OneTimeTokenGenerationSuccessHandler getGeneratedOneTimeTokenHandler(H http) { + private OneTimeTokenGenerationSuccessHandler getOneTimeTokenGenerationSuccessHandler(H http) { if (this.oneTimeTokenGenerationSuccessHandler == null) { this.oneTimeTokenGenerationSuccessHandler = getBeanOrNull(http, OneTimeTokenGenerationSuccessHandler.class); } if (this.oneTimeTokenGenerationSuccessHandler == null) { throw new IllegalStateException(""" - A GeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin(). + A OneTimeTokenGenerationSuccessHandler is required to enable oneTimeTokenLogin(). Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. """); } @@ -200,7 +200,7 @@ public OneTimeTokenLoginConfigurer tokenGeneratingUrl(String tokenGeneratingU */ public OneTimeTokenLoginConfigurer tokenGenerationSuccessHandler( OneTimeTokenGenerationSuccessHandler oneTimeTokenGenerationSuccessHandler) { - Assert.notNull(oneTimeTokenGenerationSuccessHandler, "generatedOneTimeTokenHandler cannot be null"); + Assert.notNull(oneTimeTokenGenerationSuccessHandler, "oneTimeTokenGenerationSuccessHandler cannot be null"); this.oneTimeTokenGenerationSuccessHandler = oneTimeTokenGenerationSuccessHandler; return this; } 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 82a1806e4aa..561c78edfd9 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 @@ -1578,8 +1578,8 @@ public ServerHttpSecurity authenticationManager(ReactiveAuthenticationManager ma * } * * @Bean - * public ServerGeneratedOneTimeTokenHandler generatedOneTimeTokenHandler() { - * return new MyMagicLinkServerGeneratedOneTimeTokenHandler(); + * public ServerOneTimeTokenGenerationSuccessHandler oneTimeTokenGenerationSuccessHandler() { + * return new MyMagicLinkServerOneTimeTokenGenerationSuccessHandler(); * } * * } @@ -6151,12 +6151,12 @@ public OneTimeTokenLoginSpec defaultSubmitPageUrl(String submitPageUrl) { /** * Specifies strategy to be used to handle generated one-time tokens. - * @param generatedOneTimeTokenHandler + * @param generationSuccessHandler */ public OneTimeTokenLoginSpec tokenGenerationSuccessHandler( - ServerOneTimeTokenGenerationSuccessHandler generatedOneTimeTokenHandler) { - Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null"); - this.tokenGenerationSuccessHandler = generatedOneTimeTokenHandler; + ServerOneTimeTokenGenerationSuccessHandler generationSuccessHandler) { + Assert.notNull(generationSuccessHandler, "generationSuccessHandler cannot be null"); + this.tokenGenerationSuccessHandler = generationSuccessHandler; return this; } @@ -6193,7 +6193,7 @@ private ServerOneTimeTokenGenerationSuccessHandler getTokenGenerationSuccessHand } if (this.tokenGenerationSuccessHandler == null) { throw new IllegalStateException(""" - A ServerGeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin(). + A ServerOneTimeTokenGenerationSuccessHandler is required to enable oneTimeTokenLogin(). Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. """); } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt index 2b276b4a79e..88c986b02cd 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/HttpSecurityDsl.kt @@ -985,7 +985,7 @@ class HttpSecurityDsl(private val http: HttpSecurity, private val init: HttpSecu * fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { * http { * oneTimeTokenLogin { - * generatedOneTimeTokenHandler = MyMagicLinkGeneratedOneTimeTokenHandler() + * oneTimeTokenGenerationSuccessHandler = MyMagicLinkOneTimeTokenGenerationSuccessHandler() * } * } * return http.build() diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt index d308429c08b..b904a79ad4a 100644 --- a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt @@ -714,6 +714,36 @@ class ServerHttpSecurityDsl(private val http: ServerHttpSecurity, private val in this.http.sessionManagement(sessionManagementCustomizer) } + /** + * Configures One-Time Token Login support. + * + * Example: + * + * ``` + * @Configuration + * @EnableWebFluxSecurity + * open class SecurityConfig { + * + * @Bean + * open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + * return http { + * oneTimeTokenLogin { + * tokenGenerationSuccessHandler = MyMagicLinkServerOneTimeTokenGenerationSuccessHandler() + * } + * } + * } + * } + * ``` + * + * @param oneTimeTokenLoginConfiguration custom configuration to configure the One-Time Token Login + * @since 6.4 + * @see [ServerOneTimeTokenLoginDsl] + */ + fun oneTimeTokenLogin(oneTimeTokenLoginConfiguration: ServerOneTimeTokenLoginDsl.()-> Unit){ + val oneTimeTokenLoginCustomizer = ServerOneTimeTokenLoginDsl().apply(oneTimeTokenLoginConfiguration).get() + this.http.oneTimeTokenLogin(oneTimeTokenLoginCustomizer) + } + /** * Apply all configurations to the provided [ServerHttpSecurity] */ diff --git a/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOneTimeTokenLoginDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOneTimeTokenLoginDsl.kt new file mode 100644 index 00000000000..3765a3e11aa --- /dev/null +++ b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerOneTimeTokenLoginDsl.kt @@ -0,0 +1,85 @@ +/* + * 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 org.springframework.security.authentication.ReactiveAuthenticationManager +import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter +import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler +import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler +import org.springframework.security.web.server.authentication.ott.ServerOneTimeTokenGenerationSuccessHandler +import org.springframework.security.web.server.context.ServerSecurityContextRepository + +/** + * A Kotlin DSL to configure [ServerHttpSecurity] form login using idiomatic Kotlin code. + * + * @author Max Batischev + * @since 6.4 + * @property tokenService configures the [ReactiveOneTimeTokenService] used to generate and consume + * @property authenticationManager configures the [ReactiveAuthenticationManager] used to generate and consume + * @property authenticationConverter Use this [ServerAuthenticationConverter] when converting incoming requests to an authentication + * @property authenticationFailureHandler the [ServerAuthenticationFailureHandler] to use when authentication + * @property authenticationSuccessHandler the [ServerAuthenticationSuccessHandler] to be used + * @property defaultSubmitPageUrl sets the URL that the default submit page will be generated + * @property showDefaultSubmitPage configures whether the default one-time token submit page should be shown + * @property loginProcessingUrl the URL to process the login request + * @property tokenGeneratingUrl the URL that a One-Time Token generate request will be processed + * @property tokenGenerationSuccessHandler the strategy to be used to handle generated one-time tokens + * @property securityContextRepository the [ServerSecurityContextRepository] used to save the [Authentication]. For the [SecurityContext] to be loaded on subsequent requests the [ReactorContextWebFilter] must be configured to be able to load the value (they are not implicitly linked). + */ +@ServerSecurityMarker +class ServerOneTimeTokenLoginDsl { + var authenticationManager: ReactiveAuthenticationManager? = null + var tokenService: ReactiveOneTimeTokenService? = null + var authenticationConverter: ServerAuthenticationConverter? = null + var authenticationFailureHandler: ServerAuthenticationFailureHandler? = null + var authenticationSuccessHandler: ServerAuthenticationSuccessHandler? = null + var tokenGenerationSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler? = null + var securityContextRepository: ServerSecurityContextRepository? = null + var defaultSubmitPageUrl: String? = null + var loginProcessingUrl: String? = null + var tokenGeneratingUrl: String? = null + var showDefaultSubmitPage: Boolean? = true + + internal fun get(): (ServerHttpSecurity.OneTimeTokenLoginSpec) -> Unit { + return { oneTimeTokenLogin -> + authenticationManager?.also { oneTimeTokenLogin.authenticationManager(authenticationManager) } + tokenService?.also { oneTimeTokenLogin.tokenService(tokenService) } + authenticationConverter?.also { oneTimeTokenLogin.authenticationConverter(authenticationConverter) } + authenticationFailureHandler?.also { + oneTimeTokenLogin.authenticationFailureHandler( + authenticationFailureHandler + ) + } + authenticationSuccessHandler?.also { + oneTimeTokenLogin.authenticationSuccessHandler( + authenticationSuccessHandler + ) + } + securityContextRepository?.also { oneTimeTokenLogin.securityContextRepository(securityContextRepository) } + defaultSubmitPageUrl?.also { oneTimeTokenLogin.defaultSubmitPageUrl(defaultSubmitPageUrl) } + showDefaultSubmitPage?.also { oneTimeTokenLogin.showDefaultSubmitPage(showDefaultSubmitPage!!) } + loginProcessingUrl?.also { oneTimeTokenLogin.loginProcessingUrl(loginProcessingUrl) } + tokenGeneratingUrl?.also { oneTimeTokenLogin.tokenGeneratingUrl(tokenGeneratingUrl) } + tokenGenerationSuccessHandler?.also { + oneTimeTokenLogin.tokenGenerationSuccessHandler( + tokenGenerationSuccessHandler + ) + } + } + } +} diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java index a1e230d5d3f..f89a37ae40f 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java @@ -189,7 +189,7 @@ void oneTimeTokenWhenNoTokenGenerationSuccessHandlerThenException() { .havingRootCause() .isInstanceOf(IllegalStateException.class) .withMessage(""" - A GeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin(). + A OneTimeTokenGenerationSuccessHandler is required to enable oneTimeTokenLogin(). Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. """); } 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 index 75119e11d05..c19f330eba4 100644 --- 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 @@ -269,13 +269,13 @@ void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm() { } @Test - void oneTimeTokenWhenNoGeneratedOneTimeTokenHandlerThenException() { + void oneTimeTokenWhenNoOneTimeTokenGenerationSuccessHandlerThenException() { assertThatException() .isThrownBy(() -> this.spring.register(OneTimeTokenNotGeneratedOttHandlerConfig.class).autowire()) .havingRootCause() .isInstanceOf(IllegalStateException.class) .withMessage(""" - A ServerGeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin(). + A ServerOneTimeTokenGenerationSuccessHandler is required to enable oneTimeTokenLogin(). Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. """); } diff --git a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt index 9a019a0cc67..07833e283f9 100644 --- a/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt +++ b/config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt @@ -69,7 +69,7 @@ class OneTimeTokenLoginDslTests { .redirectedUrl("/login/ott") ) - val token = TestGeneratedOneTimeTokenHandler.lastToken?.tokenValue + val token = TestOneTimeTokenGenerationSuccessHandler.lastToken?.tokenValue this.mockMvc.perform( MockMvcRequestBuilders.post("/login/ott").param("token", token) @@ -91,7 +91,7 @@ class OneTimeTokenLoginDslTests { ) .andExpectAll(MockMvcResultMatchers.status().isFound(), MockMvcResultMatchers.redirectedUrl("/redirected")) - val token = TestGeneratedOneTimeTokenHandler.lastToken?.tokenValue + val token = TestOneTimeTokenGenerationSuccessHandler.lastToken?.tokenValue this.mockMvc.perform( MockMvcRequestBuilders.post("/loginprocessingurl").param("token", token) @@ -117,7 +117,7 @@ class OneTimeTokenLoginDslTests { authorize(anyRequest, authenticated) } oneTimeTokenLogin { - oneTimeTokenGenerationSuccessHandler = TestGeneratedOneTimeTokenHandler() + oneTimeTokenGenerationSuccessHandler = TestOneTimeTokenGenerationSuccessHandler() } } // @formatter:on @@ -138,7 +138,7 @@ class OneTimeTokenLoginDslTests { } oneTimeTokenLogin { tokenGeneratingUrl = "/generateurl" - oneTimeTokenGenerationSuccessHandler = TestGeneratedOneTimeTokenHandler("/redirected") + oneTimeTokenGenerationSuccessHandler = TestOneTimeTokenGenerationSuccessHandler("/redirected") loginProcessingUrl = "/loginprocessingurl" authenticationSuccessHandler = SimpleUrlAuthenticationSuccessHandler("/authenticated") } @@ -156,7 +156,7 @@ class OneTimeTokenLoginDslTests { InMemoryUserDetailsManager(PasswordEncodedUser.user(), PasswordEncodedUser.admin()) } - private class TestGeneratedOneTimeTokenHandler : + private class TestOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler { private val delegate: OneTimeTokenGenerationSuccessHandler diff --git a/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOneTimeTokenLoginDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOneTimeTokenLoginDslTests.kt new file mode 100644 index 00000000000..db4be9e3130 --- /dev/null +++ b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOneTimeTokenLoginDslTests.kt @@ -0,0 +1,222 @@ +/* + * 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 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.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Import +import org.springframework.context.ApplicationContext +import org.springframework.http.MediaType +import org.springframework.security.authentication.ott.OneTimeToken +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.ServerOneTimeTokenGenerationSuccessHandler +import org.springframework.security.web.server.authentication.ott.ServerRedirectOneTimeTokenGenerationSuccessHandler +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 org.springframework.web.util.UriBuilder + +/** + * Tests for [ServerOneTimeTokenLoginDsl] + * + * @author Max Batischev + */ +@ExtendWith(SpringTestContextExtension::class) +class ServerOneTimeTokenLoginDslTests { + @JvmField + val spring = SpringTestContext(this) + + private lateinit var client: WebTestClient + + @Autowired + fun setup(context: ApplicationContext) { + this.client = WebTestClient + .bindToApplicationContext(context) + .configureClient() + .build() + } + + @Test + fun `oneTimeToken when correct token then can authenticate`() { + spring.register(OneTimeTokenConfig::class.java).autowire() + + // @formatter:off + client.mutateWith(SecurityMockServerConfigurers.csrf()) + .post() + .uri{ uriBuilder: UriBuilder -> uriBuilder + .path("/ott/generate") + .build() + } + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("username", "user")) + .exchange() + .expectStatus() + .is3xxRedirection() + .expectHeader().valueEquals("Location", "/login/ott") + + client.mutateWith(SecurityMockServerConfigurers.csrf()) + .post() + .uri{ uriBuilder:UriBuilder -> uriBuilder + .path("/ott/generate") + .build() + } + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("username", "user")) + .exchange() + .expectStatus() + .is3xxRedirection() + .expectHeader().valueEquals("Location", "/login/ott") + + val token = TestServerOneTimeTokenGenerationSuccessHandler.lastToken?.tokenValue + + client.mutateWith(SecurityMockServerConfigurers.csrf()) + .post() + .uri{ uriBuilder:UriBuilder -> uriBuilder + .path("/login/ott") + .queryParam("token", token) + .build() + } + .exchange() + .expectStatus() + .is3xxRedirection() + .expectHeader().valueEquals("Location", "/") + // @formatter:on + } + + @Test + fun `oneTimeToken when different authentication urls then can authenticate`() { + spring.register(OneTimeTokenDifferentUrlsConfig::class.java).autowire() + + // @formatter:off + client.mutateWith(SecurityMockServerConfigurers.csrf()) + .post() + .uri{ uriBuilder: UriBuilder -> uriBuilder + .path("/generateurl") + .build() + } + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("username", "user")) + .exchange() + .expectStatus() + .is3xxRedirection() + .expectHeader().valueEquals("Location", "/redirected") + + val token = TestServerOneTimeTokenGenerationSuccessHandler.lastToken?.tokenValue + + client.mutateWith(SecurityMockServerConfigurers.csrf()) + .post() + .uri{ uriBuilder: UriBuilder -> uriBuilder + .path("/loginprocessingurl") + .build() + } + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("token", token!!)) + .exchange() + .expectStatus() + .is3xxRedirection() + .expectHeader().valueEquals("Location", "/authenticated") + // @formatter:on + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + @Import(UserDetailsServiceConfig::class) + open class OneTimeTokenConfig { + + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + // @formatter:off + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oneTimeTokenLogin { + tokenGenerationSuccessHandler = TestServerOneTimeTokenGenerationSuccessHandler() + } + } + // @formatter:on + } + } + + @Configuration + @EnableWebFlux + @EnableWebFluxSecurity + @Import(UserDetailsServiceConfig::class) + open class OneTimeTokenDifferentUrlsConfig { + + @Bean + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + // @formatter:off + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oneTimeTokenLogin { + tokenGeneratingUrl = "/generateurl" + tokenGenerationSuccessHandler = TestServerOneTimeTokenGenerationSuccessHandler("/redirected") + loginProcessingUrl = "/loginprocessingurl" + authenticationSuccessHandler = RedirectServerAuthenticationSuccessHandler("/authenticated") + } + } + // @formatter:on + } + } + + @Configuration(proxyBeanMethods = false) + open class UserDetailsServiceConfig { + + @Bean + open fun userDetailsService(): ReactiveUserDetailsService = + MapReactiveUserDetailsService(User("user", "password", listOf())) + } + + private class TestServerOneTimeTokenGenerationSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler { + private var delegate: ServerRedirectOneTimeTokenGenerationSuccessHandler? = null + + companion object { + var lastToken: OneTimeToken? = null + } + + constructor() { + this.delegate = ServerRedirectOneTimeTokenGenerationSuccessHandler("/login/ott") + } + + constructor(redirectUrl: String?) { + this.delegate = ServerRedirectOneTimeTokenGenerationSuccessHandler(redirectUrl) + } + + override fun handle(exchange: ServerWebExchange?, oneTimeToken: OneTimeToken?): Mono { + lastToken = oneTimeToken + return delegate!!.handle(exchange, oneTimeToken) + } + } +} diff --git a/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc index 1964a1ea798..f24ff5b87ed 100644 --- a/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc +++ b/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc @@ -65,7 +65,7 @@ Java:: public class SecurityConfig { @Bean - public SecurityWebFilterChain filterChain(ServerHttpSecurity http, MagicLinkGeneratedOneTimeTokenHandler magicLinkSender) { + public SecurityWebFilterChain filterChain(ServerHttpSecurity http) { http // ... .formLogin(Customizer.withDefaults()) @@ -79,11 +79,11 @@ import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; @Component <1> -public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler { +public class MagicLinkOneTimeTokenGenerationSuccessHandler implements ServerOneTimeTokenGenerationSuccessHandler { private final MailSender mailSender; - private final ServerGeneratedOneTimeTokenHandler redirectHandler = new ServerRedirectGeneratedOneTimeTokenHandler("/ott/sent"); + private final ServerOneTimeTokenGenerationSuccessHandler redirectHandler = new ServerRedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); // constructor omitted @@ -119,14 +119,72 @@ class PageController { } } +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebFluxSecurity +class SecurityConfig { + + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oneTimeTokenLogin { } + } + } + +} + +@Component (1) +class MagicLinkOneTimeTokenGenerationSuccessHandler(val mailSender: MailSender): ServerOneTimeTokenGenerationSuccessHandler { + + private val redirectStrategy: ServerRedirectStrategy = DefaultServerRedirectStrategy() + + override fun handle(exchange: ServerWebExchange, oneTimeToken: OneTimeToken): Mono { + val builder = UriComponentsBuilder.fromUri(exchange.request.uri) + .replacePath(null) + .replaceQuery(null) + .fragment(null) + .path("/login/ott") + .queryParam("token", oneTimeToken.getTokenValue()) (2) + val magicLink = builder.toUriString() + builder.replacePath(null) + .replaceQuery(null) + .path("/ott/sent") + val redirectLink = builder.toUriString() + return this.mailSender.send( + getUserEmail(oneTimeToken.getUsername()), (3) + "Use the following link to sign in into the application: $magicLink") (4) + .then(this.redirectStrategy.sendRedirect(exchange, URI.create(redirectLink))) (5) + } + + private String getUserEmail() { + // ... + } +} + +@Controller +class PageController { + + @GetMapping("/ott/sent") + fun ottSent(): String { + return "my-template" + } +} + ---- ====== -<1> Make the `MagicLinkGeneratedOneTimeTokenHandler` a Spring bean +<1> Make the `MagicLinkOneTimeTokenGenerationSuccessHandler` 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 `ServerRedirectOneTimeTokenGenerationSuccessHandler` to perform a redirect to your desired URL +<4> Use the `MailSender` API to send the email to the user with the magic link +<5> Use the `ServerRedirectStrategy` to perform a redirect to your desired URL The email content will look similar to: @@ -165,9 +223,36 @@ public class SecurityConfig { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler { +public class MagicLinkOneTimeTokenGenerationSuccessHandler implements ServerOneTimeTokenGenerationSuccessHandler { + // ... +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebFluxSecurity +class SecurityConfig { + + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + // ... + formLogin { } + oneTimeTokenLogin { + generateTokenUrl = "/ott/my-generate-url" + } + } + } + +} + +@Component +class MagicLinkOneTimeTokenGenerationSuccessHandler(val mailSender: MailSender): ServerOneTimeTokenGenerationSuccessHandler { // ... } + ---- ====== @@ -202,9 +287,36 @@ public class SecurityConfig { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler { +public class MagicLinkOneTimeTokenGenerationSuccessHandler implements ServerOneTimeTokenGenerationSuccessHandler { // ... } +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebFluxSecurity +class SecurityConfig { + + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + // ... + formLogin { } + oneTimeTokenLogin { + submitPageUrl = "/ott/submit" + } + } + } + +} + +@Component +class MagicLinkOneTimeTokenGenerationSuccessHandler(val mailSender: MailSender): ServerOneTimeTokenGenerationSuccessHandler { + // ... +} + ---- ====== @@ -251,9 +363,48 @@ public class MyController { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler { +public class MagicLinkOneTimeTokenGenerationSuccessHandler implements ServerOneTimeTokenGenerationSuccessHandler { + // ... +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebFluxSecurity +class SecurityConfig { + + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + authorizeExchange { + authorize(pathMatchers("/my-ott-submit"), permitAll) + authorize(anyExchange, authenticated) + } + .formLogin { } + oneTimeTokenLogin { + showDefaultSubmitPage = false + } + } + } + +} + +@Controller +class MyController { + + @GetMapping("/my-ott-submit") + fun ottSubmitPage(): String { + return "my-ott-submit" + } +} + +@Component +class MagicLinkOneTimeTokenGenerationSuccessHandler(val mailSender: MailSender): ServerOneTimeTokenGenerationSuccessHandler { // ... } + ---- ====== @@ -301,9 +452,39 @@ public class SecurityConfig { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler { +public class MagicLinkOneTimeTokenGenerationSuccessHandler implements ServerOneTimeTokenGenerationSuccessHandler { // ... } +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebFluxSecurity +class SecurityConfig { + + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + //.. + .formLogin { } + oneTimeTokenLogin { } + } + } + + @Bean + open fun oneTimeTokenService():ReactiveOneTimeTokenService { + return MyCustomReactiveOneTimeTokenService(); + } + +} + +@Component +class MagicLinkOneTimeTokenGenerationSuccessHandler(val mailSender: MailSender): ServerOneTimeTokenGenerationSuccessHandler { + // ... +} + ---- ====== @@ -334,8 +515,34 @@ public class SecurityConfig { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements ServerGeneratedOneTimeTokenHandler { +public class MagicLinkOneTimeTokenGenerationSuccessHandler implements ServerOneTimeTokenGenerationSuccessHandler { // ... } +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Configuration +@EnableWebFluxSecurity +class SecurityConfig { + + open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + return http { + //.. + .formLogin { } + oneTimeTokenLogin { + oneTimeTokenService = MyCustomReactiveOneTimeTokenService() + } + } + } +} + +@Component +class MagicLinkOneTimeTokenGenerationSuccessHandler(val mailSender: MailSender): ServerOneTimeTokenGenerationSuccessHandler { + // ... +} + ---- ====== diff --git a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc index 0f389a2ac15..9b69481647d 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc @@ -65,7 +65,7 @@ Java:: public class SecurityConfig { @Bean - public SecurityFilterChain filterChain(HttpSecurity http, MagicLinkGeneratedOneTimeTokenHandler magicLinkSender) { + public SecurityFilterChain filterChain(HttpSecurity http) { http // ... .formLogin(Customizer.withDefaults()) @@ -79,11 +79,11 @@ import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; @Component <1> -public class MagicLinkGeneratedOneTimeTokenHandler implements GeneratedOneTimeTokenSuccessHandler { +public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler { private final MailSender mailSender; - private final GeneratedOneTimeTokenHandler redirectHandler = new RedirectGeneratedOneTimeTokenHandler("/ott/sent"); + private final OneTimeTokenGenerationSuccessHandler redirectHandler = new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent"); // constructor omitted @@ -128,10 +128,7 @@ Kotlin:: class SecurityConfig { @Bean - open fun filterChain( - http: HttpSecurity, - magicLinkSender: MagicLinkGeneratedOneTimeTokenSuccessHandler? - ): SecurityFilterChain { + open fun filterChain(http: HttpSecurity): SecurityFilterChain { http{ formLogin {} oneTimeTokenLogin { } @@ -144,10 +141,10 @@ import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; @Component (1) -class MagicLinkGeneratedOneTimeTokenSuccessHandler( +class MagicLinkOneTimeTokenGenerationSuccessHandler( private val mailSender: MailSender, - private val redirectHandler: GeneratedOneTimeTokenHandler = RedirectGeneratedOneTimeTokenHandler("/ott/sent") -) : GeneratedOneTimeTokenHandler { + private val redirectHandler: OneTimeTokenGenerationSuccessHandler = RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent") +) : OneTimeTokenGenerationSuccessHandler { override fun handle(request: HttpServletRequest, response: HttpServletResponse, oneTimeToken: OneTimeToken) { val builder = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request)) @@ -179,7 +176,7 @@ class PageController { ---- ====== -<1> Make the `MagicLinkGeneratedOneTimeTokenHandler` a Spring bean +<1> Make the `MagicLinkOneTimeTokenGenerationSuccessHandler` 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 @@ -222,7 +219,7 @@ public class SecurityConfig { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements GeneratedOneTimeTokenSuccessHandler { +public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler { // ... } ---- @@ -249,7 +246,7 @@ class SecurityConfig { } @Component -class MagicLinkGeneratedOneTimeTokenSuccessHandler : GeneratedOneTimeTokenHandler { +class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler { // ... } ---- @@ -286,7 +283,7 @@ public class SecurityConfig { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements GeneratedOneTimeTokenSuccessHandler { +public class MagicLinkGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler { // ... } ---- @@ -313,7 +310,7 @@ class SecurityConfig { } @Component -class MagicLinkGeneratedOneTimeTokenSuccessHandler : GeneratedOneTimeTokenHandler { +class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler { // ... } ---- @@ -362,7 +359,7 @@ public class MyController { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements GeneratedOneTimeTokenSuccessHandler { +public class OneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler { // ... } ---- @@ -401,7 +398,7 @@ class MyController { } @Component -class MagicLinkGeneratedOneTimeTokenSuccessHandler : GeneratedOneTimeTokenHandler { +class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler { // ... } ---- @@ -452,7 +449,7 @@ public class SecurityConfig { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements GeneratedOneTimeTokenSuccessHandler { +public class MagicLinkOneTimeTokenGenerationSuccessHandler implements OneTimeTokenGenerationSuccessHandler { // ... } ---- @@ -482,7 +479,7 @@ class SecurityConfig { } @Component -class MagicLinkGeneratedOneTimeTokenSuccessHandler : GeneratedOneTimeTokenHandler { +class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler { // ... } ---- @@ -515,7 +512,7 @@ public class SecurityConfig { } @Component -public class MagicLinkGeneratedOneTimeTokenHandler implements GeneratedOneTimeTokenSuccessHandler { +public class MagicLinkGeneratedOneTimeTokenHandler implements OneTimeTokenGenerationSuccessHandler { // ... } ---- @@ -543,7 +540,7 @@ class SecurityConfig { } @Component -class MagicLinkGeneratedOneTimeTokenSuccessHandler : GeneratedOneTimeTokenHandler { +class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSuccessHandler { // ... } ---- diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java b/web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java index 19545d75c21..c8a160a4a13 100644 --- a/web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java +++ b/web/src/main/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilter.java @@ -43,13 +43,13 @@ public final class GenerateOneTimeTokenWebFilter implements WebFilter { private ServerWebExchangeMatcher matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/ott/generate"); - private final ServerOneTimeTokenGenerationSuccessHandler generatedOneTimeTokenHandler; + private final ServerOneTimeTokenGenerationSuccessHandler generationSuccessHandler; public GenerateOneTimeTokenWebFilter(ReactiveOneTimeTokenService oneTimeTokenService, - ServerOneTimeTokenGenerationSuccessHandler generatedOneTimeTokenHandler) { - Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null"); + ServerOneTimeTokenGenerationSuccessHandler generationSuccessHandler) { + Assert.notNull(generationSuccessHandler, "generationSuccessHandler cannot be null"); Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null"); - this.generatedOneTimeTokenHandler = generatedOneTimeTokenHandler; + this.generationSuccessHandler = generationSuccessHandler; this.oneTimeTokenService = oneTimeTokenService; } @@ -63,7 +63,7 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { .mapNotNull((data) -> 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)); + .flatMap((token) -> this.generationSuccessHandler.handle(exchange, token)); // @formatter:on } diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilterTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilterTests.java index 434ec74dddc..c0f8a091d72 100644 --- a/web/src/test/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/server/authentication/ott/GenerateOneTimeTokenWebFilterTests.java @@ -45,7 +45,7 @@ public class GenerateOneTimeTokenWebFilterTests { private final ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class); - private final ServerRedirectOneTimeTokenGenerationSuccessHandler generatedOneTimeTokenHandler = new ServerRedirectOneTimeTokenGenerationSuccessHandler( + private final ServerRedirectOneTimeTokenGenerationSuccessHandler generationSuccessHandler = new ServerRedirectOneTimeTokenGenerationSuccessHandler( "/login/ott"); private static final String TOKEN = "token"; @@ -60,7 +60,7 @@ void filterWhenUsernameFormParamIsPresentThenSuccess() { .contentType(MediaType.APPLICATION_FORM_URLENCODED) .body("username=user")); GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService, - this.generatedOneTimeTokenHandler); + this.generationSuccessHandler); filter.filter(exchange, (e) -> Mono.empty()).block(); @@ -75,7 +75,7 @@ void filterWhenUsernameFormParamIsEmptyThenNull() { MockServerHttpRequest.BaseBuilder request = MockServerHttpRequest.post("/ott/generate"); MockServerWebExchange exchange = MockServerWebExchange.from(request); GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService, - this.generatedOneTimeTokenHandler); + this.generationSuccessHandler); filter.filter(exchange, (e) -> Mono.empty()).block(); @@ -86,14 +86,14 @@ void filterWhenUsernameFormParamIsEmptyThenNull() { public void constructorWhenOneTimeTokenServiceNullThenIllegalArgumentException() { // @formatter:off assertThatIllegalArgumentException() - .isThrownBy(() -> new GenerateOneTimeTokenWebFilter(null, this.generatedOneTimeTokenHandler)); + .isThrownBy(() -> new GenerateOneTimeTokenWebFilter(null, this.generationSuccessHandler)); // @formatter:on } @Test public void setWhenRequestMatcherNullThenIllegalArgumentException() { GenerateOneTimeTokenWebFilter filter = new GenerateOneTimeTokenWebFilter(this.oneTimeTokenService, - this.generatedOneTimeTokenHandler); + this.generationSuccessHandler); // @formatter:off assertThatIllegalArgumentException() .isThrownBy(() -> filter.setRequestMatcher(null));