Skip to content

Commit 0dba7d6

Browse files
Add Support GenerateOneTimeTokenRequestResolver
Closes gh-16291 Signed-off-by: Max Batischev <mblancer@mail.ru>
1 parent e5ea75f commit 0dba7d6

File tree

13 files changed

+395
-12
lines changed

13 files changed

+395
-12
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java

+30-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,13 +18,15 @@
1818

1919
import java.util.Collections;
2020
import java.util.Map;
21+
import java.util.Objects;
2122

2223
import jakarta.servlet.http.HttpServletRequest;
2324

2425
import org.springframework.context.ApplicationContext;
2526
import org.springframework.http.HttpMethod;
2627
import org.springframework.security.authentication.AuthenticationManager;
2728
import org.springframework.security.authentication.AuthenticationProvider;
29+
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
2830
import org.springframework.security.authentication.ott.InMemoryOneTimeTokenService;
2931
import org.springframework.security.authentication.ott.OneTimeToken;
3032
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationProvider;
@@ -40,7 +42,9 @@
4042
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
4143
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
4244
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
45+
import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver;
4346
import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter;
47+
import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver;
4448
import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationConverter;
4549
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
4650
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
@@ -79,6 +83,8 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
7983

8084
private AuthenticationProvider authenticationProvider;
8185

86+
private GenerateOneTimeTokenRequestResolver requestResolver;
87+
8288
public OneTimeTokenLoginConfigurer(ApplicationContext context) {
8389
this.context = context;
8490
}
@@ -135,6 +141,7 @@ private void configureOttGenerateFilter(H http) {
135141
GenerateOneTimeTokenFilter generateFilter = new GenerateOneTimeTokenFilter(getOneTimeTokenService(http),
136142
getOneTimeTokenGenerationSuccessHandler(http));
137143
generateFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.tokenGeneratingUrl));
144+
generateFilter.setRequestResolver(getGenerateRequestResolver(http));
138145
http.addFilter(postProcess(generateFilter));
139146
http.addFilter(DefaultResourcesFilter.css());
140147
}
@@ -301,6 +308,28 @@ private AuthenticationFailureHandler getAuthenticationFailureHandler() {
301308
return this.authenticationFailureHandler;
302309
}
303310

