From 2d728561b00390f28de737b7a97f540f93b68129 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 5 Feb 2025 11:46:37 +0100 Subject: [PATCH 1/2] Introduce OneTimeTokenAuthenticationFilter closes gh-16539 Signed-off-by: Daniel Garnier-Moiroux --- .../web/builders/FilterOrderRegistration.java | 2 + .../ott/OneTimeTokenLoginConfigurer.java | 17 ++- .../ott/OneTimeTokenAuthenticationFilter.java | 73 ++++++++++ ...OneTimeTokenAuthenticationFilterTests.java | 125 ++++++++++++++++++ 4 files changed, 210 insertions(+), 7 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilter.java create mode 100644 web/src/test/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilterTests.java 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 6f297cdb231..dd5972420f2 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 @@ -31,6 +31,7 @@ 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.ott.OneTimeTokenAuthenticationFilter; import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter; import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter; @@ -101,6 +102,7 @@ final class FilterOrderRegistration { "org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter", order.next()); put(UsernamePasswordAuthenticationFilter.class, order.next()); + put(OneTimeTokenAuthenticationFilter.class, order.next()); order.next(); // gh-8105 put(DefaultResourcesFilter.class, order.next()); put(DefaultLoginPageGeneratingFilter.class, order.next()); 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 cb34bf5f3fd..eca10d52aae 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 @@ -37,7 +37,6 @@ 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; @@ -45,6 +44,7 @@ import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter; import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver; import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationConverter; +import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter; import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler; import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter; import org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter; @@ -74,7 +74,7 @@ public final class OneTimeTokenLoginConfigurer> private boolean submitPageEnabled = true; - private String loginProcessingUrl = "/login/ott"; + private String loginProcessingUrl = OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL; private String tokenGeneratingUrl = "/ott/generate"; @@ -119,12 +119,15 @@ public void configure(H http) { private void configureOttAuthenticationFilter(H http) { AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class); - AuthenticationFilter oneTimeTokenAuthenticationFilter = new AuthenticationFilter(authenticationManager, - this.authenticationConverter); + 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)); - oneTimeTokenAuthenticationFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.loginProcessingUrl)); - oneTimeTokenAuthenticationFilter.setFailureHandler(getAuthenticationFailureHandler()); - oneTimeTokenAuthenticationFilter.setSuccessHandler(this.authenticationSuccessHandler); http.addFilter(postProcess(oneTimeTokenAuthenticationFilter)); } diff --git a/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilter.java b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilter.java new file mode 100644 index 00000000000..ba2930e4910 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilter.java @@ -0,0 +1,73 @@ +/* + * 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. + * 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.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.util.Assert; + +/** + * Filter that processes a one-time token for log in. + *

