From e9fe6360bc58aeaed644b30792b41972f3f3e5c9 Mon Sep 17 00:00:00 2001 From: Max Batischev Date: Tue, 15 Oct 2024 23:18:04 +0300 Subject: [PATCH] Add Reactive One-Time Token Login Kotlin DSL Support Closes gh-15887 --- .../web/server/ServerHttpSecurityDsl.kt | 30 +++ .../web/server/ServerOneTimeTokenLoginDsl.kt | 85 +++++++ .../server/ServerOneTimeTokenLoginDslTests.kt | 222 ++++++++++++++++++ 3 files changed, 337 insertions(+) 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/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt b/config/src/main/kotlin/org/springframework/security/config/web/server/ServerHttpSecurityDsl.kt index d308429c08..b904a79ad4 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 0000000000..3765a3e11a --- /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/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 0000000000..db4be9e313 --- /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) + } + } +}