311+
/**
312+
* Use this {@link GenerateOneTimeTokenRequestResolver} when resolving
313+
* {@link GenerateOneTimeTokenRequest} from {@link HttpServletRequest}. By default,
314+
* the {@link DefaultGenerateOneTimeTokenRequestResolver} is used.
315+
* @param requestResolver the {@link GenerateOneTimeTokenRequestResolver}
316+
* @since 6.5
317+
*/
318+
public OneTimeTokenLoginConfigurer<H> generateRequestResolver(GenerateOneTimeTokenRequestResolver requestResolver) {
319+
Assert.notNull(requestResolver, "requestResolver cannot be null");
320+
this.requestResolver = requestResolver;
321+
return this;
322+
}
323+
324+
private GenerateOneTimeTokenRequestResolver getGenerateRequestResolver(H http) {
325+
if (this.requestResolver != null) {
326+
return this.requestResolver;
327+
}
328+
GenerateOneTimeTokenRequestResolver bean = getBeanOrNull(http, GenerateOneTimeTokenRequestResolver.class);
329+
this.requestResolver = Objects.requireNonNullElseGet(bean, DefaultGenerateOneTimeTokenRequestResolver::new);
330+
return this.requestResolver;
331+
}
332+
304333
private OneTimeTokenService getOneTimeTokenService(H http) {
305334
if (this.oneTimeTokenService != null) {
306335
return this.oneTimeTokenService;

config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt

+8
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import org.springframework.security.config.annotation.web.configurers.ott.OneTim
2323
import org.springframework.security.web.authentication.AuthenticationConverter
2424
import org.springframework.security.web.authentication.AuthenticationFailureHandler
2525
import org.springframework.security.web.authentication.AuthenticationSuccessHandler
26+
import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver
2627
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
2728

2829
/**
@@ -34,6 +35,7 @@ import org.springframework.security.web.authentication.ott.OneTimeTokenGeneratio
3435
* @property authenticationConverter Use this [AuthenticationConverter] when converting incoming requests to an authentication
3536
* @property authenticationFailureHandler the [AuthenticationFailureHandler] to use when authentication
3637
* @property authenticationSuccessHandler the [AuthenticationSuccessHandler] to be used
38+
* @property generateRequestResolver the [GenerateOneTimeTokenRequestResolver] to be used
3739
* @property defaultSubmitPageUrl sets the URL that the default submit page will be generated
3840
* @property showDefaultSubmitPage configures whether the default one-time token submit page should be shown
3941
* @property loginProcessingUrl the URL to process the login request
@@ -47,6 +49,7 @@ class OneTimeTokenLoginDsl {
4749
var authenticationConverter: AuthenticationConverter? = null
4850
var authenticationFailureHandler: AuthenticationFailureHandler? = null
4951
var authenticationSuccessHandler: AuthenticationSuccessHandler? = null
52+
var generateRequestResolver: GenerateOneTimeTokenRequestResolver? = null
5053
var defaultSubmitPageUrl: String? = null
5154
var loginProcessingUrl: String? = null
5255
var tokenGeneratingUrl: String? = null
@@ -68,6 +71,11 @@ class OneTimeTokenLoginDsl {
6871
authenticationSuccessHandler
6972
)
7073
}
74+
generateRequestResolver?.also {
75+
oneTimeTokenLoginConfigurer.generateRequestResolver(
76+
generateRequestResolver
77+
)
78+
}
7179
defaultSubmitPageUrl?.also { oneTimeTokenLoginConfigurer.defaultSubmitPageUrl(defaultSubmitPageUrl) }
7280
showDefaultSubmitPage?.also { oneTimeTokenLoginConfigurer.showDefaultSubmitPage(showDefaultSubmitPage!!) }
7381
loginProcessingUrl?.also { oneTimeTokenLoginConfigurer.loginProcessingUrl(loginProcessingUrl) }

config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java

+56-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -17,6 +17,9 @@
1717
package org.springframework.security.config.annotation.web.configurers.ott;
1818

1919
import java.io.IOException;
20+
import java.time.Duration;
21+
import java.time.Instant;
22+
import java.time.ZoneOffset;
2023

2124
import jakarta.servlet.ServletException;
2225
import jakarta.servlet.http.HttpServletRequest;
@@ -29,6 +32,7 @@
2932
import org.springframework.context.annotation.Bean;
3033
import org.springframework.context.annotation.Configuration;
3134
import org.springframework.context.annotation.Import;
35+
import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest;
3236
import org.springframework.security.authentication.ott.OneTimeToken;
3337
import org.springframework.security.config.Customizer;
3438
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -40,6 +44,8 @@
4044
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
4145
import org.springframework.security.web.SecurityFilterChain;
4246
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
47+
import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver;
48+
import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver;
4349
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
4450
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler;
4551
import org.springframework.security.web.csrf.CsrfToken;
@@ -194,6 +200,55 @@ Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL.
194200
""");
195201
}
196202

203+
@Test
204+
void oneTimeTokenWhenCustomTokenExpirationTimeSetThenAuthenticate() throws Exception {
205+
this.spring.register(OneTimeTokenConfigWithCustomTokenExpirationTime.class).autowire();
206+
this.mvc.perform(post("/ott/generate").param("username", "user").with(csrf()))
207+
.andExpectAll(status().isFound(), redirectedUrl("/login/ott"));
208+
209+
OneTimeToken token = TestOneTimeTokenGenerationSuccessHandler.lastToken;
210+
211+
this.mvc.perform(post("/login/ott").param("token", token.getTokenValue()).with(csrf()))
212+
.andExpectAll(status().isFound(), redirectedUrl("/"), authenticated());
213+
assertThat(getCurrentMinutes(token.getExpiresAt())).isEqualTo(10);
214+
}
215+
216+
private int getCurrentMinutes(Instant expiresAt) {
217+
int expiresMinutes = expiresAt.atZone(ZoneOffset.UTC).getMinute();
218+
int currentMinutes = Instant.now().atZone(ZoneOffset.UTC).getMinute();
219+
return expiresMinutes - currentMinutes;
220+
}
221+
222+
@Configuration(proxyBeanMethods = false)
223+
@EnableWebSecurity
224+
@Import(UserDetailsServiceConfig.class)
225+
static class OneTimeTokenConfigWithCustomTokenExpirationTime {
226+
227+
@Bean
228+
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
229+
// @formatter:off
230+
http
231+
.authorizeHttpRequests((authz) -> authz
232+
.anyRequest().authenticated()
233+
)
234+
.oneTimeTokenLogin((ott) -> ott
235+
.tokenGenerationSuccessHandler(new TestOneTimeTokenGenerationSuccessHandler())
236+
);
237+
// @formatter:on
238+
return http.build();
239+
}
240+
241+
@Bean
242+
GenerateOneTimeTokenRequestResolver generateOneTimeTokenRequestResolver() {
243+
DefaultGenerateOneTimeTokenRequestResolver delegate = new DefaultGenerateOneTimeTokenRequestResolver();
244+
return (request) -> {
245+
GenerateOneTimeTokenRequest generate = delegate.resolve(request);
246+
return new GenerateOneTimeTokenRequest(generate.getUsername(), Duration.ofSeconds(600));
247+
};
248+
}
249+
250+
}
251+
197252
@Configuration(proxyBeanMethods = false)
198253
@EnableWebSecurity
199254
@Import(UserDetailsServiceConfig.class)

config/src/test/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDslTests.kt

+58-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@ package org.springframework.security.config.annotation.web
1818

1919
import jakarta.servlet.http.HttpServletRequest
2020
import jakarta.servlet.http.HttpServletResponse
21+
import org.assertj.core.api.Assertions.assertThat
2122
import org.junit.jupiter.api.Test
2223
import org.junit.jupiter.api.extension.ExtendWith
2324
import org.springframework.beans.factory.annotation.Autowired
@@ -36,11 +37,15 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ
3637
import org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers
3738
import org.springframework.security.web.SecurityFilterChain
3839
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler
40+
import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver
3941
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler
4042
import org.springframework.security.web.authentication.ott.RedirectOneTimeTokenGenerationSuccessHandler
4143
import org.springframework.test.web.servlet.MockMvc
4244
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
4345
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
46+
import java.time.Duration
47+
import java.time.Instant
48+
import java.time.ZoneOffset
4449

4550
/**
4651
* Tests for [OneTimeTokenLoginDsl]
@@ -104,6 +109,32 @@ class OneTimeTokenLoginDslTests {
104109
)
105110
}
106111

112+
@Test
113+
fun `oneTimeToken when custom resolver set then use custom token`() {
114+
spring.register(OneTimeTokenConfigWithCustomTokenResolver::class.java).autowire()
115+
116+
this.mockMvc.perform(
117+
MockMvcRequestBuilders.post("/ott/generate").param("username", "user")
118+
.with(SecurityMockMvcRequestPostProcessors.csrf())
119+
).andExpectAll(
120+
MockMvcResultMatchers
121+
.status()
122+
.isFound(),
123+
MockMvcResultMatchers
124+
.redirectedUrl("/login/ott")
125+
)
126+
127+
val token = TestOneTimeTokenGenerationSuccessHandler.lastToken
128+
129+
assertThat(getCurrentMinutes(token!!.expiresAt)).isEqualTo(10)
130+
}
131+
132+
private fun getCurrentMinutes(expiresAt: Instant): Int {
133+
val expiresMinutes = expiresAt.atZone(ZoneOffset.UTC).minute
134+
val currentMinutes = Instant.now().atZone(ZoneOffset.UTC).minute
135+
return expiresMinutes - currentMinutes
136+
}
137+
107138
@Configuration
108139
@EnableWebSecurity
109140
@Import(UserDetailsServiceConfig::class)
@@ -125,6 +156,32 @@ class OneTimeTokenLoginDslTests {
125156
}
126157
}
127158

159+
@Configuration
160+
@EnableWebSecurity
161+
@Import(UserDetailsServiceConfig::class)
162+
open class OneTimeTokenConfigWithCustomTokenResolver {
163+
164+
@Bean
165+
open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
166+
// @formatter:off
167+
http {
168+
authorizeHttpRequests {
169+
authorize(anyRequest, authenticated)
170+
}
171+
oneTimeTokenLogin {
172+
oneTimeTokenGenerationSuccessHandler = TestOneTimeTokenGenerationSuccessHandler()
173+
generateRequestResolver = DefaultGenerateOneTimeTokenRequestResolver().apply {
174+
this.setExpiresIn(Duration.ofMinutes(10))
175+
}
176+
}
177+
}
178+
// @formatter:on
179+
return http.build()
180+
}
181+
182+
183+
}
184+
128185
@EnableWebSecurity
129186
@Configuration(proxyBeanMethods = false)
130187
@Import(UserDetailsServiceConfig::class)

core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java

+16-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,23 +18,38 @@
1818

1919
import org.springframework.util.Assert;
2020

21+
import java.time.Duration;
22+
2123
/**
2224
* Class to store information related to an One-Time Token authentication request
2325
*
2426
* @author Marcus da Coregio
2527
* @since 6.4
2628
*/
2729
public class GenerateOneTimeTokenRequest {
30+
private static final Duration DEFAULT_EXPIRES_IN = Duration.ofMinutes(5);
2831

2932
private final String username;
3033

34+
private final Duration expiresIn;
35+
3136
public GenerateOneTimeTokenRequest(String username) {
37+
this(username, DEFAULT_EXPIRES_IN);
38+
}
39+
40+
public GenerateOneTimeTokenRequest(String username, Duration expiresIn) {
3241
Assert.hasText(username, "username cannot be empty");
42+
Assert.notNull(expiresIn, "expiresIn cannot be null");
3343
this.username = username;
44+
this.expiresIn = expiresIn;
3445
}
3546

3647
public String getUsername() {
3748
return this.username;
3849
}
3950

51+
public Duration getExpiresIn() {
52+
return this.expiresIn;
53+
}
54+
4055
}

core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -44,8 +44,8 @@ public final class InMemoryOneTimeTokenService implements OneTimeTokenService {
4444
@NonNull
4545
public OneTimeToken generate(GenerateOneTimeTokenRequest request) {
4646
String token = UUID.randomUUID().toString();
47-
Instant fiveMinutesFromNow = this.clock.instant().plusSeconds(300);
48-
OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow);
47+
Instant expiresAt = this.clock.instant().plus(request.getExpiresIn());
48+
OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), expiresAt);
4949
this.oneTimeTokenByToken.put(token, ott);
5050
cleanExpiredTokensIfNeeded();
5151
return ott;

core/src/main/java/org/springframework/security/authentication/ott/JdbcOneTimeTokenService.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -132,8 +132,8 @@ public void setCleanupCron(String cleanupCron) {
132132
public OneTimeToken generate(GenerateOneTimeTokenRequest request) {
133133
Assert.notNull(request, "generateOneTimeTokenRequest cannot be null");
134134
String token = UUID.randomUUID().toString();
135-
Instant fiveMinutesFromNow = this.clock.instant().plus(Duration.ofMinutes(5));
136-
OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow);
135+
Instant expiresAt = this.clock.instant().plus(request.getExpiresIn());
136+
OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), expiresAt);
137137
insertOneTimeToken(oneTimeToken);
138138
return oneTimeToken;
139139
}

0 commit comments

Comments
 (0)