Skip to content

Add Support GenerateOneTimeTokenRequestResolver #16297

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -18,13 +18,15 @@

import java.util.Collections;
import java.util.Map;
import java.util.Objects;

import jakarta.servlet.http.HttpServletRequest;

import org.springframework.context.ApplicationContext;
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;
Expand All @@ -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;
Expand Down Expand Up @@ -79,6 +83,8 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>

private AuthenticationProvider authenticationProvider;

private GenerateOneTimeTokenRequestResolver requestResolver;

public OneTimeTokenLoginConfigurer(ApplicationContext context) {
this.context = context;
}
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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<H> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -16,6 +16,8 @@

package org.springframework.security.authentication.ott;

import java.time.Duration;

import org.springframework.util.Assert;

/**
Expand All @@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading