Add Support ServerGenerateOneTimeTokenRequestResolver

Closes gh-16488

Signed-off-by: Max Batischev <mblancer@mail.ru>
This commit is contained in:
Max Batischev 2025-01-27 18:18:30 +03:00 committed by Josh Cummings
parent 981e3fd779
commit be81377235
9 changed files with 345 additions and 14 deletions

View File

@ -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.HashMap;
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.AbstractAuthenticationToken;
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.LogoutWebFi
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 class ServerHttpSecurity {
private ServerSecurityContextRepository securityContextRepository;
private ServerGenerateOneTimeTokenRequestResolver requestResolver;
private String loginProcessingUrl = "/login/ott";
private String defaultSubmitPageUrl = "/login/ott";
@ -5985,6 +5991,7 @@ public class ServerHttpSecurity {
getTokenGenerationSuccessHandler());
generateFilter
.setRequestMatcher(ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, this.tokenGeneratingUrl));
generateFilter.setGenerateRequestResolver(getRequestResolver());
http.addFilterAt(generateFilter, SecurityWebFiltersOrder.ONE_TIME_TOKEN);
}
@ -6112,6 +6119,32 @@ public class ServerHttpSecurity {
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

View File

@ -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) }

View File

@ -271,10 +271,10 @@ public class OneTimeTokenLoginSpecTests {
@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.
""");

View File

@ -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

View File

@ -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))
}
}
----
======

View File

@ -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<GenerateOneTimeTokenRequest> 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;
}
}

View File

@ -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 @@ package org.springframework.security.web.server.authentication.ott;
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 @@ import org.springframework.web.server.WebFilterChain;
*/
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 final class GenerateOneTimeTokenWebFilter implements WebFilter {
// @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 final class GenerateOneTimeTokenWebFilter implements WebFilter {
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;
}
}

View File

@ -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<GenerateOneTimeTokenRequest> resolve(ServerWebExchange exchange);
}

View File

@ -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));
}
}