From 5ee6b8395310be7234af812a463b3c1b95742488 Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Wed, 5 Feb 2025 11:46:37 +0100 Subject: [PATCH] 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 6f297cdb23..dd5972420f 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.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.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 cb34bf5f3f..eca10d52aa 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.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; @@ -45,6 +44,7 @@ import org.springframework.security.web.authentication.ott.DefaultGenerateOneTim 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 final class OneTimeTokenLoginConfigurer> 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 0000000000..ba2930e491 --- /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 0000000000..3fc3ec70de --- /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("/"); + } + +}