From f4484d8b193a131be9eb4b68281b61e357b0a76b Mon Sep 17 00:00:00 2001 From: Max Batischev Date: Mon, 27 Jan 2025 18:18:30 +0300 Subject: [PATCH 1/2] Add Support ServerGenerateOneTimeTokenRequestResolver Closes gh-16488 Signed-off-by: Max Batischev --- .../config/web/server/ServerHttpSecurity.java | 35 +++++++- .../web/server/ServerOneTimeTokenLoginDsl.kt | 6 +- .../server/OneTimeTokenLoginSpecTests.java | 8 +- .../server/ServerOneTimeTokenLoginDslTests.kt | 79 ++++++++++++++++++- .../reactive/authentication/onetimetoken.adoc | 32 ++++++++ ...erGenerateOneTimeTokenRequestResolver.java | 62 +++++++++++++++ .../ott/GenerateOneTimeTokenWebFilter.java | 23 ++++-- ...erGenerateOneTimeTokenRequestResolver.java | 40 ++++++++++ ...erateOneTimeTokenRequestResolverTests.java | 74 +++++++++++++++++ 9 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/server/authentication/ott/DefaultServerGenerateOneTimeTokenRequestResolver.java create mode 100644 web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerGenerateOneTimeTokenRequestResolver.java create mode 100644 web/src/test/java/org/springframework/security/web/server/authentication/ott/DefaultServerGenerateOneTimeTokenRequestResolverTests.java 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 ac72b75eb9a..ec7da0f266b 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -29,6 +29,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import java.util.function.Consumer; import java.util.function.Function; @@ -53,6 +54,7 @@ import org.springframework.security.authentication.DelegatingReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.ReactiveAuthenticationManagerResolver; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; import org.springframework.security.authentication.ott.OneTimeToken; import org.springframework.security.authentication.ott.reactive.InMemoryReactiveOneTimeTokenService; import org.springframework.security.authentication.ott.reactive.OneTimeTokenReactiveAuthenticationManager; @@ -156,7 +158,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.DefaultServerGenerateOneTimeTokenRequestResolver; import org.springframework.security.web.server.authentication.ott.GenerateOneTimeTokenWebFilter; +import org.springframework.security.web.server.authentication.ott.ServerGenerateOneTimeTokenRequestResolver; import org.springframework.security.web.server.authentication.ott.ServerOneTimeTokenAuthenticationConverter; import org.springframework.security.web.server.authentication.ott.ServerOneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.server.authorization.AuthorizationContext; @@ -5940,6 +5944,8 @@ public final class OneTimeTokenLoginSpec { private ServerSecurityContextRepository securityContextRepository; + private ServerGenerateOneTimeTokenRequestResolver requestResolver; + private String loginProcessingUrl = "/login/ott"; private String defaultSubmitPageUrl = "/login/ott"; @@ -5985,6 +5991,7 @@ private void configureOttGenerateFilter(ServerHttpSecurity http) { getTokenGenerationSuccessHandler()); generateFilter .setRequestMatcher(ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, this.tokenGeneratingUrl)); + generateFilter.setGenerateRequestResolver(getRequestResolver()); http.addFilterAt(generateFilter, SecurityWebFiltersOrder.ONE_TIME_TOKEN); } @@ -6112,6 +6119,32 @@ public OneTimeTokenLoginSpec authenticationConverter(ServerAuthenticationConvert return this; } + /** + * Use this {@link ServerGenerateOneTimeTokenRequestResolver} when resolving + * {@link GenerateOneTimeTokenRequest} from {@link ServerWebExchange}. By default, + * the {@link DefaultServerGenerateOneTimeTokenRequestResolver} is used. + * @param requestResolver the + * {@link DefaultServerGenerateOneTimeTokenRequestResolver} to use + * @since 6.5 + */ + public OneTimeTokenLoginSpec generateRequestResolver( + ServerGenerateOneTimeTokenRequestResolver requestResolver) { + Assert.notNull(requestResolver, "generateRequestResolver cannot be null"); + this.requestResolver = requestResolver; + return this; + } + + private ServerGenerateOneTimeTokenRequestResolver getRequestResolver() { + if (this.requestResolver != null) { + return this.requestResolver; + } + ServerGenerateOneTimeTokenRequestResolver bean = getBeanOrNull( + ServerGenerateOneTimeTokenRequestResolver.class); + this.requestResolver = Objects.requireNonNullElseGet(bean, + DefaultServerGenerateOneTimeTokenRequestResolver::new); + return this.requestResolver; + } + /** * 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 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 index 3765a3e11aa..05019e045ce 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -18,6 +18,7 @@ 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.ott.ServerGenerateOneTimeTokenRequestResolver import org.springframework.security.web.server.authentication.ServerAuthenticationConverter import org.springframework.security.web.server.authentication.ServerAuthenticationFailureHandler import org.springframework.security.web.server.authentication.ServerAuthenticationSuccessHandler @@ -34,6 +35,7 @@ import org.springframework.security.web.server.context.ServerSecurityContextRepo * @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 generateRequestResolver the [ServerGenerateOneTimeTokenRequestResolver] 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 @@ -50,6 +52,7 @@ class ServerOneTimeTokenLoginDsl { var authenticationSuccessHandler: ServerAuthenticationSuccessHandler? = null var tokenGenerationSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler? = null var securityContextRepository: ServerSecurityContextRepository? = null + var generateRequestResolver: ServerGenerateOneTimeTokenRequestResolver? = null var defaultSubmitPageUrl: String? = null var loginProcessingUrl: String? = null var tokenGeneratingUrl: String? = null @@ -71,6 +74,7 @@ class ServerOneTimeTokenLoginDsl { ) } securityContextRepository?.also { oneTimeTokenLogin.securityContextRepository(securityContextRepository) } + generateRequestResolver?.also { oneTimeTokenLogin.generateRequestResolver(generateRequestResolver) } defaultSubmitPageUrl?.also { oneTimeTokenLogin.defaultSubmitPageUrl(defaultSubmitPageUrl) } showDefaultSubmitPage?.also { oneTimeTokenLogin.showDefaultSubmitPage(showDefaultSubmitPage!!) } loginProcessingUrl?.also { oneTimeTokenLogin.loginProcessingUrl(loginProcessingUrl) } 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 c19f330eba4..b83f46081ed 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 @@ -271,10 +271,10 @@ void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm() { @Test void oneTimeTokenWhenNoOneTimeTokenGenerationSuccessHandlerThenException() { assertThatException() - .isThrownBy(() -> this.spring.register(OneTimeTokenNotGeneratedOttHandlerConfig.class).autowire()) - .havingRootCause() - .isInstanceOf(IllegalStateException.class) - .withMessage(""" + .isThrownBy(() -> this.spring.register(OneTimeTokenNotGeneratedOttHandlerConfig.class).autowire()) + .havingRootCause() + .isInstanceOf(IllegalStateException.class) + .withMessage(""" 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/web/server/ServerOneTimeTokenLoginDslTests.kt b/config/src/test/kotlin/org/springframework/security/config/web/server/ServerOneTimeTokenLoginDslTests.kt index db4be9e3130..674a1ec570e 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -16,6 +16,7 @@ package org.springframework.security.config.web.server +import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import reactor.core.publisher.Mono @@ -26,6 +27,7 @@ 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.GenerateOneTimeTokenRequest import org.springframework.security.authentication.ott.OneTimeToken import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.test.SpringTestContext @@ -34,6 +36,8 @@ import org.springframework.security.core.userdetails.MapReactiveUserDetailsServi 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.authentication.ott.DefaultServerGenerateOneTimeTokenRequestResolver +import org.springframework.security.web.server.authentication.ott.ServerGenerateOneTimeTokenRequestResolver import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler import org.springframework.security.web.server.authentication.ott.ServerOneTimeTokenGenerationSuccessHandler @@ -43,6 +47,9 @@ 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 +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset /** * Tests for [ServerOneTimeTokenLoginDsl] @@ -146,6 +153,48 @@ class ServerOneTimeTokenLoginDslTests { // @formatter:on } + @Test + fun `oneTimeToken when custom token expiration time set then authenticate`() { + spring.register(OneTimeTokenConfigWithCustomTokenExpirationTime::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 + + Assertions.assertThat(getCurrentMinutes(token!!.expiresAt)).isEqualTo(10) + } + + private fun getCurrentMinutes(expiresAt:Instant): Int { + val expiresMinutes = expiresAt.atZone(ZoneOffset.UTC).minute + val currentMinutes = Instant.now().atZone(ZoneOffset.UTC).minute + return expiresMinutes - currentMinutes + } + @Configuration @EnableWebFlux @EnableWebFluxSecurity @@ -199,6 +248,34 @@ class ServerOneTimeTokenLoginDslTests { MapReactiveUserDetailsService(User("user", "password", listOf())) } + @Configuration(proxyBeanMethods = false) + @EnableWebFlux + @EnableWebFluxSecurity + @Import(OneTimeTokenLoginSpecTests.UserDetailsServiceConfig::class) + open class OneTimeTokenConfigWithCustomTokenExpirationTime { + @Bean + open fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + // @formatter:off + return http { + authorizeExchange { + authorize(anyExchange, authenticated) + } + oneTimeTokenLogin { + tokenGenerationSuccessHandler = TestServerOneTimeTokenGenerationSuccessHandler() + } + } + } + + @Bean + open fun resolver(): ServerGenerateOneTimeTokenRequestResolver { + val resolver = DefaultServerGenerateOneTimeTokenRequestResolver() + return ServerGenerateOneTimeTokenRequestResolver { exchange -> + resolver.resolve(exchange) + .map { request -> GenerateOneTimeTokenRequest(request.username, Duration.ofSeconds(600)) } + } + } + } + private class TestServerOneTimeTokenGenerationSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler { private var delegate: ServerRedirectOneTimeTokenGenerationSuccessHandler? = null diff --git a/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc index f24ff5b87ed..9d5412e4b13 100644 --- a/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc +++ b/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc @@ -546,3 +546,35 @@ class MagicLinkOneTimeTokenGenerationSuccessHandler(val mailSender: MailSender): ---- ====== + +[[customize-generate-token-request]] +== Customize GenerateOneTimeTokenRequest Instance +There are a number of reasons that you may want to adjust an GenerateOneTimeTokenRequest. For example, you may want expiresIn to be set to 10 mins, which Spring Security sets to 5 mins by default. + +You can customize elements of GenerateOneTimeTokenRequest by publishing an ServerGenerateOneTimeTokenRequestResolver as a @Bean, like so: +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +ServerGenerateOneTimeTokenRequestResolver generateOneTimeTokenRequestResolver() { + DefaultServerGenerateOneTimeTokenRequestResolver resolver = new DefaultServerGenerateOneTimeTokenRequestResolver(); + resolver.setExpiresIn(Duration.ofSeconds(600)); + return resolver; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun generateOneTimeTokenRequestResolver() : ServerGenerateOneTimeTokenRequestResolver { + return DefaultServerGenerateOneTimeTokenRequestResolver().apply { + this.setExpiresIn(Duration.ofMinutes(10)) + } +} +---- +====== diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/ott/DefaultServerGenerateOneTimeTokenRequestResolver.java b/web/src/main/java/org/springframework/security/web/server/authentication/ott/DefaultServerGenerateOneTimeTokenRequestResolver.java new file mode 100644 index 00000000000..f89298f6d49 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/ott/DefaultServerGenerateOneTimeTokenRequestResolver.java @@ -0,0 +1,62 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server.authentication.ott; + +import java.time.Duration; + +import reactor.core.publisher.Mono; + +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; + +/** + * Default implementation of {@link ServerGenerateOneTimeTokenRequestResolver}. Resolves + * {@link GenerateOneTimeTokenRequest} from username parameter. + * + * @author Max Batischev + * @since 6.5 + */ +public final class DefaultServerGenerateOneTimeTokenRequestResolver + implements ServerGenerateOneTimeTokenRequestResolver { + + private static final String USERNAME = "username"; + + private static final Duration DEFAULT_EXPIRES_IN = Duration.ofMinutes(5); + + private Duration expiresIn = DEFAULT_EXPIRES_IN; + + @Override + public Mono resolve(ServerWebExchange exchange) { + // @formatter:off + return exchange.getFormData() + .mapNotNull((data) -> data.getFirst(USERNAME)) + .switchIfEmpty(Mono.empty()) + .map((username) -> new GenerateOneTimeTokenRequest(username, this.expiresIn)); + // @formatter:on + } + + /** + * Sets one-time token expiration time + * @param expiresIn one-time token expiration time + */ + public void setExpiresIn(Duration expiresIn) { + Assert.notNull(expiresIn, "expiresIn cannot be null"); + this.expiresIn = expiresIn; + } + +} 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 170d1d0b680..9a9640d358e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -19,7 +19,6 @@ import reactor.core.publisher.Mono; import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; @@ -37,12 +36,12 @@ */ public final class GenerateOneTimeTokenWebFilter implements WebFilter { - private static final String USERNAME = "username"; - private final ReactiveOneTimeTokenService oneTimeTokenService; private ServerWebExchangeMatcher matcher = ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/ott/generate"); + private ServerGenerateOneTimeTokenRequestResolver generateRequestResolver = new DefaultServerGenerateOneTimeTokenRequestResolver(); + private final ServerOneTimeTokenGenerationSuccessHandler oneTimeTokenGenerationSuccessHandler; public GenerateOneTimeTokenWebFilter(ReactiveOneTimeTokenService oneTimeTokenService, @@ -58,10 +57,9 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { // @formatter:off return this.matcher.matches(exchange) .filter(ServerWebExchangeMatcher.MatchResult::isMatch) - .then(exchange.getFormData()) - .mapNotNull((data) -> data.getFirst(USERNAME)) + .flatMap((result) -> this.generateRequestResolver.resolve(exchange)) .switchIfEmpty(chain.filter(exchange).then(Mono.empty())) - .flatMap((username) -> this.oneTimeTokenService.generate(new GenerateOneTimeTokenRequest(username))) + .flatMap(this.oneTimeTokenService::generate) .flatMap((token) -> this.oneTimeTokenGenerationSuccessHandler.handle(exchange, token)); // @formatter:on } @@ -75,4 +73,15 @@ public void setRequestMatcher(ServerWebExchangeMatcher matcher) { this.matcher = matcher; } + /** + * Use the given {@link ServerGenerateOneTimeTokenRequestResolver} to resolve the + * request, defaults to {@link DefaultServerGenerateOneTimeTokenRequestResolver} + * @param requestResolver {@link ServerGenerateOneTimeTokenRequestResolver} + * @since 6.5 + */ + public void setGenerateRequestResolver(ServerGenerateOneTimeTokenRequestResolver requestResolver) { + Assert.notNull(requestResolver, "requestResolver cannot be null"); + this.generateRequestResolver = requestResolver; + } + } diff --git a/web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerGenerateOneTimeTokenRequestResolver.java b/web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerGenerateOneTimeTokenRequestResolver.java new file mode 100644 index 00000000000..1f360813e19 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/server/authentication/ott/ServerGenerateOneTimeTokenRequestResolver.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server.authentication.ott; + +import reactor.core.publisher.Mono; + +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; +import org.springframework.web.server.ServerWebExchange; + +/** + * A strategy for resolving a {@link GenerateOneTimeTokenRequest} from the + * {@link ServerWebExchange}. + * + * @author Max Batischev + * @since 6.5 + */ +public interface ServerGenerateOneTimeTokenRequestResolver { + + /** + * Resolves {@link GenerateOneTimeTokenRequest} from {@link ServerWebExchange} + * @param exchange {@link ServerWebExchange} to resolve + * @return {@link GenerateOneTimeTokenRequest} + */ + Mono resolve(ServerWebExchange exchange); + +} diff --git a/web/src/test/java/org/springframework/security/web/server/authentication/ott/DefaultServerGenerateOneTimeTokenRequestResolverTests.java b/web/src/test/java/org/springframework/security/web/server/authentication/ott/DefaultServerGenerateOneTimeTokenRequestResolverTests.java new file mode 100644 index 00000000000..c9bfc9eef10 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/server/authentication/ott/DefaultServerGenerateOneTimeTokenRequestResolverTests.java @@ -0,0 +1,74 @@ +/* + * Copyright 2002-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.server.authentication.ott; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DefaultServerGenerateOneTimeTokenRequestResolver} + * + * @author Max Batischev + */ +public class DefaultServerGenerateOneTimeTokenRequestResolverTests { + + private final DefaultServerGenerateOneTimeTokenRequestResolver resolver = new DefaultServerGenerateOneTimeTokenRequestResolver(); + + @Test + void resolveWhenUsernameParameterIsPresentThenResolvesGenerateRequest() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/ott/generate") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body("username=user")); + + GenerateOneTimeTokenRequest request = this.resolver.resolve(exchange).block(); + + assertThat(request).isNotNull(); + assertThat(request.getUsername()).isEqualTo("user"); + assertThat(request.getExpiresIn()).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + void resolveWhenUsernameParameterIsNotPresentThenNull() { + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.post("/ott/generate").contentType(MediaType.APPLICATION_FORM_URLENCODED)); + + GenerateOneTimeTokenRequest request = this.resolver.resolve(exchange).block(); + + assertThat(request).isNull(); + } + + @Test + void resolveWhenExpiresInSetThenResolvesGenerateRequest() { + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.post("/ott/generate") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body("username=user")); + this.resolver.setExpiresIn(Duration.ofSeconds(600)); + + GenerateOneTimeTokenRequest generateRequest = this.resolver.resolve(exchange).block(); + + assertThat(generateRequest.getExpiresIn()).isEqualTo(Duration.ofSeconds(600)); + } + +} From 0ee71adcd616904da1c06754cfe9e55b0f1ae5cd Mon Sep 17 00:00:00 2001 From: Max Batischev Date: Wed, 5 Feb 2025 22:38:49 +0300 Subject: [PATCH 2/2] TestServerOneTimeTokenGenerationSuccessHandler.lastToken to non-static variable Signed-off-by: Max Batischev --- .../server/OneTimeTokenLoginSpecTests.java | 121 +++++++++++++++--- .../server/ServerOneTimeTokenLoginDslTests.kt | 97 +++++++------- 2 files changed, 150 insertions(+), 68 deletions(-) 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 b83f46081ed..d475d2c7c6e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 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. @@ -21,6 +21,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; import reactor.core.publisher.Mono; import org.springframework.beans.factory.annotation.Autowired; @@ -40,6 +42,8 @@ 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.DefaultServerGenerateOneTimeTokenRequestResolver; +import org.springframework.security.web.server.authentication.ott.ServerGenerateOneTimeTokenRequestResolver; 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; @@ -49,6 +53,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatException; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; /** * Tests for {@link ServerHttpSecurity.OneTimeTokenLoginSpec} @@ -107,7 +113,7 @@ void oneTimeTokenWhenCorrectTokenThenCanAuthenticate() { .expectHeader().valueEquals("Location", "/login/ott"); // @formatter:on - String token = TestServerOneTimeTokenGenerationSuccessHandler.lastToken.getTokenValue(); + String token = getLastToken().getTokenValue(); // @formatter:off this.client.mutateWith(SecurityMockServerConfigurers.csrf()) @@ -143,7 +149,7 @@ void oneTimeTokenWhenDifferentAuthenticationUrlsThenCanAuthenticate() { .expectHeader().valueEquals("Location", "/redirected"); // @formatter:on - String token = TestServerOneTimeTokenGenerationSuccessHandler.lastToken.getTokenValue(); + String token = getLastToken().getTokenValue(); // @formatter:off this.client.mutateWith(SecurityMockServerConfigurers.csrf()) @@ -179,7 +185,7 @@ void oneTimeTokenWhenCorrectTokenUsedTwiceThenSecondTimeFails() { .expectHeader().valueEquals("Location", "/login/ott"); // @formatter:on - String token = TestServerOneTimeTokenGenerationSuccessHandler.lastToken.getTokenValue(); + String token = getLastToken().getTokenValue(); // @formatter:off this.client.mutateWith(SecurityMockServerConfigurers.csrf()) @@ -268,18 +274,49 @@ void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm() { assertThat(response.contains(GENERATE_OTT_PART)).isTrue(); } + private OneTimeToken getLastToken() { + OneTimeToken lastToken = this.spring.getContext() + .getBean(TestServerOneTimeTokenGenerationSuccessHandler.class).lastToken; + return lastToken; + } + @Test void oneTimeTokenWhenNoOneTimeTokenGenerationSuccessHandlerThenException() { assertThatException() - .isThrownBy(() -> this.spring.register(OneTimeTokenNotGeneratedOttHandlerConfig.class).autowire()) - .havingRootCause() - .isInstanceOf(IllegalStateException.class) - .withMessage(""" + .isThrownBy(() -> this.spring.register(OneTimeTokenNotGeneratedOttHandlerConfig.class).autowire()) + .havingRootCause() + .isInstanceOf(IllegalStateException.class) + .withMessage(""" A ServerOneTimeTokenGenerationSuccessHandler is required to enable oneTimeTokenLogin(). Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. """); } + @Test + void oneTimeTokenWhenCustomRequestResolverSetThenCustomResolverUse() { + this.spring.register(OneTimeTokenConfigWithCustomRequestResolver.class).autowire(); + + // @formatter:off + this.client.mutateWith(SecurityMockServerConfigurers.csrf()) + .post() + .uri((uriBuilder) -> uriBuilder + .path("/ott/generate") + .build() + ) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("username", "user")) + .exchange() + .expectStatus() + .is3xxRedirection() + .expectHeader().valueEquals("Location", "/login/ott"); + // @formatter:on + + ServerGenerateOneTimeTokenRequestResolver resolver = this.spring.getContext() + .getBean(ServerGenerateOneTimeTokenRequestResolver.class); + + verify(resolver, times(1)).resolve(ArgumentMatchers.any(ServerWebExchange.class)); + } + @Configuration(proxyBeanMethods = false) @EnableWebFlux @EnableWebFluxSecurity @@ -287,7 +324,8 @@ Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. static class OneTimeTokenDefaultConfig { @Bean - SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, + ServerOneTimeTokenGenerationSuccessHandler ottSuccessHandler) { // @formatter:off http .authorizeExchange((authorize) -> authorize @@ -295,12 +333,17 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { .authenticated() ) .oneTimeTokenLogin((ott) -> ott - .tokenGenerationSuccessHandler(new TestServerOneTimeTokenGenerationSuccessHandler()) + .tokenGenerationSuccessHandler(ottSuccessHandler) ); // @formatter:on return http.build(); } + @Bean + TestServerOneTimeTokenGenerationSuccessHandler ottSuccessHandler() { + return new TestServerOneTimeTokenGenerationSuccessHandler(); + } + } @Configuration(proxyBeanMethods = false) @@ -310,7 +353,8 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { static class OneTimeTokenDifferentUrlsConfig { @Bean - SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { + SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http, + ServerOneTimeTokenGenerationSuccessHandler ottSuccessHandler) { // @formatter:off http .authorizeExchange((authorize) -> authorize @@ -319,7 +363,7 @@ SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { ) .oneTimeTokenLogin((ott) -> ott .tokenGeneratingUrl("/generateurl") - .tokenGenerationSuccessHandler(new TestServerOneTimeTokenGenerationSuccessHandler("/redirected")) + .tokenGenerationSuccessHandler(ottSuccessHandler) .loginProcessingUrl("/loginprocessingurl") .authenticationSuccessHandler(new RedirectServerAuthenticationSuccessHandler("/authenticated")) ); @@ -327,6 +371,11 @@ SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { return http.build(); } + @Bean + TestServerOneTimeTokenGenerationSuccessHandler ottSuccessHandler() { + return new TestServerOneTimeTokenGenerationSuccessHandler("/redirected"); + } + } @Configuration(proxyBeanMethods = false) @@ -336,7 +385,8 @@ SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { static class OneTimeTokenFormLoginConfig { @Bean - SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { + SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http, + ServerOneTimeTokenGenerationSuccessHandler ottSuccessHandler) { // @formatter:off http .authorizeExchange((authorize) -> authorize @@ -345,12 +395,17 @@ SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { ) .formLogin(Customizer.withDefaults()) .oneTimeTokenLogin((ott) -> ott - .tokenGenerationSuccessHandler(new TestServerOneTimeTokenGenerationSuccessHandler()) + .tokenGenerationSuccessHandler(ottSuccessHandler) ); // @formatter:on return http.build(); } + @Bean + TestServerOneTimeTokenGenerationSuccessHandler ottSuccessHandler() { + return new TestServerOneTimeTokenGenerationSuccessHandler(); + } + } @Configuration(proxyBeanMethods = false) @@ -385,10 +440,44 @@ ReactiveUserDetailsService userDetailsService() { } + @Configuration(proxyBeanMethods = false) + @EnableWebFlux + @EnableWebFluxSecurity + @Import(UserDetailsServiceConfig.class) + static class OneTimeTokenConfigWithCustomRequestResolver { + + @Bean + SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, + ServerOneTimeTokenGenerationSuccessHandler ottSuccessHandler) { + // @formatter:off + http + .authorizeExchange((authorize) -> authorize + .anyExchange() + .authenticated() + ) + .oneTimeTokenLogin((ott) -> ott + .tokenGenerationSuccessHandler(ottSuccessHandler) + ); + // @formatter:on + return http.build(); + } + + @Bean + ServerGenerateOneTimeTokenRequestResolver resolver() { + return Mockito.spy(new DefaultServerGenerateOneTimeTokenRequestResolver()); + } + + @Bean + TestServerOneTimeTokenGenerationSuccessHandler ottSuccessHandler() { + return new TestServerOneTimeTokenGenerationSuccessHandler(); + } + + } + private static class TestServerOneTimeTokenGenerationSuccessHandler implements ServerOneTimeTokenGenerationSuccessHandler { - private static OneTimeToken lastToken; + private OneTimeToken lastToken; private final ServerOneTimeTokenGenerationSuccessHandler delegate; @@ -402,7 +491,7 @@ private static class TestServerOneTimeTokenGenerationSuccessHandler @Override public Mono handle(ServerWebExchange exchange, OneTimeToken oneTimeToken) { - lastToken = oneTimeToken; + this.lastToken = oneTimeToken; return this.delegate.handle(exchange, oneTimeToken); } 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 index 674a1ec570e..61958d476cd 100644 --- 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 @@ -16,18 +16,17 @@ package org.springframework.security.config.web.server -import org.assertj.core.api.Assertions import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import reactor.core.publisher.Mono - +import org.mockito.ArgumentMatchers +import org.mockito.Mockito +import org.mockito.Mockito.verify 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.context.ApplicationContext import org.springframework.http.MediaType -import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest import org.springframework.security.authentication.ott.OneTimeToken import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity import org.springframework.security.config.test.SpringTestContext @@ -36,10 +35,10 @@ import org.springframework.security.core.userdetails.MapReactiveUserDetailsServi 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.authentication.ott.DefaultServerGenerateOneTimeTokenRequestResolver -import org.springframework.security.web.server.authentication.ott.ServerGenerateOneTimeTokenRequestResolver import org.springframework.security.web.server.SecurityWebFilterChain import org.springframework.security.web.server.authentication.RedirectServerAuthenticationSuccessHandler +import org.springframework.security.web.server.authentication.ott.DefaultServerGenerateOneTimeTokenRequestResolver +import org.springframework.security.web.server.authentication.ott.ServerGenerateOneTimeTokenRequestResolver 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 @@ -47,9 +46,7 @@ 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 -import java.time.Duration -import java.time.Instant -import java.time.ZoneOffset +import reactor.core.publisher.Mono /** * Tests for [ServerOneTimeTokenLoginDsl] @@ -102,7 +99,7 @@ class ServerOneTimeTokenLoginDslTests { .is3xxRedirection() .expectHeader().valueEquals("Location", "/login/ott") - val token = TestServerOneTimeTokenGenerationSuccessHandler.lastToken?.tokenValue + val token = lastToken()!!.tokenValue client.mutateWith(SecurityMockServerConfigurers.csrf()) .post() @@ -136,7 +133,7 @@ class ServerOneTimeTokenLoginDslTests { .is3xxRedirection() .expectHeader().valueEquals("Location", "/redirected") - val token = TestServerOneTimeTokenGenerationSuccessHandler.lastToken?.tokenValue + val token = lastToken()!!.tokenValue client.mutateWith(SecurityMockServerConfigurers.csrf()) .post() @@ -154,8 +151,8 @@ class ServerOneTimeTokenLoginDslTests { } @Test - fun `oneTimeToken when custom token expiration time set then authenticate`() { - spring.register(OneTimeTokenConfigWithCustomTokenExpirationTime::class.java).autowire() + fun `oneTimeToken when custom request resolver set then custom resolver use`() { + spring.register(OneTimeTokenConfigWithCustomRequestResolver::class.java).autowire() // @formatter:off client.mutateWith(SecurityMockServerConfigurers.csrf()) @@ -171,29 +168,18 @@ class ServerOneTimeTokenLoginDslTests { .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 + val resolver = spring.context + .getBean(ServerGenerateOneTimeTokenRequestResolver::class.java) - Assertions.assertThat(getCurrentMinutes(token!!.expiresAt)).isEqualTo(10) + verify(resolver, Mockito.times(1)) + .resolve(ArgumentMatchers.any(ServerWebExchange::class.java)) + // @formatter:on } - private fun getCurrentMinutes(expiresAt:Instant): Int { - val expiresMinutes = expiresAt.atZone(ZoneOffset.UTC).minute - val currentMinutes = Instant.now().atZone(ZoneOffset.UTC).minute - return expiresMinutes - currentMinutes - } + private fun lastToken():OneTimeToken? = + spring.context.getBean(TestServerOneTimeTokenGenerationSuccessHandler::class.java) + .lastToken + @Configuration @EnableWebFlux @@ -202,18 +188,23 @@ class ServerOneTimeTokenLoginDslTests { open class OneTimeTokenConfig { @Bean - open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + open fun springWebFilterChain(http: ServerHttpSecurity, + ottSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler): SecurityWebFilterChain { // @formatter:off return http { authorizeExchange { authorize(anyExchange, authenticated) } oneTimeTokenLogin { - tokenGenerationSuccessHandler = TestServerOneTimeTokenGenerationSuccessHandler() + tokenGenerationSuccessHandler = ottSuccessHandler } } // @formatter:on } + + @Bean + open fun ottSuccessHandler(): ServerOneTimeTokenGenerationSuccessHandler = + TestServerOneTimeTokenGenerationSuccessHandler() } @Configuration @@ -223,7 +214,8 @@ class ServerOneTimeTokenLoginDslTests { open class OneTimeTokenDifferentUrlsConfig { @Bean - open fun springWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + open fun springWebFilterChain(http: ServerHttpSecurity, + ottSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler): SecurityWebFilterChain { // @formatter:off return http { authorizeExchange { @@ -231,13 +223,17 @@ class ServerOneTimeTokenLoginDslTests { } oneTimeTokenLogin { tokenGeneratingUrl = "/generateurl" - tokenGenerationSuccessHandler = TestServerOneTimeTokenGenerationSuccessHandler("/redirected") + tokenGenerationSuccessHandler = ottSuccessHandler loginProcessingUrl = "/loginprocessingurl" authenticationSuccessHandler = RedirectServerAuthenticationSuccessHandler("/authenticated") } } // @formatter:on } + + @Bean + open fun ottSuccessHandler(): ServerOneTimeTokenGenerationSuccessHandler = + TestServerOneTimeTokenGenerationSuccessHandler("/redirected") } @Configuration(proxyBeanMethods = false) @@ -252,36 +248,33 @@ class ServerOneTimeTokenLoginDslTests { @EnableWebFlux @EnableWebFluxSecurity @Import(OneTimeTokenLoginSpecTests.UserDetailsServiceConfig::class) - open class OneTimeTokenConfigWithCustomTokenExpirationTime { + open class OneTimeTokenConfigWithCustomRequestResolver { @Bean - open fun securityWebFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain { + open fun securityWebFilterChain(http: ServerHttpSecurity, + ottSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler): SecurityWebFilterChain { // @formatter:off return http { authorizeExchange { authorize(anyExchange, authenticated) } oneTimeTokenLogin { - tokenGenerationSuccessHandler = TestServerOneTimeTokenGenerationSuccessHandler() + tokenGenerationSuccessHandler = ottSuccessHandler } } } @Bean - open fun resolver(): ServerGenerateOneTimeTokenRequestResolver { - val resolver = DefaultServerGenerateOneTimeTokenRequestResolver() - return ServerGenerateOneTimeTokenRequestResolver { exchange -> - resolver.resolve(exchange) - .map { request -> GenerateOneTimeTokenRequest(request.username, Duration.ofSeconds(600)) } - } - } + open fun resolver(): ServerGenerateOneTimeTokenRequestResolver = + Mockito.spy(DefaultServerGenerateOneTimeTokenRequestResolver()) + + @Bean + open fun ottSuccessHandler(): ServerOneTimeTokenGenerationSuccessHandler = + TestServerOneTimeTokenGenerationSuccessHandler() } private class TestServerOneTimeTokenGenerationSuccessHandler: ServerOneTimeTokenGenerationSuccessHandler { private var delegate: ServerRedirectOneTimeTokenGenerationSuccessHandler? = null - - companion object { - var lastToken: OneTimeToken? = null - } + var lastToken: OneTimeToken? = null constructor() { this.delegate = ServerRedirectOneTimeTokenGenerationSuccessHandler("/login/ott")