Skip to content

Commit 474b5e1

Browse files
franticticktickrwinch
authored andcommitted
Add Support GenerateOneTimeTokenRequestResolver
Closes gh-16291 Signed-off-by: Max Batischev <[email protected]>
1 parent 68c8a5a commit 474b5e1

File tree

13 files changed

+398
-13
lines changed

13 files changed

+398
-13
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

+17-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.
@@ -16,6 +16,8 @@
1616

1717
package org.springframework.security.authentication.ott;
1818

19+
import java.time.Duration;
20+
1921
import org.springframework.util.Assert;
2022

2123
/**
@@ -26,15 +28,29 @@
2628
*/
2729
public class GenerateOneTimeTokenRequest {
2830

31+
private static final Duration DEFAULT_EXPIRES_IN = Duration.ofMinutes(5);
32+
2933
private final String username;
3034

35+
private final Duration expiresIn;
36+
3137
public GenerateOneTimeTokenRequest(String username) {
38+
this(username, DEFAULT_EXPIRES_IN);
39+
}
40+
41+
public GenerateOneTimeTokenRequest(String username, Duration expiresIn) {
3242
Assert.hasText(username, "username cannot be empty");
43+
Assert.notNull(expiresIn, "expiresIn cannot be null");
3344
this.username = username;
45+
this.expiresIn = expiresIn;
3446
}
3547

3648
public String getUsername() {
3749
return this.username;
3850
}
3951

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

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-4
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.
@@ -21,7 +21,6 @@
2121
import java.sql.Timestamp;
2222
import java.sql.Types;
2323
import java.time.Clock;
24-
import java.time.Duration;
2524
import java.time.Instant;
2625
import java.util.ArrayList;
2726
import java.util.List;
@@ -132,8 +131,8 @@ public void setCleanupCron(String cleanupCron) {
132131
public OneTimeToken generate(GenerateOneTimeTokenRequest request) {
133132
Assert.notNull(request, "generateOneTimeTokenRequest cannot be null");
134133
String token = UUID.randomUUID().toString();
135-
Instant fiveMinutesFromNow = this.clock.instant().plus(Duration.ofMinutes(5));
136-
OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow);
134+
Instant expiresAt = this.clock.instant().plus(request.getExpiresIn());
135+
OneTimeToken oneTimeToken = new DefaultOneTimeToken(token, request.getUsername(), expiresAt);
137136
insertOneTimeToken(oneTimeToken);
138137
return oneTimeToken;
139138
}

0 commit comments

Comments
 (0)