Introduce OneTimeTokenAuthenticationFilter

closes gh-16539

Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
Daniel Garnier-Moiroux 2025-02-05 11:46:37 +01:00 committed by Rob Winch
parent 8e2a4bf356
commit 5ee6b83953
4 changed files with 210 additions and 7 deletions

View File

@ -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());

View File

@ -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));
}

View File

@ -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;
}
}

View File

@ -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("/");
}
}