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..89a61eec9fa 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 @@ -157,6 +157,7 @@ public interface HttpSecurityBuilder> *
  • {@link DigestAuthenticationFilter}
  • *
  • {@link BearerTokenAuthenticationFilter}
  • *
  • {@link BasicAuthenticationFilter}
  • + *
  • {@link org.springframework.security.web.authentication.AuthenticationFilter}
  • *
  • {@link RequestCacheAwareFilter}
  • *
  • {@link SecurityContextHolderAwareRequestFilter}
  • *
  • {@link JaasApiIntegrationFilter}
  • 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..7ba8cb1386e 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 @@ -27,14 +27,17 @@ import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import org.springframework.security.web.authentication.AuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.LogoutFilter; +import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter; 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(GenerateOneTimeTokenFilter.class, order.next()); put(X509AuthenticationFilter.class, order.next()); put(AbstractPreAuthenticatedProcessingFilter.class, order.next()); this.filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter", order.next()); @@ -99,12 +103,14 @@ final class FilterOrderRegistration { 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( "org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter", order.next()); put(BasicAuthenticationFilter.class, order.next()); + put(AuthenticationFilter.class, order.next()); put(RequestCacheAwareFilter.class, order.next()); put(SecurityContextHolderAwareRequestFilter.class, order.next()); put(JaasApiIntegrationFilter.class, order.next()); 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..12253d65da8 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,45 @@ public HttpSecurity oauth2ResourceServer( return HttpSecurity.this; } + /** + * Configures One-Time Token Login Support. + * + *

    Example Configuration

    + * + *
    +	 * @Configuration
    +	 * @EnableWebSecurity
    +	 * public class SecurityConfig {
    +	 *
    +	 * 	@Bean
    +	 * 	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    +	 * 		http
    +	 * 			.authorizeHttpRequests((authorize) -> authorize
    +	 * 					.anyRequest().authenticated()
    +	 * 			)
    +	 * 			.oneTimeTokenLogin(Customizer.withDefaults());
    +	 * 		return http.build();
    +	 * 	}
    +	 *
    +	 * 	@Bean
    +	 * 	public GeneratedOneTimeTokenHandler generatedOneTimeTokenHandler() {
    +	 * 		return new MyMagicLinkGeneratedOneTimeTokenHandler();
    +	 * 	}
    +	 *
    +	 * }
    +	 * 
    + * @param oneTimeTokenLoginConfigurerCustomizer the {@link Customizer} to provide more + * options for the {@link OneTimeTokenLoginConfigurer} + * @return the {@link HttpSecurity} for further customizations + * @throws Exception + */ + 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..b6176d846ad --- /dev/null +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java @@ -0,0 +1,346 @@ +/* + * 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.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.AuthenticationFilter; +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.GenerateOneTimeTokenFilter; +import org.springframework.security.web.authentication.ott.GeneratedOneTimeTokenHandler; +import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationConverter; +import org.springframework.security.web.authentication.ott.RedirectGeneratedOneTimeTokenHandler; +import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; +import org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +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 String defaultSubmitPageUrl = "/login/ott"; + + private boolean submitPageEnabled = true; + + private String loginProcessingUrl = "/login/ott"; + + private String generateTokenUrl = "/ott/generate"; + + private GeneratedOneTimeTokenHandler generatedOneTimeTokenHandler; + + 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.setGenerateOneTimeTokenUrl(this.generateTokenUrl); + if (this.authenticationFailureHandler == null + && StringUtils.hasText(loginPageGeneratingFilter.getLoginPageUrl())) { + this.authenticationFailureHandler = new SimpleUrlAuthenticationFailureHandler( + loginPageGeneratingFilter.getLoginPageUrl() + "?error"); + } + } + + @Override + public void configure(H http) { + configureSubmitPage(http); + configureOttGenerateFilter(http); + configureOttAuthenticationFilter(http); + } + + private void configureOttAuthenticationFilter(H http) { + AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); + AuthenticationFilter oneTimeTokenAuthenticationFilter = new AuthenticationFilter(authenticationManager, + this.authenticationConverter); + oneTimeTokenAuthenticationFilter.setSecurityContextRepository(getSecurityContextRepository(http)); + oneTimeTokenAuthenticationFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.loginProcessingUrl)); + oneTimeTokenAuthenticationFilter.setFailureHandler(getAuthenticationFailureHandler()); + oneTimeTokenAuthenticationFilter.setSuccessHandler(this.authenticationSuccessHandler); + http.addFilter(postProcess(oneTimeTokenAuthenticationFilter)); + } + + private SecurityContextRepository getSecurityContextRepository(H http) { + SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class); + if (securityContextRepository != null) { + return securityContextRepository; + } + return new HttpSessionSecurityContextRepository(); + } + + private void configureOttGenerateFilter(H http) { + GenerateOneTimeTokenFilter generateFilter = new GenerateOneTimeTokenFilter(getOneTimeTokenService(http)); + generateFilter.setGeneratedOneTimeTokenHandler(getGeneratedOneTimeTokenHandler(http)); + generateFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.generateTokenUrl)); + http.addFilter(postProcess(generateFilter)); + } + + private GeneratedOneTimeTokenHandler getGeneratedOneTimeTokenHandler(H http) { + if (this.generatedOneTimeTokenHandler == null) { + this.generatedOneTimeTokenHandler = getBeanOrNull(http, GeneratedOneTimeTokenHandler.class); + } + if (this.generatedOneTimeTokenHandler == null) { + throw new IllegalStateException(""" + A GeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin(). + Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. + """); + } + return this.generatedOneTimeTokenHandler; + } + + private void configureSubmitPage(H http) { + if (!this.submitPageEnabled) { + return; + } + DefaultOneTimeTokenSubmitPageGeneratingFilter submitPage = new DefaultOneTimeTokenSubmitPageGeneratingFilter(); + submitPage.setResolveHiddenInputs(this::hiddenInputs); + submitPage.setRequestMatcher(antMatcher(HttpMethod.GET, this.defaultSubmitPageUrl)); + 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 generate request will be processed. + * Defaults to {@code /ott/generate}. + * @param generateTokenUrl + */ + public OneTimeTokenLoginConfigurer generateTokenUrl(String generateTokenUrl) { + Assert.hasText(generateTokenUrl, "generateTokenUrl cannot be null or empty"); + this.generateTokenUrl = generateTokenUrl; + return this; + } + + /** + * Specifies strategy to be used to handle generated one-time tokens. + * @param generatedOneTimeTokenHandler + */ + public OneTimeTokenLoginConfigurer generatedOneTimeTokenHandler( + GeneratedOneTimeTokenHandler generatedOneTimeTokenHandler) { + Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null"); + this.generatedOneTimeTokenHandler = generatedOneTimeTokenHandler; + 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 showDefaultSubmitPage(boolean show) { + this.submitPageEnabled = show; + return this; + } + + /** + * Sets the URL that the default submit page will be generated. Defaults to + * {@code /login/ott}. If you don't want to generate the default submit page you + * should use {@link #showDefaultSubmitPage(boolean)}. Note that this method always + * invoke {@link #showDefaultSubmitPage(boolean)} passing {@code true}. + * @param submitPageUrl + */ + public OneTimeTokenLoginConfigurer defaultSubmitPageUrl(String submitPageUrl) { + Assert.hasText(submitPageUrl, "submitPageUrl cannot be null or empty"); + this.defaultSubmitPageUrl = submitPageUrl; + showDefaultSubmitPage(true); + 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 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..be2273e48f8 --- /dev/null +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurerTests.java @@ -0,0 +1,222 @@ +/* + * 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.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +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.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.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.security.web.authentication.ott.GeneratedOneTimeTokenHandler; +import org.springframework.security.web.authentication.ott.RedirectGeneratedOneTimeTokenHandler; +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/generate").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login/ott")); + + String token = TestGeneratedOneTimeTokenHandler.lastToken.getTokenValue(); + + this.mvc.perform(post("/login/ott").param("token", token).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated()); + } + + @Test + void oneTimeTokenWhenDifferentAuthenticationUrlsThenCanAuthenticate() throws Exception { + this.spring.register(OneTimeTokenDifferentUrlsConfig.class).autowire(); + this.mvc.perform(post("/generateurl").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/redirected")); + + String token = TestGeneratedOneTimeTokenHandler.lastToken.getTokenValue(); + + this.mvc.perform(post("/loginprocessingurl").param("token", token).with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/authenticated"), authenticated()); + } + + @Test + void oneTimeTokenWhenCorrectTokenUsedTwiceThenSecondTimeFails() throws Exception { + this.spring.register(OneTimeTokenDefaultConfig.class).autowire(); + this.mvc.perform(post("/ott/generate").param("username", "user").with(csrf())) + .andExpectAll(status().isFound(), redirectedUrl("/login/ott")); + + String token = TestGeneratedOneTimeTokenHandler.lastToken.getTokenValue(); + + 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/generate").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 oneTimeTokenWhenNoGeneratedOneTimeTokenHandlerThenException() { + assertThatException() + .isThrownBy(() -> this.spring.register(OneTimeTokenNoGeneratedOttHandlerConfig.class).autowire()) + .havingRootCause() + .isInstanceOf(IllegalStateException.class) + .withMessage(""" + A GeneratedOneTimeTokenHandler is required to enable oneTimeTokenLogin(). + Please provide it as a bean or pass it to the oneTimeTokenLogin() 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((ott) -> ott + .generatedOneTimeTokenHandler(new TestGeneratedOneTimeTokenHandler()) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @Import(UserDetailsServiceConfig.class) + static class OneTimeTokenDifferentUrlsConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authz) -> authz + .anyRequest().authenticated() + ) + .oneTimeTokenLogin((ott) -> ott + .generateTokenUrl("/generateurl") + .generatedOneTimeTokenHandler(new TestGeneratedOneTimeTokenHandler("/redirected")) + .loginProcessingUrl("/loginprocessingurl") + .authenticationSuccessHandler(new SimpleUrlAuthenticationSuccessHandler("/authenticated")) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @Import(UserDetailsServiceConfig.class) + static class OneTimeTokenNoGeneratedOttHandlerConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authz) -> authz + .anyRequest().authenticated() + ) + .oneTimeTokenLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + + } + + static class TestGeneratedOneTimeTokenHandler implements GeneratedOneTimeTokenHandler { + + private static OneTimeToken lastToken; + + private final GeneratedOneTimeTokenHandler delegate; + + TestGeneratedOneTimeTokenHandler() { + this.delegate = new RedirectGeneratedOneTimeTokenHandler("/login/ott"); + } + + TestGeneratedOneTimeTokenHandler(String redirectUrl) { + this.delegate = new RedirectGeneratedOneTimeTokenHandler(redirectUrl); + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) + throws IOException, ServletException { + lastToken = oneTimeToken; + this.delegate.handle(request, response, 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..4133da396d9 --- /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 getTokenValue() { + return this.token; + } + + @Override + public String getUsername() { + return this.username; + } + + public Instant getExpiresAt() { + return this.expireAt; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java b/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java new file mode 100644 index 00000000000..c9a023ef832 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/GenerateOneTimeTokenRequest.java @@ -0,0 +1,40 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.authentication.ott; + +import org.springframework.util.Assert; + +/** + * Class to store information related to an One-Time Token authentication request + * + * @author Marcus da Coregio + * @since 6.4 + */ +public class GenerateOneTimeTokenRequest { + + private final String username; + + public GenerateOneTimeTokenRequest(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/InMemoryOneTimeTokenService.java b/core/src/main/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenService.java new file mode 100644 index 00000000000..9683ca49842 --- /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 final class InMemoryOneTimeTokenService implements OneTimeTokenService { + + private final Map oneTimeTokenByToken = new ConcurrentHashMap<>(); + + private Clock clock = Clock.systemUTC(); + + @Override + @NonNull + public OneTimeToken generate(GenerateOneTimeTokenRequest request) { + String token = UUID.randomUUID().toString(); + Instant fiveMinutesFromNow = this.clock.instant().plusSeconds(300); + OneTimeToken ott = new DefaultOneTimeToken(token, request.getUsername(), fiveMinutesFromNow); + this.oneTimeTokenByToken.put(token, ott); + cleanExpiredTokensIfNeeded(); + return ott; + } + + @Override + public OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken) { + OneTimeToken ott = this.oneTimeTokenByToken.remove(authenticationToken.getTokenValue()); + 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.getExpiresAt()); + } + + 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..b2def9ef242 --- /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 value, never {@code null} + */ + String getTokenValue(); + + /** + * @return the username associated with this token, never {@code null} + */ + String getUsername(); + + /** + * @return the expiration time of the token + */ + Instant getExpiresAt(); + +} 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..e5db268cecb --- /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 final 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 = OneTimeTokenAuthenticationToken.authenticated(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/OneTimeTokenAuthenticationToken.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationToken.java new file mode 100644 index 00000000000..eda644dca3c --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationToken.java @@ -0,0 +1,101 @@ +/* + * 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; + +/** + * Represents a One-Time Token authentication that can be authenticated or not. + * + * @author Marcus da Coregio + * @since 6.4 + */ +public class OneTimeTokenAuthenticationToken extends AbstractAuthenticationToken { + + private final Object principal; + + private String tokenValue; + + public OneTimeTokenAuthenticationToken(Object principal, String tokenValue) { + super(Collections.emptyList()); + this.tokenValue = tokenValue; + this.principal = principal; + } + + public OneTimeTokenAuthenticationToken(String tokenValue) { + this(null, tokenValue); + } + + public OneTimeTokenAuthenticationToken(Object principal, Collection authorities) { + super(authorities); + this.principal = principal; + setAuthenticated(true); + } + + /** + * Creates an unauthenticated token + * @param tokenValue the one-time token value + * @return an unauthenticated {@link OneTimeTokenAuthenticationToken} + */ + public static OneTimeTokenAuthenticationToken unauthenticated(String tokenValue) { + return new OneTimeTokenAuthenticationToken(null, tokenValue); + } + + /** + * Creates an unauthenticated token + * @param principal the principal + * @param tokenValue the one-time token value + * @return an unauthenticated {@link OneTimeTokenAuthenticationToken} + */ + public static OneTimeTokenAuthenticationToken unauthenticated(Object principal, String tokenValue) { + return new OneTimeTokenAuthenticationToken(principal, tokenValue); + } + + /** + * Creates an unauthenticated token + * @param principal the principal + * @param authorities the principal authorities + * @return an authenticated {@link OneTimeTokenAuthenticationToken} + */ + public static OneTimeTokenAuthenticationToken authenticated(Object principal, + Collection authorities) { + return new OneTimeTokenAuthenticationToken(principal, authorities); + } + + /** + * Returns the one-time token value + * @return + */ + public String getTokenValue() { + return this.tokenValue; + } + + @Override + public Object getCredentials() { + return this.tokenValue; + } + + @Override + public Object getPrincipal() { + return this.principal; + } + +} 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..e8584a8fb36 --- /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 generate request. + * @param request the generate request containing the necessary information to + * generate the token + * @return the generated {@link OneTimeToken}, never {@code null}. + */ + @NonNull + OneTimeToken generate(GenerateOneTimeTokenRequest request); + + /** + * Consumes a one-time token based on the provided authentication token. + * @param authenticationToken the authentication token containing the one-time token + * value 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..23b398708b6 --- /dev/null +++ b/core/src/test/java/org/springframework/security/authentication/ott/InMemoryOneTimeTokenServiceTests.java @@ -0,0 +1,113 @@ +/* + * 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() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest("user"); + OneTimeToken oneTimeToken = this.oneTimeTokenService.generate(request); + assertThatNoException().isThrownBy(() -> UUID.fromString(oneTimeToken.getTokenValue())); + 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() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest("user"); + OneTimeToken generated = this.oneTimeTokenService.generate(request); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken( + generated.getTokenValue()); + OneTimeToken consumed = this.oneTimeTokenService.consume(authenticationToken); + assertThat(consumed.getTokenValue()).isEqualTo(generated.getTokenValue()); + assertThat(consumed.getUsername()).isEqualTo(generated.getUsername()); + assertThat(consumed.getExpiresAt()).isEqualTo(generated.getExpiresAt()); + } + + @Test + void consumeWhenTokenIsExpiredThenReturnNull() { + GenerateOneTimeTokenRequest request = new GenerateOneTimeTokenRequest("user"); + OneTimeToken generated = this.oneTimeTokenService.generate(request); + OneTimeTokenAuthenticationToken authenticationToken = new OneTimeTokenAuthenticationToken( + generated.getTokenValue()); + 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.getTokenValue()))) + .containsOnlyNulls(); + + assertThat(toKeep) + .extracting( + (token) -> this.oneTimeTokenService.consume(new OneTimeTokenAuthenticationToken(token.getTokenValue()))) + .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 GenerateOneTimeTokenRequest("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..12dcab92209 --- /dev/null +++ b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc @@ -0,0 +1,256 @@ +[[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: Usually a custom javadoc:org.springframework.security.web.authentication.ott.GeneratedOneTimeTokenHandler[] must be implemented, responsible for delivering the token to the end user. +- OTP: The token is often 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.GenerateOneTimeTokenRequest)[] 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 requests a token by submitting their user identifier, usually the username, and the token is delivered to them, often as a Magic Link, via e-mail, SMS, etc. +2. User submits the token to the one-time token login endpoint 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 + +It is not possible for Spring Security to reasonably determine the way the token should be delivered to your users. +Therefore, a custom javadoc:org.springframework.security.web.authentication.ott.GeneratedOneTimeTokenHandler[] must be provided to deliver the token to the user based on your needs. +One of the most common delivery strategies is a Magic Link, via e-mail, SMS, etc. +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, MagicLinkGeneratedOneTimeTokenSuccessHandler magicLinkSender) { + 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 MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOneTimeTokenSuccessHandler { + + private final MailSender mailSender; + + private final GeneratedOneTimeTokenSuccessHandler redirectHandler = new RedirectGeneratedOneTimeTokenSuccessHandler("/ott/sent"); + + // constructor omitted + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) throws IOException, ServletException { + UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request)) + .replacePath(request.getContextPath()) + .replaceQuery(null) + .fragment(null) + .path("/login/ott") + .queryParam("token", oneTimeToken.getTokenValue()); <2> + String magicLink = builder.toUriString(); + String email = getUserEmail(oneTimeToken.getUsername()); <3> + this.mailSender.send(email, "Your Spring Security One Time Token", "Use the following link to sign in into the application: " + magicLink); <4> + this.redirectHandler.handle(request, response, oneTimeToken); <5> + } + + private String getUserEmail() { + // ... + } + +} + +@Controller +class PageController { + + @GetMapping("/ott/sent") + String ottSent() { + return "my-template"; + } + +} + +---- +====== + +<1> Make the `MagicLinkGeneratedOneTimeTokenSuccessHandler` a Spring bean +<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 +<5> Use the `RedirectGeneratedOneTimeTokenSuccessHandler` to perform a redirect to your desired URL + +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 fill the form field with the token value. + +[[changing-generate-url]] +== Changing the One-Time Token Generate URL + +By default, the javadoc:org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter[] listens to `POST /ott/generate` requests. +That URL can be changed by using the `generateTokenUrl(String)` DSL method: + +.Changing the Generate 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 + .generateTokenUrl("/ott/my-generate-url") + ); + return http.build(); + } + +} + +@Component +public class MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOneTimeTokenSuccessHandler { + // ... +} +---- +====== + +[[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(); + } + +} + +@Component +public class MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOneTimeTokenSuccessHandler { + // ... +} +---- +====== + +[[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 + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/my-ott-submit").permitAll() + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin((ott) -> ott + .showDefaultSubmitPage(false) + ); + return http.build(); + } + +} + +@Controller +public class MyController { + + @GetMapping("/my-ott-submit") + public String ottSubmitPage() { + return "my-ott-submit"; + } + +} + +@Component +public class MagicLinkGeneratedOneTimeTokenSuccessHandler implements GeneratedOneTimeTokenSuccessHandler { + // ... +} +---- +====== + + diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index fe69f5c4851..4e0f392ff1b 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -187,6 +187,10 @@ fun app(val http: HttpSecurity): SecurityFilterChain { ====== You can read more https://github.com/spring-projects/spring-security/issues/15220[in the related ticket]. +== One-Time Token Login + +Spring Security now xref:servlet/authentication/onetimetoken.adoc[supports One-Time Token Login] via the `oneTimeTokenLogin()` DSL. + == Kotlin * The Kotlin DSL now supports https://github.com/spring-projects/spring-security/issues/14935[SAML 2.0] and https://github.com/spring-projects/spring-security/issues/15171[`GrantedAuthorityDefaults`] and https://github.com/spring-projects/spring-security/issues/15136[`RoleHierarchy`] ``@Bean``s diff --git a/etc/checkstyle/checkstyle-suppressions.xml b/etc/checkstyle/checkstyle-suppressions.xml index 7233843cfb5..3e5caf070a9 100644 --- a/etc/checkstyle/checkstyle-suppressions.xml +++ b/etc/checkstyle/checkstyle-suppressions.xml @@ -37,6 +37,7 @@ + diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java new file mode 100644 index 00000000000..8bb88cf17b1 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/GenerateOneTimeTokenFilter.java @@ -0,0 +1,94 @@ +/* + * 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.http.HttpMethod; +import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; +import org.springframework.security.authentication.ott.OneTimeToken; +import org.springframework.security.authentication.ott.OneTimeTokenService; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import static org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher; + +/** + * Filter that process a One-Time Token generation request. + * + * @author Marcus da Coregio + * @since 6.4 + * @see OneTimeTokenService + */ +public final class GenerateOneTimeTokenFilter extends OncePerRequestFilter { + + private final OneTimeTokenService oneTimeTokenService; + + private RequestMatcher requestMatcher = antMatcher(HttpMethod.POST, "/ott/generate"); + + private GeneratedOneTimeTokenHandler generatedOneTimeTokenHandler = new RedirectGeneratedOneTimeTokenHandler( + "/login/ott"); + + public GenerateOneTimeTokenFilter(OneTimeTokenService oneTimeTokenService) { + Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null"); + this.oneTimeTokenService = oneTimeTokenService; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!this.requestMatcher.matches(request)) { + filterChain.doFilter(request, response); + return; + } + String username = request.getParameter("username"); + if (!StringUtils.hasText(username)) { + filterChain.doFilter(request, response); + return; + } + GenerateOneTimeTokenRequest generateRequest = new GenerateOneTimeTokenRequest(username); + OneTimeToken ott = this.oneTimeTokenService.generate(generateRequest); + this.generatedOneTimeTokenHandler.handle(request, response, ott); + } + + /** + * Use the given {@link RequestMatcher} to match the request. + * @param requestMatcher + */ + public void setRequestMatcher(RequestMatcher requestMatcher) { + Assert.notNull(requestMatcher, "requestMatcher cannot be null"); + this.requestMatcher = requestMatcher; + } + + /** + * Specifies {@link GeneratedOneTimeTokenHandler} to be used to handle generated + * one-time tokens + * @param generatedOneTimeTokenHandler + */ + public void setGeneratedOneTimeTokenHandler(GeneratedOneTimeTokenHandler generatedOneTimeTokenHandler) { + Assert.notNull(generatedOneTimeTokenHandler, "generatedOneTimeTokenHandler cannot be null"); + this.generatedOneTimeTokenHandler = generatedOneTimeTokenHandler; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/GeneratedOneTimeTokenHandler.java b/web/src/main/java/org/springframework/security/web/authentication/ott/GeneratedOneTimeTokenHandler.java new file mode 100644 index 00000000000..2fb0e24cd3c --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/GeneratedOneTimeTokenHandler.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 java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.ott.OneTimeToken; + +/** + * Defines a strategy to handle generated one-time tokens. + * + * @author Marcus da Coregio + * @since 6.4 + */ +@FunctionalInterface +public interface GeneratedOneTimeTokenHandler { + + /** + * Handles generated one-time tokens + */ + void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) + throws IOException, ServletException; + +} 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..a667c9987a5 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationConverter.java @@ -0,0 +1,51 @@ +/* + * 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; + +/** + * An implementation of {@link AuthenticationConverter} that detects if the request + * contains a {@code token} parameter and constructs a + * {@link OneTimeTokenAuthenticationToken} with it. + * + * @author Marcus da Coregio + * @since 6.4 + * @see GenerateOneTimeTokenFilter + */ +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 OneTimeTokenAuthenticationToken.unauthenticated(token); + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/RedirectGeneratedOneTimeTokenHandler.java b/web/src/main/java/org/springframework/security/web/authentication/ott/RedirectGeneratedOneTimeTokenHandler.java new file mode 100644 index 00000000000..c814de85564 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/RedirectGeneratedOneTimeTokenHandler.java @@ -0,0 +1,56 @@ +/* + * 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.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.ott.OneTimeToken; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.util.Assert; + +/** + * A {@link GeneratedOneTimeTokenHandler} that performs a redirect to a specific location + * + * @author Marcus da Coregio + * @since 6.4 + */ +public final class RedirectGeneratedOneTimeTokenHandler implements GeneratedOneTimeTokenHandler { + + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + private final String redirectUrl; + + /** + * Constructs an instance of this class that redirects to the specified URL. + * @param redirectUrl + */ + public RedirectGeneratedOneTimeTokenHandler(String redirectUrl) { + Assert.hasText(redirectUrl, "redirectUrl cannot be empty or null"); + this.redirectUrl = redirectUrl; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken) + throws IOException { + this.redirectStrategy.sendRedirect(request, response, this.redirectUrl); + } + +} 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..efdc73e9a6d 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 generateOneTimeTokenUrl; + 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 setGenerateOneTimeTokenUrl(String generateOneTimeTokenUrl) { + this.generateOneTimeTokenUrl = generateOneTimeTokenUrl; + } + 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..8d47ebc7bd4 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilter.java @@ -0,0 +1,138 @@ +/* + * 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.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.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.HtmlUtils; + +/** + * Creates a default one-time token submit page. If the request contains a {@code token} + * query param the page will automatically fill the form with the token value. + * + * @author Marcus da Coregio + * @since 6.4 + */ +public final 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) { + String token = request.getParameter("token"); + String inputValue = StringUtils.hasText(token) ? HtmlUtils.htmlEscape(token) : ""; + String input = ""; + return """ + + + + One-Time Token Login + + + + """ + + CssUtils.getCssStyleBlock().indent(4) + + """ + + + +
    + """ + + "
    " + """ +

    Please input the token

    +

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

    + + """ + renderHiddenInputs(request) + """ +
    +
    + + + """; + } + + 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..c1fe329f055 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 @@ -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. @@ -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.setGenerateOneTimeTokenUrl("/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..092669707e1 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationConverterTests.java @@ -0,0 +1,72 @@ +/* + * 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.getTokenValue()).isEqualTo("1234"); + assertThat(authentication.getPrincipal()).isNull(); + } + + @Test + void convertWhenTokenAndUsernameParameterThenReturnOneTimeTokenAuthenticationTokenWithUsername() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("token", "1234"); + OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter + .convert(request); + assertThat(authentication).isNotNull(); + assertThat(authentication.getTokenValue()).isEqualTo("1234"); + } + + @Test + void convertWhenOnlyUsernameParameterThenReturnNull() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setParameter("username", "josh"); + OneTimeTokenAuthenticationToken authentication = (OneTimeTokenAuthenticationToken) this.converter + .convert(request); + assertThat(authentication).isNull(); + } + + @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/RedirectGeneratedOneTimeTokenHandlerTests.java b/web/src/test/java/org/springframework/security/web/authentication/ott/RedirectGeneratedOneTimeTokenHandlerTests.java new file mode 100644 index 00000000000..1316ee31514 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ott/RedirectGeneratedOneTimeTokenHandlerTests.java @@ -0,0 +1,62 @@ +/* + * 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 java.time.Instant; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.ott.DefaultOneTimeToken; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link RedirectGeneratedOneTimeTokenHandler} + * + * @author Marcus da Coregio + */ +class RedirectGeneratedOneTimeTokenHandlerTests { + + @Test + void handleThenRedirectToDefaultLocation() throws IOException { + RedirectGeneratedOneTimeTokenHandler handler = new RedirectGeneratedOneTimeTokenHandler("/login/ott"); + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.handle(new MockHttpServletRequest(), response, new DefaultOneTimeToken("token", "user", Instant.now())); + assertThat(response.getRedirectedUrl()).isEqualTo("/login/ott"); + } + + @Test + void handleWhenUrlChangedThenRedirectToUrl() throws IOException { + MockHttpServletResponse response = new MockHttpServletResponse(); + RedirectGeneratedOneTimeTokenHandler handler = new RedirectGeneratedOneTimeTokenHandler("/redirected"); + handler.handle(new MockHttpServletRequest(), response, new DefaultOneTimeToken("token", "user", Instant.now())); + assertThat(response.getRedirectedUrl()).isEqualTo("/redirected"); + } + + @Test + void setRedirectUrlWhenNullOrEmptyThenException() { + assertThatIllegalArgumentException().isThrownBy(() -> new RedirectGeneratedOneTimeTokenHandler(null)) + .withMessage("redirectUrl cannot be empty or null"); + assertThatIllegalArgumentException().isThrownBy(() -> new RedirectGeneratedOneTimeTokenHandler("")) + .withMessage("redirectUrl cannot be empty or null"); + } + +} 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..df4eab303d9 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ui/DefaultOneTimeTokenSubmitPageGeneratingFilterTests.java @@ -0,0 +1,88 @@ +/* + * 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.setParameter("token", "1234"); + this.filter.doFilterInternal(this.request, this.response, this.filterChain); + String response = this.response.getContentAsString(); + 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

    "); + } + + @Test + void filterWhenTokenQueryParamUsesSpecialCharactersThenValueIsEscaped() throws Exception { + this.request.setParameter("token", "this<>!@#\""); + this.filter.doFilterInternal(this.request, this.response, this.filterChain); + String response = this.response.getContentAsString(); + assertThat(response).contains( + ""); + } + +}