diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/HttpSecurityBuilder.java b/config/src/main/java/org/springframework/security/config/annotation/web/HttpSecurityBuilder.java index 94c709f5b7d..99abae73bf5 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/HttpSecurityBuilder.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/HttpSecurityBuilder.java @@ -32,6 +32,7 @@ import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter; @@ -150,6 +151,7 @@ public interface HttpSecurityBuilder> * {@docRoot}/org/springframework/security/cas/web/CasAuthenticationFilter.html">CasAuthenticationFilter *
  • {@link org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter}
  • *
  • {@link org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter}
  • + *
  • {@link OneTimeTokenAuthenticationFilter}
  • *
  • {@link UsernamePasswordAuthenticationFilter}
  • *
  • {@link org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter}
  • *
  • {@link org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter}
  • diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java index 1604bff2fe5..d22fc5c62ad 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java @@ -29,12 +29,15 @@ import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter; +import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationRequestFilter; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter; import org.springframework.security.web.authentication.switchuser.SwitchUserFilter; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter; +import org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.authentication.www.DigestAuthenticationFilter; import org.springframework.security.web.context.SecurityContextHolderFilter; @@ -87,6 +90,7 @@ final class FilterOrderRegistration { this.filterToOrder.put( "org.springframework.security.saml2.provider.service.web.Saml2WebSsoAuthenticationRequestFilter", order.next()); + put(OneTimeTokenAuthenticationRequestFilter.class, order.next()); put(X509AuthenticationFilter.class, order.next()); put(AbstractPreAuthenticatedProcessingFilter.class, order.next()); this.filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter", order.next()); @@ -95,10 +99,12 @@ final class FilterOrderRegistration { this.filterToOrder.put( "org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter", order.next()); + put(OneTimeTokenAuthenticationFilter.class, order.next()); put(UsernamePasswordAuthenticationFilter.class, order.next()); order.next(); // gh-8105 put(DefaultLoginPageGeneratingFilter.class, order.next()); put(DefaultLogoutPageGeneratingFilter.class, order.next()); + put(DefaultOneTimeTokenSubmitPageGeneratingFilter.class, order.next()); put(ConcurrentSessionFilter.class, order.next()); put(DigestAuthenticationFilter.class, order.next()); this.filterToOrder.put( diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index 9d0333d24ee..ae12a753530 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * 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. @@ -72,6 +72,7 @@ import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.client.OidcLogoutConfigurer; import org.springframework.security.config.annotation.web.configurers.oauth2.server.resource.OAuth2ResourceServerConfigurer; +import org.springframework.security.config.annotation.web.configurers.ott.OneTimeTokenLoginConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LoginConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2LogoutConfigurer; import org.springframework.security.config.annotation.web.configurers.saml2.Saml2MetadataConfigurer; @@ -2978,6 +2979,13 @@ public HttpSecurity oauth2ResourceServer( return HttpSecurity.this; } + public HttpSecurity oneTimeTokenLogin( + Customizer> oneTimeTokenLoginConfigurerCustomizer) + throws Exception { + oneTimeTokenLoginConfigurerCustomizer.customize(getOrApply(new OneTimeTokenLoginConfigurer<>(getContext()))); + return HttpSecurity.this; + } + /** * Configures channel security. In order for this configuration to be useful at least * one mapping to a required channel must be provided. 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 new file mode 100644 index 00000000000..3558fbec09c --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java @@ -0,0 +1,348 @@ +/* + * 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.config.annotation.web.configurers.ott; + +import java.util.Collections; +import java.util.Map; + +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +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.InMemoryOneTimeTokenService; +import org.springframework.security.authentication.ott.OneTimeToken; +import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationProvider; +import org.springframework.security.authentication.ott.OneTimeTokenSender; +import org.springframework.security.authentication.ott.OneTimeTokenService; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.HttpSecurityBuilder; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetailsService; +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.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationConverter; +import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter; +import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationRequestFilter; +import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; +import org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; + +public final class OneTimeTokenLoginConfigurer> + extends AbstractHttpConfigurer, H> { + + private final Log logger = LogFactory.getLog(getClass()); + + private final ApplicationContext context; + + private OneTimeTokenService oneTimeTokenService; + + private AuthenticationConverter authenticationConverter = new OneTimeTokenAuthenticationConverter(); + + private AuthenticationFailureHandler authenticationFailureHandler; + + private AuthenticationSuccessHandler authenticationSuccessHandler = new SavedRequestAwareAuthenticationSuccessHandler(); + + private OneTimeTokenSender oneTimeTokenSender; + + private String submitPageUrl = "/login/ott"; + + private boolean submitPageEnabled = true; + + private String loginProcessingUrl = "/login/ott"; + + private String authenticationRequestUrl = "/ott/authenticate"; + + private String authenticationRequestRedirectUrl = "/login/ott"; + + private AuthenticationProvider authenticationProvider; + + public OneTimeTokenLoginConfigurer(ApplicationContext context) { + this.context = context; + } + + @Override + public void init(H http) { + AuthenticationProvider authenticationProvider = getAuthenticationProvider(http); + http.authenticationProvider(postProcess(authenticationProvider)); + configureDefaultLoginPage(http); + } + + private void configureDefaultLoginPage(H http) { + DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http + .getSharedObject(DefaultLoginPageGeneratingFilter.class); + if (loginPageGeneratingFilter == null) { + return; + } + loginPageGeneratingFilter.setOneTimeTokenEnabled(true); + loginPageGeneratingFilter.setOneTimeTokenAuthenticationRequestUrl(this.authenticationRequestUrl); + if (this.authenticationFailureHandler == null + && StringUtils.hasText(loginPageGeneratingFilter.getLoginPageUrl())) { + this.authenticationFailureHandler = new SimpleUrlAuthenticationFailureHandler( + loginPageGeneratingFilter.getLoginPageUrl() + "?error"); + } + } + + @Override + public void configure(H http) { + configureSubmitPage(http); + configureOttAuthenticationRequestFilter(http); + configureOttAuthenticationFilter(http); + } + + private void configureOttAuthenticationFilter(H http) { + AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); + OneTimeTokenAuthenticationFilter oneTimeTokenAuthenticationFilter = new OneTimeTokenAuthenticationFilter( + authenticationManager, this.authenticationConverter); + oneTimeTokenAuthenticationFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.loginProcessingUrl)); + oneTimeTokenAuthenticationFilter.setFailureHandler(getAuthenticationFailureHandler()); + oneTimeTokenAuthenticationFilter.setSuccessHandler(this.authenticationSuccessHandler); + http.addFilter(postProcess(oneTimeTokenAuthenticationFilter)); + } + + private void configureOttAuthenticationRequestFilter(H http) { + OneTimeTokenAuthenticationRequestFilter authenticationRequestFilter = new OneTimeTokenAuthenticationRequestFilter( + getOneTimeTokenService(http), getOneTimeTokenSender(http)); + authenticationRequestFilter.setRedirectUrl(this.authenticationRequestRedirectUrl); + authenticationRequestFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.authenticationRequestUrl)); + http.addFilter(postProcess(authenticationRequestFilter)); + } + + private void configureSubmitPage(H http) { + if (!this.submitPageEnabled) { + return; + } + DefaultOneTimeTokenSubmitPageGeneratingFilter submitPage = new DefaultOneTimeTokenSubmitPageGeneratingFilter(); + submitPage.setResolveHiddenInputs(this::hiddenInputs); + submitPage.setRequestMatcher(antMatcher(HttpMethod.GET, this.submitPageUrl)); + submitPage.setLoginProcessingUrl(this.loginProcessingUrl); + http.addFilter(postProcess(submitPage)); + } + + private AuthenticationProvider getAuthenticationProvider(H http) { + if (this.authenticationProvider != null) { + return this.authenticationProvider; + } + UserDetailsService userDetailsService = getContext().getBean(UserDetailsService.class); + this.authenticationProvider = new OneTimeTokenAuthenticationProvider(getOneTimeTokenService(http), + userDetailsService); + return this.authenticationProvider; + } + + /** + * Specifies the {@link AuthenticationProvider} to use when authenticating the user. + * @param authenticationProvider + */ + public OneTimeTokenLoginConfigurer authenticationProvider(AuthenticationProvider authenticationProvider) { + Assert.notNull(authenticationProvider, "authenticationProvider cannot be null"); + this.authenticationProvider = authenticationProvider; + return this; + } + + /** + * Specifies the URL that a One-Time Token authentication request will be processed. + * Defaults to {@code POST /ott/authenticate}. + * @param authenticationRequestUrl + */ + public OneTimeTokenLoginConfigurer authenticationRequestUrl(String authenticationRequestUrl) { + Assert.hasText(authenticationRequestUrl, "authenticationRequestUrl cannot be null or empty"); + this.authenticationRequestUrl = authenticationRequestUrl; + return this; + } + + /** + * Specifies the URL that the user-agent will be redirected after a successful + * One-Time Token authentication. Defaults to {@code POST /login/ott}. If you are + * using the default submit page make sure that you also configure + * {@link #submitPageUrl(String)} to this same URL. + * @param authenticationRequestRedirectUrl + */ + public OneTimeTokenLoginConfigurer authenticationRequestRedirectUrl(String authenticationRequestRedirectUrl) { + Assert.hasText(authenticationRequestRedirectUrl, "authenticationRequestRedirectUrl cannot be null or empty"); + this.authenticationRequestRedirectUrl = authenticationRequestRedirectUrl; + return this; + } + + /** + * Specifies the URL to process the login request, defaults to {@code /login/ott}. + * Only POST requests are processed, for that reason make sure that you pass a valid + * CSRF token if CSRF protection is enabled. + * @param loginProcessingUrl + * @see org.springframework.security.config.annotation.web.builders.HttpSecurity#csrf(Customizer) + */ + public OneTimeTokenLoginConfigurer loginProcessingUrl(String loginProcessingUrl) { + Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty"); + this.loginProcessingUrl = loginProcessingUrl; + return this; + } + + /** + * Configures whether the default one-time token submit page should be shown. This + * will prevent the {@link DefaultOneTimeTokenSubmitPageGeneratingFilter} to be + * configured. + * @param show + */ + public OneTimeTokenLoginConfigurer showSubmitPage(boolean show) { + this.submitPageEnabled = show; + return this; + } + + /** + * Sets the URL that the default submit page will be generated. Defaults to + * {@code /login/ott}. Note that if you don't want to generate the default submit page + * you should use {@link #showSubmitPage(boolean)}. + * @param submitPageUrl + */ + public OneTimeTokenLoginConfigurer submitPageUrl(String submitPageUrl) { + Assert.hasText(submitPageUrl, "submitPageUrl cannot be null or empty"); + this.submitPageUrl = submitPageUrl; + return this; + } + + /** + * Specifies the {@link OneTimeTokenSender} to send the generated {@link OneTimeToken} + * to the user + * @param oneTimeTokenSender + */ + public OneTimeTokenLoginConfigurer oneTimeTokenSender(OneTimeTokenSender oneTimeTokenSender) { + Assert.notNull(oneTimeTokenSender, "oneTimeTokenSender cannot be null"); + this.oneTimeTokenSender = oneTimeTokenSender; + return this; + } + + /** + * Configures the {@link OneTimeTokenService} used to generate and consume + * {@link OneTimeToken} + * @param oneTimeTokenService + */ + public OneTimeTokenLoginConfigurer oneTimeTokenService(OneTimeTokenService oneTimeTokenService) { + Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null"); + this.oneTimeTokenService = oneTimeTokenService; + return this; + } + + /** + * Use this {@link AuthenticationConverter} when converting incoming requests to an + * {@link Authentication}. By default, the {@link OneTimeTokenAuthenticationConverter} + * is used. + * @param authenticationConverter the {@link AuthenticationConverter} to use + */ + public OneTimeTokenLoginConfigurer authenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + return this; + } + + /** + * Specifies the {@link AuthenticationFailureHandler} to use when authentication + * fails. The default is redirecting to "/login?error" using + * {@link SimpleUrlAuthenticationFailureHandler} + * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} to use + * when authentication fails. + */ + public OneTimeTokenLoginConfigurer authenticationFailureHandler( + AuthenticationFailureHandler authenticationFailureHandler) { + Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null"); + this.authenticationFailureHandler = authenticationFailureHandler; + return this; + } + + /** + * Specifies the {@link AuthenticationSuccessHandler} to be used. The default is + * {@link SavedRequestAwareAuthenticationSuccessHandler} with no additional properties + * set. + * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler}. + */ + public OneTimeTokenLoginConfigurer authenticationSuccessHandler( + AuthenticationSuccessHandler authenticationSuccessHandler) { + Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); + this.authenticationSuccessHandler = authenticationSuccessHandler; + return this; + } + + private AuthenticationFailureHandler getAuthenticationFailureHandler() { + if (this.authenticationFailureHandler != null) { + return this.authenticationFailureHandler; + } + this.authenticationFailureHandler = new SimpleUrlAuthenticationFailureHandler("/login?error"); + return this.authenticationFailureHandler; + } + + private OneTimeTokenService getOneTimeTokenService(H http) { + if (this.oneTimeTokenService != null) { + return this.oneTimeTokenService; + } + OneTimeTokenService bean = getBeanOrNull(http, OneTimeTokenService.class); + if (bean != null) { + this.oneTimeTokenService = bean; + } + else { + this.logger.debug("Configuring InMemoryOneTimeTokenService for oneTimeTokenLogin()"); + this.oneTimeTokenService = new InMemoryOneTimeTokenService(); + } + return this.oneTimeTokenService; + } + + private OneTimeTokenSender getOneTimeTokenSender(H http) { + if (this.oneTimeTokenSender != null) { + return this.oneTimeTokenSender; + } + OneTimeTokenSender bean = getBeanOrNull(http, OneTimeTokenSender.class); + if (bean == null) { + throw new IllegalStateException("A OneTimeTokenSender is required for oneTimeTokenLogin(). " + + "Please define a bean or pass an instance to the DSL."); + } + this.oneTimeTokenSender = bean; + return this.oneTimeTokenSender; + } + + private C getBeanOrNull(H http, Class clazz) { + ApplicationContext context = http.getSharedObject(ApplicationContext.class); + if (context == null) { + return null; + } + try { + return context.getBean(clazz); + } + catch (NoSuchBeanDefinitionException ex) { + return null; + } + } + + private Map hiddenInputs(HttpServletRequest request) { + CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + return (token != null) ? Collections.singletonMap(token.getParameterName(), token.getToken()) + : Collections.emptyMap(); + } + + public ApplicationContext getContext() { + return this.context; + } + +} 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 new file mode 100644 index 00000000000..7afb2ad531a --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java @@ -0,0 +1,206 @@ +/* + * 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.config.annotation.web.configurers.ott; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.authentication.ott.OneTimeToken; +import org.springframework.security.authentication.ott.OneTimeTokenSender; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.test.SpringTestContext; +import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.userdetails.PasswordEncodedUser; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThatException; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(SpringTestContextExtension.class) +public class OneTimeTokenLoginConfigurerTests { + + public SpringTestContext spring = new SpringTestContext(this); + + @Autowired(required = false) + MockMvc mvc; + + @Test + void oneTimeTokenWhenCorrectTokenThenCanAuthenticate() throws Exception { + this.spring.register(OneTimeTokenDefaultConfig.class).autowire(); + this.mvc.perform(post("/ott/authenticate").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login/ott")); + + String token = TestOneTimeTokenSender.lastToken.getToken(); + + this.mvc.perform(post("/login/ott").param("token", token).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated()); + } + + @Test + void oneTimeTokenWhenDifferentAuthenticationUrlsThenCanAuthenticate() throws Exception { + this.spring.register(OneTimeTokenDifferentAuthenticationUrlsConfig.class).autowire(); + this.mvc.perform(post("/authenticationrequesturl").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login/ott")); + + String token = TestOneTimeTokenSender.lastToken.getToken(); + + this.mvc.perform(post("/loginprocessingurl").param("token", token).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated()); + } + + @Test + void oneTimeTokenWhenCorrectTokenUsedTwiceThenSecondTimeFails() throws Exception { + this.spring.register(OneTimeTokenDefaultConfig.class).autowire(); + this.mvc.perform(post("/ott/authenticate").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login/ott")); + + String token = TestOneTimeTokenSender.lastToken.getToken(); + + this.mvc.perform(post("/login/ott").param("token", token).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated()); + + this.mvc.perform(post("/login/ott").param("token", token).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login?error"), unauthenticated()); + } + + @Test + void oneTimeTokenWhenWrongTokenThenAuthenticationFail() throws Exception { + this.spring.register(OneTimeTokenDefaultConfig.class).autowire(); + this.mvc.perform(post("/ott/authenticate").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login/ott")); + + String token = "wrong"; + + this.mvc.perform(post("/login/ott").param("token", token).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login?error"), unauthenticated()); + } + + @Test + void oneTimeTokenWhenNoOneTimeTokenSenderThenException() { + assertThatException().isThrownBy(() -> this.spring.register(NoOneTimeTokenSenderConfig.class).autowire()) + .havingRootCause() + .isInstanceOf(IllegalStateException.class) + .withMessage( + "A OneTimeTokenSender is required for oneTimeTokenLogin(). Please define a bean or pass an instance to the DSL."); + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @Import(UserDetailsServiceConfig.class) + static class OneTimeTokenDefaultConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authz) -> authz + .anyRequest().authenticated() + ) + .oneTimeTokenLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + + @Bean + OneTimeTokenSender oneTimeTokenSender() { + return new TestOneTimeTokenSender(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @Import(UserDetailsServiceConfig.class) + static class NoOneTimeTokenSenderConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authz) -> authz + .anyRequest().authenticated() + ) + .oneTimeTokenLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @Import(UserDetailsServiceConfig.class) + static class OneTimeTokenDifferentAuthenticationUrlsConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authz) -> authz + .anyRequest().authenticated() + ) + .oneTimeTokenLogin((ott) -> ott + .authenticationRequestUrl("/authenticationrequesturl") + .loginProcessingUrl("/loginprocessingurl") + ); + // @formatter:on + return http.build(); + } + + @Bean + OneTimeTokenSender oneTimeTokenSender() { + return new TestOneTimeTokenSender(); + } + + } + + static class TestOneTimeTokenSender implements OneTimeTokenSender { + + private static OneTimeToken lastToken; + + @Override + public void send(OneTimeToken oneTimeToken) { + lastToken = oneTimeToken; + } + + } + + @Configuration(proxyBeanMethods = false) + static class UserDetailsServiceConfig { + + @Bean + UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager(PasswordEncodedUser.user(), PasswordEncodedUser.admin()); + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/DefaultOneTimeToken.java b/core/src/main/java/org/springframework/security/authentication/ott/DefaultOneTimeToken.java new file mode 100644 index 00000000000..3a49bcb4626 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/DefaultOneTimeToken.java @@ -0,0 +1,60 @@ +/* + * 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.authentication.ott; + +import java.time.Instant; + +import org.springframework.util.Assert; + +/** + * A default implementation of {@link OneTimeToken} + * + * @author Marcus da Coregio + * @since 6.4 + */ +public class DefaultOneTimeToken implements OneTimeToken { + + private final String token; + + private final String username; + + private final Instant expireAt; + + public DefaultOneTimeToken(String token, String username, Instant expireAt) { + Assert.hasText(token, "token cannot be empty"); + Assert.hasText(username, "username cannot be empty"); + Assert.notNull(expireAt, "expireAt cannot be null"); + this.token = token; + this.username = username; + this.expireAt = expireAt; + } + + @Override + public String getToken() { + return this.token; + } + + @Override + public String getUsername() { + return this.username; + } + + public Instant getExpireAt() { + return this.expireAt; + } + +} 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 new file mode 100644 index 00000000000..8013974159d --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java @@ -0,0 +1,83 @@ +/* + * 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.authentication.ott; + +import java.time.Clock; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; + +/** + * Provides an in-memory implementation of the {@link OneTimeTokenService} interface that + * uses a {@link ConcurrentHashMap} to store the generated {@link OneTimeToken}. A random + * {@link UUID} is used as the token value. A clean-up of the expired tokens is made if + * there is more or equal than 100 tokens stored in the map. + * + * @author Marcus da Coregio + * @since 6.4 + */ +public class InMemoryOneTimeTokenService implements OneTimeTokenService { + + private final Map oneTimeTokenByToken = new ConcurrentHashMap<>(); + + private Clock clock = Clock.systemUTC(); + + @Override + @NonNull + public OneTimeToken generate(OneTimeTokenAuthenticationRequest request) { + String token = UUID.randomUUID().toString(); + Instant fiveMinutesFromNow = this.clock.instant().plusSeconds(300); + OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow); + this.oneTimeTokenByToken.put(token, ott); + cleanExpiredTokensIfNeeded(); + return ott; + } + + @Override + public OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken) { + OneTimeToken ott = this.oneTimeTokenByToken.remove(authenticationToken.getToken()); + if (ott == null || isExpired(ott)) { + return null; + } + return ott; + } + + private void cleanExpiredTokensIfNeeded() { + if (this.oneTimeTokenByToken.size() < 100) { + return; + } + for (Map.Entry entry : this.oneTimeTokenByToken.entrySet()) { + if (isExpired(entry.getValue())) { + this.oneTimeTokenByToken.remove(entry.getKey()); + } + } + } + + private boolean isExpired(OneTimeToken ott) { + return this.clock.instant().isAfter(ott.getExpireAt()); + } + + void setClock(Clock clock) { + Assert.notNull(clock, "clock cannot be null"); + this.clock = clock; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/InvalidOneTimeTokenException.java b/core/src/main/java/org/springframework/security/authentication/ott/InvalidOneTimeTokenException.java new file mode 100644 index 00000000000..03289f12b78 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/InvalidOneTimeTokenException.java @@ -0,0 +1,33 @@ +/* + * 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.authentication.ott; + +import org.springframework.security.core.AuthenticationException; + +/** + * An {@link AuthenticationException} that indicates an invalid one-time token. + * + * @author Marcus da Coregio + * @since 6.4 + */ +public class InvalidOneTimeTokenException extends AuthenticationException { + + public InvalidOneTimeTokenException(String msg) { + super(msg); + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeToken.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeToken.java new file mode 100644 index 00000000000..3323e9ff785 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeToken.java @@ -0,0 +1,44 @@ +/* + * 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.authentication.ott; + +import java.time.Instant; + +/** + * Represents a one-time use token with an associated username and expiration time. + * + * @author Marcus da Coregio + * @since 6.4 + */ +public interface OneTimeToken { + + /** + * @return the one-time token, never {@code null} + */ + String getToken(); + + /** + * @return the username associated with this token, never {@code null} + */ + String getUsername(); + + /** + * @return the expiration time of the token + */ + Instant getExpireAt(); + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java new file mode 100644 index 00000000000..2300a88d2af --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java @@ -0,0 +1,67 @@ +/* + * 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.authentication.ott; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.util.Assert; + +/** + * An {@link AuthenticationProvider} responsible for authenticating users based on + * one-time tokens. It uses an {@link OneTimeTokenService} to consume tokens and an + * {@link UserDetailsService} to fetch user authorities. + * + * @author Marcus da Coregio + * @since 6.4 + */ +public class OneTimeTokenAuthenticationProvider implements AuthenticationProvider { + + private final OneTimeTokenService oneTimeTokenService; + + private final UserDetailsService userDetailsService; + + public OneTimeTokenAuthenticationProvider(OneTimeTokenService oneTimeTokenService, + UserDetailsService userDetailsService) { + Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null"); + Assert.notNull(userDetailsService, "userDetailsService cannot be null"); + this.userDetailsService = userDetailsService; + this.oneTimeTokenService = oneTimeTokenService; + } + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + OneTimeTokenAuthenticationToken otpAuthenticationToken = (OneTimeTokenAuthenticationToken) authentication; + OneTimeToken consumed = this.oneTimeTokenService.consume(otpAuthenticationToken); + if (consumed == null) { + throw new InvalidOneTimeTokenException("Invalid token"); + } + UserDetails user = this.userDetailsService.loadUserByUsername(consumed.getUsername()); + OneTimeTokenAuthenticationToken authenticated = new OneTimeTokenAuthenticationToken(user, + user.getAuthorities()); + authenticated.setDetails(otpAuthenticationToken.getDetails()); + return authenticated; + } + + @Override + public boolean supports(Class authentication) { + return OneTimeTokenAuthenticationToken.class.isAssignableFrom(authentication); + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationRequest.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationRequest.java new file mode 100644 index 00000000000..fe337d8de78 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationRequest.java @@ -0,0 +1,34 @@ +/* + * 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.authentication.ott; + +import org.springframework.util.Assert; + +public class OneTimeTokenAuthenticationRequest { + + private final String username; + + public OneTimeTokenAuthenticationRequest(String username) { + Assert.hasText(username, "username cannot be empty"); + this.username = username; + } + + public String getUsername() { + return this.username; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationToken.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationToken.java new file mode 100644 index 00000000000..7ee3e573e85 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationToken.java @@ -0,0 +1,57 @@ +/* + * 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.authentication.ott; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +public class OneTimeTokenAuthenticationToken extends AbstractAuthenticationToken { + + private final Object principal; + + private String token; + + public OneTimeTokenAuthenticationToken(String token) { + super(Collections.emptyList()); + this.token = token; + this.principal = null; + } + + public OneTimeTokenAuthenticationToken(Object principal, Collection authorities) { + super(authorities); + this.principal = principal; + setAuthenticated(true); + } + + public String getToken() { + return this.token; + } + + @Override + public Object getCredentials() { + return this.token; + } + + @Override + public Object getPrincipal() { + return this.principal; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenSender.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenSender.java new file mode 100644 index 00000000000..36707906411 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenSender.java @@ -0,0 +1,33 @@ +/* + * 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.authentication.ott; + +/** + * Interface to define a strategy to send a {@link OneTimeToken} to its user. + * + * @author Marcus da Coregio + * @since 6.4 + */ +public interface OneTimeTokenSender { + + /** + * Send the {@link OneTimeToken} to the user + * @param oneTimeToken + */ + void send(OneTimeToken oneTimeToken); + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenService.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenService.java new file mode 100644 index 00000000000..ac7d54071a6 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenService.java @@ -0,0 +1,48 @@ +/* + * 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.authentication.ott; + +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +/** + * Interface for generating and consuming one-time tokens. + * + * @author Marcus da Coregio + * @since 6.4 + */ +public interface OneTimeTokenService { + + /** + * Generates a one-time token based on the provided authentication request. + * @param request the authentication request containing the necessary information to + * generate the token + * @return the generated {@link OneTimeToken}, never {@code null}. + */ + @NonNull + OneTimeToken generate(OneTimeTokenAuthenticationRequest request); + + /** + * Consumes a one-time token based on the provided authentication token. + * @param authenticationToken the authentication token containing the one-time token + * to be consumed + * @return the consumed {@link OneTimeToken} or {@code null} if the token is invalid + */ + @Nullable + OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken); + +} diff --git a/core/src/test/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenServiceTests.java b/core/src/test/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenServiceTests.java new file mode 100644 index 00000000000..d96da7ff2c5 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenServiceTests.java @@ -0,0 +1,111 @@ +/* + * 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.authentication.ott; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests for {@link InMemoryOneTimeTokenService} + * + * @author Marcus da Coregio + */ +class InMemoryOneTimeTokenServiceTests { + + InMemoryOneTimeTokenService oneTimeTokenService = new InMemoryOneTimeTokenService(); + + @Test + void generateThenTokenValueShouldBeValidUuidAndProvidedUsernameIsUsed() { + OneTimeTokenAuthenticationRequest request = new OneTimeTokenAuthenticationRequest("user"); + OneTimeToken oneTimeToken = this.oneTimeTokenService.generate(request); + assertThatNoException().isThrownBy(() -> UUID.fromString(oneTimeToken.getToken())); + assertThat(request.getUsername()).isEqualTo("user"); + } + + @Test + void consumeWhenTokenDoesNotExistsThenNull() { + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken("123"); + OneTimeToken oneTimeToken = this.oneTimeTokenService.consume(authenticationToken); + assertThat(oneTimeToken).isNull(); + } + + @Test + void consumeWhenTokenExistsThenReturnItself() { + OneTimeTokenAuthenticationRequest request = new OneTimeTokenAuthenticationRequest("user"); + OneTimeToken generated = this.oneTimeTokenService.generate(request); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken(generated.getToken()); + OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken); + assertThat(consumed.getToken()).isEqualTo(generated.getToken()); + assertThat(consumed.getUsername()).isEqualTo(generated.getUsername()); + assertThat(consumed.getExpireAt()).isEqualTo(generated.getExpireAt()); + } + + @Test + void consumeWhenTokenIsExpiredThenReturnNull() { + OneTimeTokenAuthenticationRequest request = new OneTimeTokenAuthenticationRequest("user"); + OneTimeToken generated = this.oneTimeTokenService.generate(request); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken(generated.getToken()); + Clock tenMinutesFromNow = Clock.fixed(Instant.now().plus(10, ChronoUnit.MINUTES), ZoneOffset.UTC); + this.oneTimeTokenService.setClock(tenMinutesFromNow); + OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken); + assertThat(consumed).isNull(); + } + + @Test + void generateWhenMoreThan100TokensThenClearExpired() { + // @formatter:off + List toExpire = generate(50); // 50 tokens will expire in 5 minutes from now + Clock twoMinutesFromNow = Clock.fixed(Instant.now().plus(2, ChronoUnit.MINUTES), ZoneOffset.UTC); + this.oneTimeTokenService.setClock(twoMinutesFromNow); + List toKeep = generate(50); // 50 tokens will expire in 7 minutes from now + Clock sixMinutesFromNow = Clock.fixed(Instant.now().plus(6, ChronoUnit.MINUTES), ZoneOffset.UTC); + this.oneTimeTokenService.setClock(sixMinutesFromNow); + + assertThat(toExpire) + .extracting( + (token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getToken()))) + .containsOnlyNulls(); + + assertThat(toKeep) + .extracting( + (token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getToken()))) + .noneMatch(Objects::isNull); + // @formatter:on + } + + private List generate(int howMany) { + List generated = new ArrayList<>(howMany); + for (int i = 0; i < howMany; i++) { + OneTimeToken oneTimeToken = this.oneTimeTokenService + .generate(new OneTimeTokenAuthenticationRequest("generated" + i)); + generated.add(oneTimeToken); + } + return generated; + } + +} diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 7ba36aa76b5..0e289b539a1 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -45,6 +45,7 @@ ***** xref:servlet/authentication/passwords/dao-authentication-provider.adoc[DaoAuthenticationProvider] ***** xref:servlet/authentication/passwords/ldap.adoc[LDAP] *** xref:servlet/authentication/persistence.adoc[Persistence] +*** xref:servlet/authentication/onetimetoken.adoc[One-Time Token] *** xref:servlet/authentication/session-management.adoc[Session Management] *** xref:servlet/authentication/rememberme.adoc[Remember Me] *** xref:servlet/authentication/anonymous.adoc[Anonymous] diff --git a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc new file mode 100644 index 00000000000..a3df975a3e2 --- /dev/null +++ b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc @@ -0,0 +1,253 @@ +[[one-time-token-login]] += One-Time Token Login + +Spring Security offers support for One-Time Token (OTT) authentication via the `oneTimeTokenLogin()` DSL. +Before diving into implementation details, it's important to clarify the scope of the OTT feature within the framework, highlighting what is supported and what isn't. + +== Understanding One-Time Tokens vs. One-Time Passwords + +It's common to confuse One-Time Tokens (OTT) with https://en.wikipedia.org/wiki/One-time_password[One-Time Passwords] (OTP), but in Spring Security, these concepts differ in several key ways. +For clarity, we'll assume OTP refers to https://en.wikipedia.org/wiki/Time-based_one-time_password[TOTP] (Time-Based One-Time Password) or https://en.wikipedia.org/wiki/HMAC-based_one-time_password[HOTP] (HMAC-Based One-Time Password). + +=== Setup Requirements + +- OTT: No initial setup is required. The user doesn't need to configure anything in advance. +- OTP: Typically requires setup, such as generating and sharing a secret key with an external tool to produce the one-time passwords. + +=== Token Delivery + +- OTT: The `oneTimeTokenLogin()` feature needs a javadoc:org.springframework.security.authentication.ott.OneTimeTokenSender[] bean, responsible for delivering the token to the end user. +- OTP: The token is generated by an external tool, so there's no need to send it to the user via the application. + +=== Token Generation + +- OTT: The javadoc:org.springframework.security.authentication.ott.OneTimeTokenService#generate(org.springframework.security.authentication.ott.OneTimeTokenAuthenticationRequest)[] method requires a javadoc:org.springframework.security.authentication.ott.OneTimeToken[] to be returned, emphasizing server-side generation. +- OTP: The token is not necessarily generated on the server side, it's often created by the client using the shared secret. + +In summary, One-Time Tokens (OTT) provide a way to authenticate users without additional account setup, differentiating them from One-Time Passwords (OTP), which typically involve a more complex setup process and rely on external tools for token generation. + +The One-Time Token Login works in two major steps. + +1. User request a token by submitting their user identifier, usually the username, and the token is delivered to them, via e-mail, SMS, Magic Link, etc. +2. User submits the token to the application and, if valid, the user gets logged in. + +[[default-pages]] +== Default Login Page and Default One-Time Token Submit Page + +The `oneTimeTokenLogin()` DSL can be used in conjunction with `formLogin()`, which will produce an additional One-Time Token Request Form in the xref:servlet/authentication/passwords/form.adoc[default generated login page]. +It will also set up the javadoc:org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter[] to generate a default One-Time Token submit page. + +In the following sections we will explore how to configure OTT Login for your needs. + +- <> +- <> +- <> + +[[sending-token-to-user]] +== Sending the Token to the User + +As we mentioned before, the `oneTimeTokenLogin()` requires a bean of type javadoc:org.springframework.security.authentication.ott.OneTimeTokenSender[] because Spring Security cannot decide the best strategy to deliver the tokens to your users. +Some of the most common delivery strategies are: SMS, Token via E-mail, Magic Link via E-mail, and others. +In the following example, we are going to create a magic link and sent it to the user's email. + +.One-Time Token Login Configuration +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) { + http + // ... + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin(Customizer.withDefaults()); + return http.build(); + } + +} + +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +@Component <1> +public class MagicLinkOneTimeTokenSender implements OneTimeTokenSender { + + private final JavaMailSender mailSender; + + // constructor omitted + + @Override + public void send(OneTimeToken oneTimeToken) { + String magicLink = "http://localhost:8080/login/ott?token=" + oneTimeToken.getToken(); <2> + String userEmail = getUserEmail(oneTimeToken.getUsername()); <3> + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom("noreply@example.com"); + message.setTo(userEmail); + message.setSubject("Your Spring Security One Time Token"); + message.setText("Use the following link to sign in into the application: " + magicLink); + this.mailSender.send(message); <4> + } + + private String getUserEmail(String username) { + // ... + } + +} + +---- +====== + +<1> Make the `MagicLinkOneTimeTokenSender` a Spring Bean so it is automatically picked up by `oneTimeTokenLogin()` DSL +<2> Create a login processing URL with the `token` as a query param +<3> Retrieve the user's email based on the username +<4> Use the `JavaMailSender` API to send the email to the user with the magic link + +The email content will look similar to: + +> Use the following link to sign in into the application: \http://localhost:8080/login/ott?token=a830c444-29d8-4d98-9b46-6aba7b22fe5b + +The default submit page will detect that the URL has the `token` query param and will automatically submit the form with the token value, effectively logging the user into the application. + +[[changing-authentication-request-url]] +== Changing the Authentication Request URL + +By default, the javadoc:org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationRequestFilter[] listens to `POST /ott/authenticate` requests. +That URL can be changed by using the `authenticationRequestUrl(String)` DSL method: + +.Changing the Authentication Request URL +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) { + http + // ... + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin((ott) -> ott + .authenticationRequestUrl("/ott/myauthenticationrequesturl") + ); + return http.build(); + } + +} +---- +====== + +== Changing the Redirect URL After a Successful Authentication Request + +After a successful authentication request, where the token has been generated and delivered, the user-agent is redirected to `/login/ott`. +The redirect URL can be changed by using the `authenticationRequestRedirectUrl(String)` DSL method: + +.Changing the Authentication Request Redirect URL +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) { + http + // ... + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin((ott) -> ott + .authenticationRequestRedirectUrl("/ott/submit") + ); + return http.build(); + } + +} +---- +====== + +If you are using the default submit page, you should also <>. + +[[changing-submit-page-url]] +== Changing the Default Submit Page URL + +The default One-Time Token submit page is generated by the javadoc:org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter[] and listens to `GET /login/ott`. +The URL can also be changed, like so: + +.Configuring the Default Submit Page URL +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) { + http + // ... + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin((ott) -> ott + .submitPageUrl("/ott/submit") + ); + return http.build(); + } + +} +---- +====== + +[[disabling-default-submit-page]] +== Disabling the Default Submit Page + +If you want to use your own One-Time Token submit page, you can disable the default page and then provide your own endpoint. + +.Disabling the Default Submit Page +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) { + http + // ... + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin((ott) -> ott + .showSubmitPage(false) + ); + return http.build(); + } + +} + +@Controller +public class MyController { + + @GetMapping("/my-ott-submit") + public String ottSubmitPage() { + return "my-ott-submit"; + } + +} +---- +====== diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationConverter.java b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationConverter.java new file mode 100644 index 00000000000..923766e6d28 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationConverter.java @@ -0,0 +1,42 @@ +/* + * 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.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.StringUtils; + +public class OneTimeTokenAuthenticationConverter implements AuthenticationConverter { + + private final Log logger = LogFactory.getLog(getClass()); + + @Override + public Authentication convert(HttpServletRequest request) { + String token = request.getParameter("token"); + if (!StringUtils.hasText(token)) { + this.logger.debug("No token found in request"); + return null; + } + return new OneTimeTokenAuthenticationToken(token); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilter.java new file mode 100644 index 00000000000..8ec28e1571c --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilter.java @@ -0,0 +1,32 @@ +/* + * 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.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.authentication.AuthenticationFilter; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; + +public class OneTimeTokenAuthenticationFilter extends AuthenticationFilter { + + public OneTimeTokenAuthenticationFilter(AuthenticationManager authenticationManager, + AuthenticationConverter authenticationConverter) { + super(authenticationManager, authenticationConverter); + setSecurityContextRepository(new HttpSessionSecurityContextRepository()); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationRequestFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationRequestFilter.java new file mode 100644 index 00000000000..2dd20191015 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationRequestFilter.java @@ -0,0 +1,118 @@ +/* + * 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 java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.ott.OneTimeToken; +import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationRequest; +import org.springframework.security.authentication.ott.OneTimeTokenSender; +import org.springframework.security.authentication.ott.OneTimeTokenService; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Filter that process a One-Time Token authentication request. By default, the filter + * listen to {@code POST} requests to {@code /ott/authenticate}. The + * {@link #authenticationRequestResolver} resolves the needed information from the request + * and the {@link #oneTimeTokenService} generates the {@link OneTimeToken}. + * + * @author Marcus da Coregio + * @since 6.4 + * @see OneTimeTokenService + */ +public class OneTimeTokenAuthenticationRequestFilter extends OncePerRequestFilter { + + private final OneTimeTokenService oneTimeTokenService; + + private final OneTimeTokenSender oneTimeTokenSender; + + private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + private RequestMatcher requestMatcher = new AntPathRequestMatcher("/ott/authenticate", "POST"); + + private OneTimeTokenAuthenticationRequestResolver authenticationRequestResolver = new RequestParameterOneTimeTokenAuthenticationRequestResolver(); + + private String redirectUrl = "/login/ott"; + + public OneTimeTokenAuthenticationRequestFilter(OneTimeTokenService oneTimeTokenService, + OneTimeTokenSender oneTimeTokenSender) { + Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null"); + Assert.notNull(oneTimeTokenSender, "oneTimeTokenSender cannot be null"); + this.oneTimeTokenService = oneTimeTokenService; + this.oneTimeTokenSender = oneTimeTokenSender; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!this.requestMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + OneTimeTokenAuthenticationRequest authenticationRequest = this.authenticationRequestResolver.resolve(request); + if (authenticationRequest == null) { + filterChain.doFilter(request, response); + return; + } + OneTimeToken ott = this.oneTimeTokenService.generate(authenticationRequest); + this.oneTimeTokenSender.send(ott); + this.redirectStrategy.sendRedirect(request, response, this.redirectUrl); + } + + public void setRequestMatcher(RequestMatcher requestMatcher) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + this.requestMatcher = requestMatcher; + } + + /** + * Sets the {@link OneTimeTokenAuthenticationRequestResolver} to use, defaults to + * {@link RequestParameterOneTimeTokenAuthenticationRequestResolver} + * @param authenticationRequestResolver + */ + public void setAuthenticationRequestResolver( + OneTimeTokenAuthenticationRequestResolver authenticationRequestResolver) { + Assert.notNull(authenticationRequestResolver, "authenticationRequestResolver cannot be null"); + this.authenticationRequestResolver = authenticationRequestResolver; + } + + /** + * Sets the {@link RedirectStrategy} to use + * @param redirectStrategy + */ + public void setRedirectStrategy(RedirectStrategy redirectStrategy) { + this.redirectStrategy = redirectStrategy; + } + + /** + * The redirect url to be passed to the {@link RedirectStrategy} + * @param redirectUrl + */ + public void setRedirectUrl(String redirectUrl) { + this.redirectUrl = redirectUrl; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationRequestResolver.java b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationRequestResolver.java new file mode 100644 index 00000000000..e1d417dc51d --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationRequestResolver.java @@ -0,0 +1,41 @@ +/* + * 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.OneTimeTokenAuthenticationRequest; + +/** + * An interface for resolving an {@link OneTimeTokenAuthenticationRequest} based on the + * provided {@link HttpServletRequest} + * + * @author Marcus da Coregio + * @since 6.4 + */ +public interface OneTimeTokenAuthenticationRequestResolver { + + /** + * Resolve the {@link OneTimeTokenAuthenticationRequest} from the provided request + * @param request + * @return the resolved {@link OneTimeTokenAuthenticationRequest} or {@code null} + */ + @Nullable + OneTimeTokenAuthenticationRequest resolve(HttpServletRequest request); + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/RequestParameterOneTimeTokenAuthenticationRequestResolver.java b/web/src/main/java/org/springframework/security/web/authentication/ott/RequestParameterOneTimeTokenAuthenticationRequestResolver.java new file mode 100644 index 00000000000..356108e2b91 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/RequestParameterOneTimeTokenAuthenticationRequestResolver.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 jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationRequest; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Resolves the {@link OneTimeTokenAuthenticationRequest} by retrieving the username from + * the request parameter + * + * @author Marcus da Coregio + * @since 6.4 + */ +public final class RequestParameterOneTimeTokenAuthenticationRequestResolver + implements OneTimeTokenAuthenticationRequestResolver { + + private String requestParameter = "username"; + + /** + * Constructs a new instance that retrieves the username from the request parameter + * named {@code username} + */ + public RequestParameterOneTimeTokenAuthenticationRequestResolver() { + } + + /** + * Constructs a new instance that looks for the provided request parameter + * @param requestParameter the request parameter to retrieve the username, cannot be + * empty + */ + public RequestParameterOneTimeTokenAuthenticationRequestResolver(String requestParameter) { + Assert.hasText(requestParameter, "requestParameter cannot be null or empty"); + this.requestParameter = requestParameter; + } + + @Override + public OneTimeTokenAuthenticationRequest resolve(HttpServletRequest request) { + String username = request.getParameter(this.requestParameter); + if (!StringUtils.hasText(username)) { + return null; + } + return new OneTimeTokenAuthenticationRequest(username); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java index 89b44ecf62c..26847070d18 100644 --- a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultLoginPageGeneratingFilter.java @@ -68,8 +68,12 @@ public class DefaultLoginPageGeneratingFilter extends GenericFilterBean { private boolean saml2LoginEnabled; + private boolean oneTimeTokenEnabled; + private String authenticationUrl; + private String oneTimeTokenAuthenticationRequestUrl; + private String usernameParameter; private String passwordParameter; @@ -142,6 +146,10 @@ public void setOauth2LoginEnabled(boolean oauth2LoginEnabled) { this.oauth2LoginEnabled = oauth2LoginEnabled; } + public void setOneTimeTokenEnabled(boolean oneTimeTokenEnabled) { + this.oneTimeTokenEnabled = oneTimeTokenEnabled; + } + public void setSaml2LoginEnabled(boolean saml2LoginEnabled) { this.saml2LoginEnabled = saml2LoginEnabled; } @@ -150,6 +158,10 @@ public void setAuthenticationUrl(String authenticationUrl) { this.authenticationUrl = authenticationUrl; } + public void setOneTimeTokenAuthenticationRequestUrl(String oneTimeTokenAuthenticationRequestUrl) { + this.oneTimeTokenAuthenticationRequestUrl = oneTimeTokenAuthenticationRequestUrl; + } + public void setUsernameParameter(String usernameParameter) { this.usernameParameter = usernameParameter; } @@ -224,6 +236,19 @@ private String generateLoginPageHtml(HttpServletRequest request, boolean loginEr sb.append(" \n"); sb.append(" \n"); } + if (this.oneTimeTokenEnabled) { + sb.append("
    \n"); + sb.append("

    Request a One-Time Token

    \n"); + sb.append(createError(loginError, errorMsg) + createLogoutSuccess(logoutSuccess) + "

    \n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append("

    \n"); + sb.append(renderHiddenInputs(request)); + sb.append(" \n"); + sb.append("
    \n"); + } if (this.oauth2LoginEnabled) { sb.append("

    Login with OAuth 2.0

    "); sb.append(createError(loginError, errorMsg)); diff --git a/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java new file mode 100644 index 00000000000..f3562dd9a71 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java @@ -0,0 +1,146 @@ +/* + * 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.ui; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.web.util.CssUtils; +import org.springframework.security.web.util.UrlUtils; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; + +/** + * Creates a default one-time token submit page. If the request contains a {@code token} + * query param the page will automatically submit its form with the token value. + * + * @author Marcus da Coregio + * @since 6.4 + */ +public class DefaultOneTimeTokenSubmitPageGeneratingFilter extends OncePerRequestFilter { + + private RequestMatcher requestMatcher = new AntPathRequestMatcher("/login/ott", "GET"); + + private Function> resolveHiddenInputs = (request) -> Collections.emptyMap(); + + private String loginProcessingUrl = "/login/ott"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!this.requestMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + String html = generateHtml(request); + response.setContentType("text/html;charset=UTF-8"); + response.setContentLength(html.getBytes(StandardCharsets.UTF_8).length); + response.getWriter().write(html); + } + + private String generateHtml(HttpServletRequest request) { + UriComponents uriComponents = UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request)).build(); + List tokens = uriComponents.getQueryParams().get("token"); + boolean autoSubmitForm = !CollectionUtils.isEmpty(tokens); + String inputValue = autoSubmitForm ? ("value=\"" + tokens.get(0) + "\"") : ""; + String input = ""; + return """ + + + + One-Time Token Login + + + + """ + + CssUtils.getCssStyleBlock().indent(4) + + """ + + + +
    + """ + + "
    " + """ +

    Please input the token

    +

    + + """ + input + """ +

    + + """ + renderHiddenInputs(request) + """ +
    +
    + """ + + (autoSubmitForm ? "" : "") + + """ + + + """; + } + + private String renderHiddenInputs(HttpServletRequest request) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry input : this.resolveHiddenInputs.apply(request).entrySet()) { + sb.append("\n"); + } + return sb.toString(); + } + + public void setResolveHiddenInputs(Function> resolveHiddenInputs) { + Assert.notNull(resolveHiddenInputs, "resolveHiddenInputs cannot be null"); + this.resolveHiddenInputs = resolveHiddenInputs; + } + + public void setRequestMatcher(RequestMatcher requestMatcher) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + this.requestMatcher = requestMatcher; + } + + /** + * Specifies the URL that the submit form should POST to. Defaults to + * {@code /login/ott}. + * @param loginProcessingUrl + */ + public void setLoginProcessingUrl(String loginProcessingUrl) { + Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty"); + this.loginProcessingUrl = loginProcessingUrl; + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java index 12fdacdcf89..64eccdf1665 100644 --- a/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java +++ b/web/src/test/java/org/springframework/security/web/authentication/DefaultLoginPageGeneratingFilterTests.java @@ -185,4 +185,25 @@ public void generatesWhenExceptionWithEmptyMessageThenInvalidCredentials() throw assertThat(response.getContentAsString()).contains("Invalid credentials"); } + @Test + public void generateWhenOneTimeTokenLoginThenOttForm() throws Exception { + DefaultLoginPageGeneratingFilter filter = new DefaultLoginPageGeneratingFilter(); + filter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL); + filter.setOneTimeTokenEnabled(true); + filter.setOneTimeTokenAuthenticationRequestUrl("/ott/authenticate"); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(new MockHttpServletRequest("GET", "/login"), response, this.chain); + assertThat(response.getContentAsString()).contains("Request a One-Time Token"); + assertThat(response.getContentAsString()).contains(""" + + """); + } + } diff --git a/web/src/test/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationConverterTests.java b/web/src/test/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationConverterTests.java new file mode 100644 index 00000000000..607d375f626 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationConverterTests.java @@ -0,0 +1,52 @@ +/* + * 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.OneTimeTokenAuthenticationToken; +import org.springframework.security.core.Authentication; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link OneTimeTokenAuthenticationConverter} + * + * @author Marcus da Coregio + */ +class OneTimeTokenAuthenticationConverterTests { + + private final OneTimeTokenAuthenticationConverter converter = new OneTimeTokenAuthenticationConverter(); + + @Test + void convertWhenTokenParameterThenReturnOneTimeTokenAuthenticationToken() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("token", "1234"); + OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter + .convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getToken()).isEqualTo("1234"); + } + + @Test + void convertWhenNoTokenParameterThenNull() { + Authentication authentication = this.converter.convert(new MockHttpServletRequest()); + assertThat(authentication).isNull(); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/ott/RequestParameterOneTimeTokenAuthenticationRequestResolverTests.java b/web/src/test/java/org/springframework/security/web/authentication/ott/RequestParameterOneTimeTokenAuthenticationRequestResolverTests.java new file mode 100644 index 00000000000..db940a86f07 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ott/RequestParameterOneTimeTokenAuthenticationRequestResolverTests.java @@ -0,0 +1,61 @@ +/* + * 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.OneTimeTokenAuthenticationRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link RequestParameterOneTimeTokenAuthenticationRequestResolver} + * + * @author Marcus da Coregio + */ +class RequestParameterOneTimeTokenAuthenticationRequestResolverTests { + + private RequestParameterOneTimeTokenAuthenticationRequestResolver resolver = new RequestParameterOneTimeTokenAuthenticationRequestResolver(); + + @Test + void resolveWhenUsernameParameterExistsThenResolved() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("username", "josh"); + OneTimeTokenAuthenticationRequest resolved = this.resolver.resolve(request); + assertThat(resolved).isNotNull(); + assertThat(resolved.getUsername()).isEqualTo("josh"); + } + + @Test + void resolveWhenNoUsernameParameterThenNull() { + MockHttpServletRequest request = new MockHttpServletRequest(); + OneTimeTokenAuthenticationRequest resolved = this.resolver.resolve(request); + assertThat(resolved).isNull(); + } + + @Test + void resolveWhenAnotherParameterAndExistsThenResolved() { + this.resolver = new RequestParameterOneTimeTokenAuthenticationRequestResolver("my_parameter"); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("my_parameter", "josh"); + OneTimeTokenAuthenticationRequest resolved = this.resolver.resolve(request); + assertThat(resolved).isNotNull(); + assertThat(resolved.getUsername()).isEqualTo("josh"); + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilterTests.java new file mode 100644 index 00000000000..c09d0ac0162 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilterTests.java @@ -0,0 +1,80 @@ +/* + * 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.ui; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link DefaultOneTimeTokenSubmitPageGeneratingFilter} + * + * @author Marcus da Coregio + */ +class DefaultOneTimeTokenSubmitPageGeneratingFilterTests { + + DefaultOneTimeTokenSubmitPageGeneratingFilter filter = new DefaultOneTimeTokenSubmitPageGeneratingFilter(); + + MockHttpServletRequest request = new MockHttpServletRequest(); + + MockHttpServletResponse response = new MockHttpServletResponse(); + + MockFilterChain filterChain = new MockFilterChain(); + + @BeforeEach + void setup() { + this.request.setMethod("GET"); + this.request.setServletPath("/login/ott"); + } + + @Test + void filterWhenTokenQueryParamThenShouldIncludeJavascriptToAutoSubmitFormAndInputHasTokenValue() throws Exception { + this.request.setQueryString("token=1234"); + this.filter.doFilterInternal(this.request, this.response, this.filterChain); + String response = this.response.getContentAsString(); + assertThat(response).contains(""); + assertThat(response).contains( + ""); + } + + @Test + void setRequestMatcherWhenNullThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setRequestMatcher(null)); + } + + @Test + void setLoginProcessingUrlWhenNullOrEmptyThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setLoginProcessingUrl(null)); + assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setLoginProcessingUrl("")); + } + + @Test + void setLoginProcessingUrlThenUseItForFormAction() throws Exception { + this.filter.setLoginProcessingUrl("/login/another"); + this.filter.doFilterInternal(this.request, this.response, this.filterChain); + String response = this.response.getContentAsString(); + assertThat(response).contains( + "
    \t

    Please input the token

    "); + } + +}