From 238f47ce5e9f6d6d797564146650a6010d834b01 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 22 Jan 2025 11:33:10 +0100 Subject: [PATCH] One Time Token login registers the default login page closes gh-16414 Signed-off-by: Daniel Garnier-Moiroux --- .../web/builders/FilterOrderRegistration.java | 2 +- .../ott/OneTimeTokenLoginConfigurer.java | 151 +++++++++++------- .../config/web/server/ServerHttpSecurity.java | 57 +++++-- .../annotation/web/OneTimeTokenLoginDsl.kt | 6 +- .../ott/OneTimeTokenLoginConfigurerTests.java | 84 +++++----- .../server/OneTimeTokenLoginSpecTests.java | 48 ++++++ .../servlet/authentication/onetimetoken.adoc | 4 +- .../ott/GenerateOneTimeTokenFilter.java | 4 +- .../ui/DefaultLoginPageGeneratingFilter.java | 4 +- ...neTimeTokenSubmitPageGeneratingFilter.java | 9 +- 10 files changed, 242 insertions(+), 127 deletions(-) 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 dd5972420f..23f7150461 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. 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 index eca10d52aa..6f1e02ca6e 100644 --- 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 @@ -23,7 +23,6 @@ import jakarta.servlet.http.HttpServletRequest; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpMethod; -import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.ott.GenerateOneTimeTokenRequest; import org.springframework.security.authentication.ott.InMemoryOneTimeTokenService; @@ -32,6 +31,9 @@ import org.springframework.security.authentication.ott.OneTimeTokenAuthenticatio 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.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetailsService; @@ -49,34 +51,70 @@ import org.springframework.security.web.authentication.ott.OneTimeTokenGeneratio import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultResourcesFilter; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; -import org.springframework.security.web.context.SecurityContextRepository; import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.util.matcher.RequestMatcher; 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> { +/** + * An {@link AbstractHttpConfigurer} for One-Time Token Login. + * + *

+ * One-Time Token Login provides an application with the capability to have users log in + * by obtaining a single-use token out of band, for example through email. + * + *

+ * Defaults are provided for all configuration options, with the only required + * configuration being + * {@link #tokenGenerationSuccessHandler(OneTimeTokenGenerationSuccessHandler)}. + * Alternatively, a {@link OneTimeTokenGenerationSuccessHandler} {@code @Bean} may be + * registered instead. + * + *

Security Filters

+ * + * The following {@code Filter}s are populated: + * + *
    + *
  • {@link DefaultOneTimeTokenSubmitPageGeneratingFilter}
  • + *
  • {@link GenerateOneTimeTokenFilter}
  • + *
  • {@link OneTimeTokenAuthenticationFilter}
  • + *
+ * + *

Shared Objects Used

+ * + * The following shared objects are used: + * + *
    + *
  • {@link DefaultLoginPageGeneratingFilter} - if {@link #loginPage(String)} is not + * configured and {@code DefaultLoginPageGeneratingFilter} is available, then a default + * login page will be made available
  • + *
+ * + * @author Marcus Da Coregio + * @author Daniel Garnier-Moiroux + * @since 6.4 + * @see HttpSecurity#oneTimeTokenLogin(Customizer) + * @see DefaultOneTimeTokenSubmitPageGeneratingFilter + * @see GenerateOneTimeTokenFilter + * @see OneTimeTokenAuthenticationFilter + * @see AbstractAuthenticationFilterConfigurer + */ +public final class OneTimeTokenLoginConfigurer> extends + AbstractAuthenticationFilterConfigurer, OneTimeTokenAuthenticationFilter> { 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 String defaultSubmitPageUrl = DefaultOneTimeTokenSubmitPageGeneratingFilter.DEFAULT_SUBMIT_PAGE_URL; private boolean submitPageEnabled = true; private String loginProcessingUrl = OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL; - private String tokenGeneratingUrl = "/ott/generate"; + private String tokenGeneratingUrl = GenerateOneTimeTokenFilter.DEFAULT_GENERATE_URL; private OneTimeTokenGenerationSuccessHandler oneTimeTokenGenerationSuccessHandler; @@ -85,58 +123,41 @@ public final class OneTimeTokenLoginConfigurer> private GenerateOneTimeTokenRequestResolver requestResolver; public OneTimeTokenLoginConfigurer(ApplicationContext context) { + super(new OneTimeTokenAuthenticationFilter(), OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL); this.context = context; } @Override - public void init(H http) { + public void init(H http) throws Exception { + super.init(http); AuthenticationProvider authenticationProvider = getAuthenticationProvider(); http.authenticationProvider(postProcess(authenticationProvider)); - configureDefaultLoginPage(http); + intiDefaultLoginFilter(http); } - private void configureDefaultLoginPage(H http) { + private void intiDefaultLoginFilter(H http) { DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http .getSharedObject(DefaultLoginPageGeneratingFilter.class); - if (loginPageGeneratingFilter == null) { + if (loginPageGeneratingFilter == null || isCustomLoginPage()) { return; } loginPageGeneratingFilter.setOneTimeTokenEnabled(true); loginPageGeneratingFilter.setOneTimeTokenGenerationUrl(this.tokenGeneratingUrl); - if (this.authenticationFailureHandler == null - && StringUtils.hasText(loginPageGeneratingFilter.getLoginPageUrl())) { - this.authenticationFailureHandler = new SimpleUrlAuthenticationFailureHandler( - loginPageGeneratingFilter.getLoginPageUrl() + "?error"); + + if (!StringUtils.hasText(loginPageGeneratingFilter.getLoginPageUrl())) { + loginPageGeneratingFilter.setLoginPageUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL); + loginPageGeneratingFilter.setFailureUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL + "?" + + DefaultLoginPageGeneratingFilter.ERROR_PARAMETER_NAME); + loginPageGeneratingFilter + .setLogoutSuccessUrl(DefaultLoginPageGeneratingFilter.DEFAULT_LOGIN_PAGE_URL + "?logout"); } } @Override - public void configure(H http) { + public void configure(H http) throws Exception { + super.configure(http); configureSubmitPage(http); configureOttGenerateFilter(http); - configureOttAuthenticationFilter(http); - } - - private void configureOttAuthenticationFilter(H http) { - AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); - OneTimeTokenAuthenticationFilter oneTimeTokenAuthenticationFilter = new OneTimeTokenAuthenticationFilter(); - oneTimeTokenAuthenticationFilter.setAuthenticationManager(authenticationManager); - if (this.loginProcessingUrl != null) { - oneTimeTokenAuthenticationFilter - .setRequiresAuthenticationRequestMatcher(antMatcher(HttpMethod.POST, this.loginProcessingUrl)); - } - oneTimeTokenAuthenticationFilter.setAuthenticationSuccessHandler(this.authenticationSuccessHandler); - oneTimeTokenAuthenticationFilter.setAuthenticationFailureHandler(getAuthenticationFailureHandler()); - oneTimeTokenAuthenticationFilter.setSecurityContextRepository(getSecurityContextRepository(http)); - 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) { @@ -170,7 +191,7 @@ public final class OneTimeTokenLoginConfigurer> DefaultOneTimeTokenSubmitPageGeneratingFilter submitPage = new DefaultOneTimeTokenSubmitPageGeneratingFilter(); submitPage.setResolveHiddenInputs(this::hiddenInputs); submitPage.setRequestMatcher(antMatcher(HttpMethod.GET, this.defaultSubmitPageUrl)); - submitPage.setLoginProcessingUrl(this.loginProcessingUrl); + submitPage.setLoginProcessingUrl(this.getLoginProcessingUrl()); http.addFilter(postProcess(submitPage)); } @@ -184,6 +205,11 @@ public final class OneTimeTokenLoginConfigurer> return this.authenticationProvider; } + @Override + protected RequestMatcher createLoginProcessingUrlMatcher(String loginProcessingUrl) { + return antMatcher(HttpMethod.POST, loginProcessingUrl); + } + /** * Specifies the {@link AuthenticationProvider} to use when authenticating the user. * @param authenticationProvider @@ -221,14 +247,25 @@ public final class OneTimeTokenLoginConfigurer> * 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) + * @see HttpSecurity#csrf(Customizer) */ public OneTimeTokenLoginConfigurer loginProcessingUrl(String loginProcessingUrl) { Assert.hasText(loginProcessingUrl, "loginProcessingUrl cannot be null or empty"); - this.loginProcessingUrl = loginProcessingUrl; + super.loginProcessingUrl(loginProcessingUrl); return this; } + /** + * Specifies the URL to send users to if login is required. If used with + * {@link EnableWebSecurity} a default login page will be generated when this + * attribute is not specified. + * @param loginPage + */ + @Override + public OneTimeTokenLoginConfigurer loginPage(String loginPage) { + return super.loginPage(loginPage); + } + /** * Configures whether the default one-time token submit page should be shown. This * will prevent the {@link DefaultOneTimeTokenSubmitPageGeneratingFilter} to be @@ -273,7 +310,7 @@ public final class OneTimeTokenLoginConfigurer> */ public OneTimeTokenLoginConfigurer authenticationConverter(AuthenticationConverter authenticationConverter) { Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); - this.authenticationConverter = authenticationConverter; + this.getAuthenticationFilter().setAuthenticationConverter(authenticationConverter); return this; } @@ -283,11 +320,13 @@ public final class OneTimeTokenLoginConfigurer> * {@link SimpleUrlAuthenticationFailureHandler} * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} to use * when authentication fails. + * @deprecated Use {@link #failureHandler(AuthenticationFailureHandler)} instead */ + @Deprecated(since = "6.5") public OneTimeTokenLoginConfigurer authenticationFailureHandler( AuthenticationFailureHandler authenticationFailureHandler) { Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null"); - this.authenticationFailureHandler = authenticationFailureHandler; + super.failureHandler(authenticationFailureHandler); return this; } @@ -296,22 +335,16 @@ public final class OneTimeTokenLoginConfigurer> * {@link SavedRequestAwareAuthenticationSuccessHandler} with no additional properties * set. * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler}. + * @deprecated Use {@link #successHandler(AuthenticationSuccessHandler)} instead */ + @Deprecated(since = "6.5") public OneTimeTokenLoginConfigurer authenticationSuccessHandler( AuthenticationSuccessHandler authenticationSuccessHandler) { Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null"); - this.authenticationSuccessHandler = authenticationSuccessHandler; + super.successHandler(authenticationSuccessHandler); return this; } - private AuthenticationFailureHandler getAuthenticationFailureHandler() { - if (this.authenticationFailureHandler != null) { - return this.authenticationFailureHandler; - } - this.authenticationFailureHandler = new SimpleUrlAuthenticationFailureHandler("/login?error"); - return this.authenticationFailureHandler; - } - /** * Use this {@link GenerateOneTimeTokenRequestResolver} when resolving * {@link GenerateOneTimeTokenRequest} from {@link HttpServletRequest}. By default, diff --git a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java index ec7da0f266..fbe5bfb903 100644 --- a/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java @@ -3039,7 +3039,8 @@ public class ServerHttpSecurity { return; } if (http.formLogin != null && http.formLogin.isEntryPointExplicit - || http.oauth2Login != null && StringUtils.hasText(http.oauth2Login.loginPage)) { + || http.oauth2Login != null && StringUtils.hasText(http.oauth2Login.loginPage) + || http.oneTimeTokenLogin != null && StringUtils.hasText(http.oneTimeTokenLogin.loginPage)) { return; } LoginPageGeneratingWebFilter loginPage = null; @@ -3054,6 +3055,13 @@ public class ServerHttpSecurity { } loginPage.setOauth2AuthenticationUrlToClientName(urlToText); } + if (http.oneTimeTokenLogin != null) { + if (loginPage == null) { + loginPage = new LoginPageGeneratingWebFilter(); + } + loginPage.setOneTimeTokenEnabled(true); + loginPage.setGenerateOneTimeTokenUrl(http.oneTimeTokenLogin.tokenGeneratingUrl); + } if (loginPage != null) { http.addFilterAt(loginPage, SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); http.addFilterBefore(DefaultResourcesWebFilter.css(), SecurityWebFiltersOrder.LOGIN_PAGE_GENERATING); @@ -5954,11 +5962,13 @@ public class ServerHttpSecurity { private boolean submitPageEnabled = true; + private String loginPage; + protected void configure(ServerHttpSecurity http) { configureSubmitPage(http); configureOttGenerateFilter(http); configureOttAuthenticationFilter(http); - configureDefaultLoginPage(http); + configureDefaultEntryPoint(http); } private void configureOttAuthenticationFilter(ServerHttpSecurity http) { @@ -5995,17 +6005,29 @@ public class ServerHttpSecurity { http.addFilterAt(generateFilter, SecurityWebFiltersOrder.ONE_TIME_TOKEN); } - private void configureDefaultLoginPage(ServerHttpSecurity http) { - if (http.formLogin != null) { - for (WebFilter webFilter : http.webFilters) { - OrderedWebFilter orderedWebFilter = (OrderedWebFilter) webFilter; - if (orderedWebFilter.webFilter instanceof LoginPageGeneratingWebFilter loginPageGeneratingFilter) { - loginPageGeneratingFilter.setOneTimeTokenEnabled(true); - loginPageGeneratingFilter.setGenerateOneTimeTokenUrl(this.tokenGeneratingUrl); - break; - } + private void configureDefaultEntryPoint(ServerHttpSecurity http) { + MediaTypeServerWebExchangeMatcher htmlMatcher = new MediaTypeServerWebExchangeMatcher( + MediaType.APPLICATION_XHTML_XML, new MediaType("image", "*"), MediaType.TEXT_HTML, + MediaType.TEXT_PLAIN); + htmlMatcher.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL)); + ServerWebExchangeMatcher xhrMatcher = (exchange) -> { + if (exchange.getRequest().getHeaders().getOrEmpty("X-Requested-With").contains("XMLHttpRequest")) { + return ServerWebExchangeMatcher.MatchResult.match(); } + return ServerWebExchangeMatcher.MatchResult.notMatch(); + }; + ServerWebExchangeMatcher notXhrMatcher = new NegatedServerWebExchangeMatcher(xhrMatcher); + ServerWebExchangeMatcher defaultEntryPointMatcher = new AndServerWebExchangeMatcher(notXhrMatcher, + htmlMatcher); + String loginPage = "/login"; + if (this.loginPage != null) { + loginPage = this.loginPage; } + RedirectServerAuthenticationEntryPoint defaultEntryPoint = new RedirectServerAuthenticationEntryPoint( + loginPage); + defaultEntryPoint.setRequestCache(http.requestCache.requestCache); + http.defaultEntryPoints.add(new DelegateEntry(defaultEntryPointMatcher, defaultEntryPoint)); + } /** @@ -6233,6 +6255,19 @@ public class ServerHttpSecurity { return this.tokenGenerationSuccessHandler; } + /** + * Specifies the URL to send users to if login is required. A default login page + * will be generated when this attribute is not specified. + * @param loginPage the URL to send users to if login is required + * @return the {@link OAuth2LoginSpec} for further configuration + * @since 6.5 + */ + public OneTimeTokenLoginSpec loginPage(String loginPage) { + Assert.hasText(loginPage, "loginPage cannot be empty"); + this.loginPage = loginPage; + return this; + } + } } diff --git a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt index 2345bc5a67..8fa6d58fa5 100644 --- a/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt +++ b/config/src/main/kotlin/org/springframework/security/config/annotation/web/OneTimeTokenLoginDsl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2002-2024 the original author or authors. + * Copyright 2002-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -62,12 +62,12 @@ class OneTimeTokenLoginDsl { tokenService?.also { oneTimeTokenLoginConfigurer.tokenService(tokenService) } authenticationConverter?.also { oneTimeTokenLoginConfigurer.authenticationConverter(authenticationConverter) } authenticationFailureHandler?.also { - oneTimeTokenLoginConfigurer.authenticationFailureHandler( + oneTimeTokenLoginConfigurer.failureHandler( authenticationFailureHandler ) } authenticationSuccessHandler?.also { - oneTimeTokenLoginConfigurer.authenticationSuccessHandler( + oneTimeTokenLoginConfigurer.successHandler( authenticationSuccessHandler ) } 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 index a5f8417892..0b965cfc2f 100644 --- 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 @@ -146,8 +146,8 @@ public class OneTimeTokenLoginConfigurerTests { } @Test - void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm() throws Exception { - this.spring.register(OneTimeTokenFormLoginConfig.class).autowire(); + void oneTimeTokenWhenConfiguredThenRendersRequestTokenForm() throws Exception { + this.spring.register(OneTimeTokenDefaultConfig.class).autowire(); CsrfToken csrfToken = new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", "BaseSpringSpec_CSRFTOKEN"); String csrfAttributeName = HttpSessionCsrfTokenRepository.class.getName().concat(".CSRF_TOKEN"); //@formatter:off @@ -168,21 +168,7 @@ public class OneTimeTokenLoginConfigurerTests {
-