Просмотр исходного кода

Introduce OneTimeTokenAuthenticationFilter

closes gh-16539

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
Daniel Garnier-Moiroux 6 месяцев назад
Родитель
Сommit
5ee6b83953

+ 2 - 0
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());

+ 10 - 7
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<H extends HttpSecurityBuilder<H>>
 
 	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<H extends HttpSecurityBuilder<H>>
 
 	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));
 	}
 

+ 73 - 0
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.
+ * <p>
+ * 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;
+	}
+
+}

+ 125 - 0
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("/");
+	}
+
+}