+ * By default, it uses {@link OneTimeTokenAuthenticationConverter} to extract the token + * from the request. + * + * @author Daniel Garnier-Moiroux + * @since 6.5 + */ +public final class OneTimeTokenAuthenticationFilter extends AbstractAuthenticationProcessingFilter { + + public static final String DEFAULT_LOGIN_PROCESSING_URL = "/login/ott"; + + private AuthenticationConverter authenticationConverter = new OneTimeTokenAuthenticationConverter(); + + public OneTimeTokenAuthenticationFilter() { + super(new AntPathRequestMatcher(DEFAULT_LOGIN_PROCESSING_URL, "POST")); + } + + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) + throws AuthenticationException, IOException, ServletException { + Authentication authentication = this.authenticationConverter.convert(request); + if (authentication == null) { + throw new BadCredentialsException("Unable to authenticate with the one-time token"); + } + return getAuthenticationManager().authenticate(authentication); + } + + /** + * 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 void setAuthenticationConverter(AuthenticationConverter authenticationConverter) { + Assert.notNull(authenticationConverter, "authenticationConverter cannot be null"); + this.authenticationConverter = authenticationConverter; + } + +} diff --git a/web/src/test/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilterTests.java b/web/src/test/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilterTests.java new file mode 100644 index 00000000000..3fc3ec70deb --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/authentication/ott/OneTimeTokenAuthenticationFilterTests.java @@ -0,0 +1,125 @@ +/* + * 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. + * 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.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.web.servlet.MockServletContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * Tests for {@link OneTimeTokenAuthenticationFilter}. + * + * @author Daniel Garnier-Moiroux + * @since 6.5 + */ +@ExtendWith(MockitoExtension.class) +class OneTimeTokenAuthenticationFilterTests { + + @Mock + private FilterChain chain; + + @Mock + private AuthenticationManager authenticationManager; + + private final OneTimeTokenAuthenticationFilter filter = new OneTimeTokenAuthenticationFilter(); + + private final HttpServletResponse response = new MockHttpServletResponse(); + + @BeforeEach + void setUp() { + this.filter.setAuthenticationManager(this.authenticationManager); + } + + @Test + void setAuthenticationConverterWhenNullThenIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setAuthenticationConverter(null)); + } + + @Test + void doFilterWhenUrlDoesNotMatchThenContinues() throws ServletException, IOException { + OneTimeTokenAuthenticationConverter converter = mock(OneTimeTokenAuthenticationConverter.class); + HttpServletResponse response = mock(HttpServletResponse.class); + this.filter.setAuthenticationConverter(converter); + this.filter.doFilter(post("/nomatch").buildRequest(new MockServletContext()), response, this.chain); + verifyNoInteractions(converter, response); + verify(this.chain).doFilter(any(), any()); + } + + @Test + void doFilterWhenMethodDoesNotMatchThenContinues() throws ServletException, IOException { + OneTimeTokenAuthenticationConverter converter = mock(OneTimeTokenAuthenticationConverter.class); + HttpServletResponse response = mock(HttpServletResponse.class); + this.filter.setAuthenticationConverter(converter); + this.filter.doFilter(get("/login/ott").buildRequest(new MockServletContext()), response, this.chain); + verifyNoInteractions(converter, response); + verify(this.chain).doFilter(any(), any()); + } + + @Test + void doFilterWhenMissingTokenThenUnauthorized() throws ServletException, IOException { + this.filter.doFilter(post("/login/ott").buildRequest(new MockServletContext()), this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + verifyNoInteractions(this.chain); + } + + @Test + void doFilterWhenInvalidTokenThenUnauthorized() throws ServletException, IOException { + given(this.authenticationManager.authenticate(any())).willThrow(new BadCredentialsException("invalid token")); + this.filter.doFilter( + post("/login/ott").param("token", "some-token-value").buildRequest(new MockServletContext()), + this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value()); + verifyNoInteractions(this.chain); + } + + @Test + void doFilterWhenValidThenRedirectsToSavedRequest() throws ServletException, IOException { + given(this.authenticationManager.authenticate(any())) + .willReturn(OneTimeTokenAuthenticationToken.authenticated("username", AuthorityUtils.NO_AUTHORITIES)); + this.filter.doFilter( + post("/login/ott").param("token", "some-token-value").buildRequest(new MockServletContext()), + this.response, this.chain); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.FOUND.value()); + assertThat(this.response.getHeader("location")).endsWith("/"); + } + +} From 95001c3a7b9794d05d7bf4a197857ff99a6f9f6a Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 22 Jan 2025 11:33:10 +0100 Subject: [PATCH 2/2] 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 | 42 +++-- .../server/OneTimeTokenLoginSpecTests.java | 48 ++++++ .../servlet/authentication/onetimetoken.adoc | 4 +- .../ott/GenerateOneTimeTokenFilter.java | 4 +- .../ui/DefaultLoginPageGeneratingFilter.java | 4 +- ...neTimeTokenSubmitPageGeneratingFilter.java | 9 +- 10 files changed, 221 insertions(+), 106 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 dd5972420f2..23f71504615 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 eca10d52aae..6f1e02ca6ed 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 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.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.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 @@ private void configureSubmitPage(H http) { 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 @@ private AuthenticationProvider getAuthenticationProvider() { 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 OneTimeTokenLoginConfigurer tokenGenerationSuccessHandler( * 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 OneTimeTokenLoginConfigurer tokenService(OneTimeTokenService oneTimeTo */ 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 OneTimeTokenLoginConfigurer authenticationConverter(AuthenticationConv * {@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 OneTimeTokenLoginConfigurer authenticationFailureHandler( * {@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 ac72b75eb9a..12f7e5f1138 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 @@ -3035,7 +3035,8 @@ protected void configure(ServerHttpSecurity http) { 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; @@ -3050,6 +3051,13 @@ protected void configure(ServerHttpSecurity http) { } 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); @@ -5948,11 +5956,13 @@ public final class OneTimeTokenLoginSpec { 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) { @@ -5988,17 +5998,29 @@ private void configureOttGenerateFilter(ServerHttpSecurity http) { 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)); + } /** @@ -6200,6 +6222,19 @@ Please provide it as a bean or pass it to the oneTimeTokenLogin() DSL. 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 2345bc5a679..8fa6d58fa5b 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 a5f8417892f..0b965cfc2f8 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 @@ void oneTimeTokenWhenConfiguredThenServesCss() throws Exception { } @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 @@ void oneTimeTokenWhenFormLoginConfiguredThenRendersRequestTokenForm() throws Exc
-