From a879f76db5cc5774b7c30201ad43dd8d653d2361 Mon Sep 17 00:00:00 2001 From: Max Batischev Date: Wed, 22 Jan 2025 15:20:06 +0300 Subject: [PATCH] Add Support GenerateOneTimeTokenRequestResolver Closes gh-16291 Signed-off-by: Max Batischev --- .../ott/OneTimeTokenLoginConfigurer.java | 31 ++++++++- .../annotation/web/OneTimeTokenLoginDsl.kt | 8 +++ .../ott/OneTimeTokenLoginConfigurerTests.java | 57 +++++++++++++++- .../web/OneTimeTokenLoginDslTests.kt | 59 +++++++++++++++- .../ott/GenerateOneTimeTokenRequest.java | 18 ++++- .../ott/InMemoryOneTimeTokenService.java | 6 +- .../ott/JdbcOneTimeTokenService.java | 7 +- .../servlet/authentication/onetimetoken.adoc | 34 ++++++++++ docs/modules/ROOT/pages/whats-new.adoc | 4 ++ ...ltGenerateOneTimeTokenRequestResolver.java | 58 ++++++++++++++++ .../ott/GenerateOneTimeTokenFilter.java | 21 +++++- .../GenerateOneTimeTokenRequestResolver.java | 41 ++++++++++++ ...erateOneTimeTokenRequestResolverTests.java | 67 +++++++++++++++++++ 13 files changed, 398 insertions(+), 13 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolver.java create mode 100644 web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenRequestResolver.java create mode 100644 web/src/test/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolverTests.java 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 15718bf51b5..654c277e49c 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 @@ -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 @@ import java.util.Collections; import java.util.Map; +import java.util.Objects; import jakarta.servlet.http.HttpServletRequest; @@ -25,6 +26,7 @@ import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; import org.springframework.security.authentication.ott.InMemoryOneTimeTokenService; import org.springframework.security.authentication.ott.OneTimeToken; import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationProvider; @@ -40,7 +42,9 @@ import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver; import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter; +import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver; import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationConverter; import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; @@ -79,6 +83,8 @@ public final class OneTimeTokenLoginConfigurer> private AuthenticationProvider authenticationProvider; + private GenerateOneTimeTokenRequestResolver requestResolver; + public OneTimeTokenLoginConfigurer(ApplicationContext context) { this.context = context; } @@ -135,6 +141,7 @@ private void configureOttGenerateFilter(H http) { GenerateOneTimeTokenFilter generateFilter = new GenerateOneTimeTokenFilter(getOneTimeTokenService(http), getOneTimeTokenGenerationSuccessHandler(http)); generateFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.tokenGeneratingUrl)); + generateFilter.setRequestResolver(getGenerateRequestResolver(http)); http.addFilter(postProcess(generateFilter)); http.addFilter(DefaultResourcesFilter.css()); } @@ -301,6 +308,28 @@ private AuthenticationFailureHandler getAuthenticationFailureHandler() { return this.authenticationFailureHandler; } + /** + * Use this {@link GenerateOneTimeTokenRequestResolver} when resolving + * {@link GenerateOneTimeTokenRequest} from {@link HttpServletRequest}. By default, + * the {@link DefaultGenerateOneTimeTokenRequestResolver} is used. + * @param requestResolver the {@link GenerateOneTimeTokenRequestResolver} + * @since 6.5 + */ + public OneTimeTokenLoginConfigurer generateRequestResolver(GenerateOneTimeTokenRequestResolver requestResolver) { + Assert.notNull(requestResolver, "requestResolver cannot be null"); + this.requestResolver = requestResolver; + return this; + } + + private GenerateOneTimeTokenRequestResolver getGenerateRequestResolver(H http) { + if (this.requestResolver != null) { + return this.requestResolver; + } + GenerateOneTimeTokenRequestResolver bean = getBeanOrNull(http, GenerateOneTimeTokenRequestResolver.class); + this.requestResolver = Objects.requireNonNullElseGet(bean, DefaultGenerateOneTimeTokenRequestResolver::new); + return this.requestResolver; + } + private OneTimeTokenService getOneTimeTokenService(H http) { if (this.oneTimeTokenService != null) { return this.oneTimeTokenService; diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt index 025e65e7410..2345bc5a679 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt @@ -23,6 +23,7 @@ import org.springframework.security.config.annotation.web.configurers.ott.OneTim import org.springframework.security.web.authentication.AuthenticationConverter import org.springframework.security.web.authentication.AuthenticationFailureHandler import org.springframework.security.web.authentication.AuthenticationSuccessHandler +import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler /** @@ -34,6 +35,7 @@ import org.springframework.security.web.authentication.ott.OneTimeTokenGeneratio * @property authenticationConverter Use this [AuthenticationConverter] when converting incoming requests to an authentication * @property authenticationFailureHandler the [AuthenticationFailureHandler] to use when authentication * @property authenticationSuccessHandler the [AuthenticationSuccessHandler] to be used + * @property generateRequestResolver the [GenerateOneTimeTokenRequestResolver] 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 @@ -47,6 +49,7 @@ class OneTimeTokenLoginDsl { var authenticationConverter: AuthenticationConverter? = null var authenticationFailureHandler: AuthenticationFailureHandler? = null var authenticationSuccessHandler: AuthenticationSuccessHandler? = null + var generateRequestResolver: GenerateOneTimeTokenRequestResolver? = null var defaultSubmitPageUrl: String? = null var loginProcessingUrl: String? = null var tokenGeneratingUrl: String? = null @@ -68,6 +71,11 @@ class OneTimeTokenLoginDsl { authenticationSuccessHandler ) } + generateRequestResolver?.also { + oneTimeTokenLoginConfigurer.generateRequestResolver( + generateRequestResolver + ) + } defaultSubmitPageUrl?.also { oneTimeTokenLoginConfigurer.defaultSubmitPageUrl(defaultSubmitPageUrl) } showDefaultSubmitPage?.also { oneTimeTokenLoginConfigurer.showDefaultSubmitPage(showDefaultSubmitPage!!) } loginProcessingUrl?.also { oneTimeTokenLoginConfigurer.loginProcessingUrl(loginProcessingUrl) } 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 f89a37ae40f..b3c97b92015 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 @@ -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. @@ -17,6 +17,9 @@ package org.springframework.security.config.annotation.web.configurers.ott; import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -29,6 +32,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; import org.springframework.security.authentication.ott.OneTimeToken; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -40,6 +44,8 @@ import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver; +import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver; import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.csrf.CsrfToken; @@ -194,6 +200,55 @@ Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. """); } + @Test + void oneTimeTokenWhenCustomTokenExpirationTimeSetThenAuthenticate() throws Exception { + this.spring.register(OneTimeTokenConfigWithCustomTokenExpirationTime.class).autowire(); + this.mvc.perform(post("/ott/generate").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login/ott")); + + OneTimeToken token = TestOneTimeTokenGenerationSuccessHandler.lastToken; + + this.mvc.perform(post("/login/ott").param("token", token.getTokenValue()).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated()); + assertThat(getCurrentMinutes(token.getExpiresAt())).isEqualTo(10); + } + + private int getCurrentMinutes(Instant expiresAt) { + int expiresMinutes = expiresAt.atZone(ZoneOffset.UTC).getMinute(); + int currentMinutes = Instant.now().atZone(ZoneOffset.UTC).getMinute(); + return expiresMinutes - currentMinutes; + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @Import(UserDetailsServiceConfig.class) + static class OneTimeTokenConfigWithCustomTokenExpirationTime { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authz) -> authz + .anyRequest().authenticated() + ) + .oneTimeTokenLogin((ott) -> ott + .tokenGenerationSuccessHandler(new TestOneTimeTokenGenerationSuccessHandler()) + ); + // @formatter:on + return http.build(); + } + + @Bean + GenerateOneTimeTokenRequestResolver generateOneTimeTokenRequestResolver() { + DefaultGenerateOneTimeTokenRequestResolver delegate = new DefaultGenerateOneTimeTokenRequestResolver(); + return (request) -> { + GenerateOneTimeTokenRequest generate = delegate.resolve(request); + return new GenerateOneTimeTokenRequest(generate.getUsername(), Duration.ofSeconds(600)); + }; + } + + } + @Configuration(proxyBeanMethods = false) @EnableWebSecurity @Import(UserDetailsServiceConfig.class) 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 07833e283f9..a8b52c51371 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 @@ -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.annotation.web import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.beans.factory.annotation.Autowired @@ -36,11 +37,15 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler +import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset /** * Tests for [OneTimeTokenLoginDsl] @@ -104,6 +109,32 @@ class OneTimeTokenLoginDslTests { ) } + @Test + fun `oneTimeToken when custom resolver set then use custom token`() { + spring.register(OneTimeTokenConfigWithCustomTokenResolver::class.java).autowire() + + this.mockMvc.perform( + MockMvcRequestBuilders.post("/ott/generate").param("username", "user") + .with(SecurityMockMvcRequestPostProcessors.csrf()) + ).andExpectAll( + MockMvcResultMatchers + .status() + .isFound(), + MockMvcResultMatchers + .redirectedUrl("/login/ott") + ) + + val token = TestOneTimeTokenGenerationSuccessHandler.lastToken + + 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 @EnableWebSecurity @Import(UserDetailsServiceConfig::class) @@ -125,6 +156,32 @@ class OneTimeTokenLoginDslTests { } } + @Configuration + @EnableWebSecurity + @Import(UserDetailsServiceConfig::class) + open class OneTimeTokenConfigWithCustomTokenResolver { + + @Bean + open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + // @formatter:off + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + oneTimeTokenLogin { + oneTimeTokenGenerationSuccessHandler = TestOneTimeTokenGenerationSuccessHandler() + generateRequestResolver = DefaultGenerateOneTimeTokenRequestResolver().apply { + this.setExpiresIn(Duration.ofMinutes(10)) + } + } + } + // @formatter:on + return http.build() + } + + + } + @EnableWebSecurity @Configuration(proxyBeanMethods = false) @Import(UserDetailsServiceConfig::class) diff --git a/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java b/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java index c9a023ef832..b03e65dd188 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.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. @@ -16,6 +16,8 @@ package org.springframework.security.authentication.ott; +import java.time.Duration; + import org.springframework.util.Assert; /** @@ -26,15 +28,29 @@ */ public class GenerateOneTimeTokenRequest { + private static final Duration DEFAULT_EXPIRES_IN = Duration.ofMinutes(5); + private final String username; + private final Duration expiresIn; + public GenerateOneTimeTokenRequest(String username) { + this(username, DEFAULT_EXPIRES_IN); + } + + public GenerateOneTimeTokenRequest(String username, Duration expiresIn) { Assert.hasText(username, "username cannot be empty"); + Assert.notNull(expiresIn, "expiresIn cannot be null"); this.username = username; + this.expiresIn = expiresIn; } public String getUsername() { return this.username; } + public Duration getExpiresIn() { + return this.expiresIn; + } + } diff --git a/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java b/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java index 6365bdb5f1d..0d679617946 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.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. @@ -44,8 +44,8 @@ public final class InMemoryOneTimeTokenService implements OneTimeTokenService { @NonNull public OneTimeToken generate(GenerateOneTimeTokenRequest request) { String token = UUID.randomUUID().toString(); - Instant fiveMinutesFromNow = this.clock.instant().plusSeconds(300); - OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow); + Instant expiresAt = this.clock.instant().plus(request.getExpiresIn()); + OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), expiresAt); this.oneTimeTokenByToken.put(token, ott); cleanExpiredTokensIfNeeded(); return ott; diff --git a/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java b/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java index 4cf6753631b..a58665bd1e5 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.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,7 +21,6 @@ import java.sql.Timestamp; import java.sql.Types; import java.time.Clock; -import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -132,8 +131,8 @@ public void setCleanupCron(String cleanupCron) { public OneTimeToken generate(GenerateOneTimeTokenRequest request) { Assert.notNull(request, "generateOneTimeTokenRequest cannot be null"); String token = UUID.randomUUID().toString(); - Instant fiveMinutesFromNow = this.clock.instant().plus(Duration.ofMinutes(5)); - OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow); + Instant expiresAt = this.clock.instant().plus(request.getExpiresIn()); + OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), expiresAt); insertOneTimeToken(oneTimeToken); return oneTimeToken; } diff --git a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc index db67a98527e..b799f6637a3 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc @@ -545,3 +545,37 @@ class MagicLinkOneTimeTokenGenerationSuccessHandler : OneTimeTokenGenerationSucc } ---- ====== + +[[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 GenerateOneTimeTokenRequestResolver as a @Bean, like so: +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +GenerateOneTimeTokenRequestResolver generateOneTimeTokenRequestResolver() { + DefaultGenerateOneTimeTokenRequestResolver delegate = new DefaultGenerateOneTimeTokenRequestResolver(); + return (request) -> { + GenerateOneTimeTokenRequest generate = delegate.resolve(request); + return new GenerateOneTimeTokenRequest(generate.getUsername(), Duration.ofSeconds(600)); + }; +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun generateRequestResolver() : GenerateOneTimeTokenRequestResolver { + return DefaultGenerateOneTimeTokenRequestResolver().apply { + this.setExpiresIn(Duration.ofMinutes(10)) + } +} +---- +====== diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index 01f5e851505..ce394207742 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -20,3 +20,7 @@ Note that this may affect reports that operate on this key name. * https://github.com/spring-projects/spring-security/pull/16282[gh-16282] - xref:servlet/authentication/passkeys.adoc#passkeys-configuration-persistence[JDBC Persistence] for WebAuthn/Passkeys * https://github.com/spring-projects/spring-security/pull/16397[gh-16397] - Added the ability to configure a custom `HttpMessageConverter` for Passkeys using the optional xref:servlet/authentication/passkeys.adoc#passkeys-configuration[`messageConverter` property] on the `webAuthn` DSL. * https://github.com/spring-projects/spring-security/pull/16396[gh-16396] - Added the ability to configure a custom xref:servlet/authentication/passkeys.adoc#passkeys-configuration-pkccor[`PublicKeyCredentialCreationOptionsRepository`] + +== One-Time Token Login + +* https://github.com/spring-projects/spring-security/issues/16291[gh-16291] - `oneTimeTokenLogin()` now supports customizing GenerateOneTimeTokenRequest xref:servlet/authentication/onetimetoken.adoc#customize-generate-token-request[via GenerateOneTimeTokenRequestResolver] diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolver.java b/web/src/main/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolver.java new file mode 100644 index 00000000000..87c5034905c --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolver.java @@ -0,0 +1,58 @@ +/* + * 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.authentication.ott; + +import java.time.Duration; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Default implementation of {@link GenerateOneTimeTokenRequestResolver}. Resolves + * {@link GenerateOneTimeTokenRequest} from username parameter. + * + * @author Max Batischev + * @since 6.5 + */ +public final class DefaultGenerateOneTimeTokenRequestResolver implements GenerateOneTimeTokenRequestResolver { + + private static final Duration DEFAULT_EXPIRES_IN = Duration.ofMinutes(5); + + private Duration expiresIn = DEFAULT_EXPIRES_IN; + + @Override + public GenerateOneTimeTokenRequest resolve(HttpServletRequest request) { + String username = request.getParameter("username"); + if (!StringUtils.hasText(username)) { + return null; + } + return new GenerateOneTimeTokenRequest(username, this.expiresIn); + } + + /** + * Sets one-time token expiration time + * @param expiresIn one-time token expiration time + */ + public void setExpiresIn(Duration expiresIn) { + Assert.notNull(expiresIn, "expiresAt cannot be null"); + this.expiresIn = expiresIn; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java index 8c9cbf65b6e..2ad462993e5 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.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. @@ -49,6 +49,8 @@ public final class GenerateOneTimeTokenFilter extends OncePerRequestFilter { private RequestMatcher requestMatcher = antMatcher(HttpMethod.POST, "/ott/generate"); + private GenerateOneTimeTokenRequestResolver requestResolver = new DefaultGenerateOneTimeTokenRequestResolver(); + public GenerateOneTimeTokenFilter(OneTimeTokenService tokenService, OneTimeTokenGenerationSuccessHandler tokenGenerationSuccessHandler) { Assert.notNull(tokenService, "tokenService cannot be null"); @@ -69,8 +71,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); return; } - GenerateOneTimeTokenRequest generateRequest = new GenerateOneTimeTokenRequest(username); + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(request); OneTimeToken ott = this.tokenService.generate(generateRequest); + if (generateRequest == null) { + filterChain.doFilter(request, response); + return; + } this.tokenGenerationSuccessHandler.handle(request, response, ott); } @@ -83,4 +89,15 @@ public void setRequestMatcher(RequestMatcher requestMatcher) { this.requestMatcher = requestMatcher; } + /** + * Use the given {@link GenerateOneTimeTokenRequestResolver} to resolve + * {@link GenerateOneTimeTokenRequest}. + * @param requestResolver {@link GenerateOneTimeTokenRequestResolver} + * @since 6.5 + */ + public void setRequestResolver(GenerateOneTimeTokenRequestResolver requestResolver) { + Assert.notNull(requestResolver, "requestResolver cannot be null"); + this.requestResolver = requestResolver; + } + } diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenRequestResolver.java b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenRequestResolver.java new file mode 100644 index 00000000000..9fa8873ed2c --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenRequestResolver.java @@ -0,0 +1,41 @@ +/* + * 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.authentication.ott; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.lang.Nullable; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; + +/** + * A strategy for resolving a {@link GenerateOneTimeTokenRequest} from the + * {@link HttpServletRequest}. + * + * @author Max Batischev + * @since 6.5 + */ +public interface GenerateOneTimeTokenRequestResolver { + + /** + * Resolves {@link GenerateOneTimeTokenRequest} from {@link HttpServletRequest} + * @param request {@link HttpServletRequest} to resolve + * @return {@link GenerateOneTimeTokenRequest} + */ + @Nullable + GenerateOneTimeTokenRequest resolve(HttpServletRequest request); + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolverTests.java b/web/src/test/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolverTests.java new file mode 100644 index 00000000000..12a491230ea --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolverTests.java @@ -0,0 +1,67 @@ +/* + * 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.authentication.ott; + +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link DefaultGenerateOneTimeTokenRequestResolver} + * + * @author Max Batischev + */ +public class DefaultGenerateOneTimeTokenRequestResolverTests { + + private final DefaultGenerateOneTimeTokenRequestResolver requestResolver = new DefaultGenerateOneTimeTokenRequestResolver(); + + @Test + void resolveWhenUsernameParameterIsPresentThenResolvesGenerateRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("username", "test"); + + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(request); + + assertThat(generateRequest).isNotNull(); + assertThat(generateRequest.getUsername()).isEqualTo("test"); + assertThat(generateRequest.getExpiresIn()).isEqualTo(Duration.ofSeconds(300)); + } + + @Test + void resolveWhenUsernameParameterIsNotPresentThenNull() { + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(new MockHttpServletRequest()); + + assertThat(generateRequest).isNull(); + } + + @Test + void resolveWhenExpiresInSetThenResolvesGenerateRequest() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("username", "test"); + this.requestResolver.setExpiresIn(Duration.ofSeconds(600)); + + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(request); + + assertThat(generateRequest.getExpiresIn()).isEqualTo(Duration.ofSeconds(600)); + } + +}