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..9961d978dd0 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 @@ -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,27 @@ private AuthenticationFailureHandler getAuthenticationFailureHandler() { return this.authenticationFailureHandler; } + /** + * Use this {@link GenerateOneTimeTokenRequestResolver} when resolving {@link GenerateOneTimeTokenRequest} from {@link HttpServletRequest}. + * By default, the {@link DefaultGenerateOneTimeTokenRequestResolver} is used. + * @since 6.5 + * @param requestResolver the {@link GenerateOneTimeTokenRequestResolver} + */ + 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/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..b951b2b59f7 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 @@ -17,6 +17,8 @@ package org.springframework.security.config.annotation.web.configurers.ott; import java.io.IOException; +import java.time.Instant; +import java.time.ZoneOffset; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -29,6 +31,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 +43,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 +199,54 @@ 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(), 600); + }; + } + } + @Configuration(proxyBeanMethods = false) @EnableWebSecurity @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..870f7435375 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 @@ -25,16 +25,35 @@ * @since 6.4 */ public class GenerateOneTimeTokenRequest { + private static final int DEFAULT_EXPIRES_IN = 300; private final String username; + private final int expiresIn; public GenerateOneTimeTokenRequest(String username) { Assert.hasText(username, "username cannot be empty"); this.username = username; + this.expiresIn = DEFAULT_EXPIRES_IN; + } + + /** + * Constructs an GenerateOneTimeTokenRequest with the specified username and expiresIn + * + * @param username username + * @param expiresIn one-time token expiration time (seconds) + */ + public GenerateOneTimeTokenRequest(String username, int expiresIn) { + Assert.hasText(username, "username cannot be empty"); + Assert.isTrue(expiresIn > 0, "expiresIn must be > 0"); + this.username = username; + this.expiresIn = expiresIn; } public String getUsername() { return this.username; } + public int 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..d4dcf75d35a 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 @@ -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().plusSeconds(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 014541373ad..f89b9702d41 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 @@ -132,8 +132,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().plusSeconds(request.getExpiresIn()); + OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), expiresAt); insertOneTimeToken(oneTimeToken); return oneTimeToken; } 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..e12681ec1d8 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolver.java @@ -0,0 +1,54 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.authentication.ott; + +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 int DEFAULT_EXPIRES_IN = 300; + + private int 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 (seconds) + * + * @param expiresIn one-time token expiration time + */ + public void setExpiresIn(int expiresIn) { + Assert.isTrue(expiresIn > 0, "expiresAt must be > 0"); + 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..41441953e3e 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 @@ -29,7 +29,6 @@ import org.springframework.security.authentication.ott.OneTimeTokenService; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; @@ -49,6 +48,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"); @@ -64,12 +65,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); return; } - String username = request.getParameter("username"); - if (!StringUtils.hasText(username)) { + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(request); + if(generateRequest == null) { filterChain.doFilter(request, response); return; } - GenerateOneTimeTokenRequest generateRequest = new GenerateOneTimeTokenRequest(username); OneTimeToken ott = this.tokenService.generate(generateRequest); this.tokenGenerationSuccessHandler.handle(request, response, ott); } @@ -83,4 +83,13 @@ public void setRequestMatcher(RequestMatcher requestMatcher) { this.requestMatcher = requestMatcher; } + /** + * Use the given {@link GenerateOneTimeTokenRequestResolver} to resolve {@link GenerateOneTimeTokenRequest}. + * @since 6.5 + * @param requestResolver {@link GenerateOneTimeTokenRequestResolver} + */ + 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..92d7e9352ed --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenRequestResolver.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.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..b55b5551d0d --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ott/DefaultGenerateOneTimeTokenRequestResolverTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.authentication.ott; + +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(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(600); + + GenerateOneTimeTokenRequest generateRequest = this.requestResolver.resolve(request); + + assertThat(generateRequest.getExpiresIn()).isEqualTo(600); + } +}