Skip to content

Commit 2d72856

Browse files
committed
Introduce OneTimeTokenAuthenticationFilter
closes gh-16539 Signed-off-by: Daniel Garnier-Moiroux <[email protected]>
1 parent f7e0f7f commit 2d72856

File tree

4 files changed

+210
-7
lines changed

4 files changed

+210
-7
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java

+2
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
3232
import org.springframework.security.web.authentication.logout.LogoutFilter;
3333
import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter;
34+
import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter;
3435
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
3536
import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter;
3637
import org.springframework.security.web.authentication.rememberme.RememberMeAuthenticationFilter;
@@ -101,6 +102,7 @@ final class FilterOrderRegistration {
101102
"org.springframework.security.saml2.provider.service.web.authentication.Saml2WebSsoAuthenticationFilter",
102103
order.next());
103104
put(UsernamePasswordAuthenticationFilter.class, order.next());
105+
put(OneTimeTokenAuthenticationFilter.class, order.next());
104106
order.next(); // gh-8105
105107
put(DefaultResourcesFilter.class, order.next());
106108
put(DefaultLoginPageGeneratingFilter.class, order.next());

config/src/main/java/org/springframework/security/config/annotation/web/configurers/ott/OneTimeTokenLoginConfigurer.java

+10-7
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@
3737
import org.springframework.security.core.userdetails.UserDetailsService;
3838
import org.springframework.security.web.authentication.AuthenticationConverter;
3939
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
40-
import org.springframework.security.web.authentication.AuthenticationFilter;
4140
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
4241
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
4342
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
4443
import org.springframework.security.web.authentication.ott.DefaultGenerateOneTimeTokenRequestResolver;
4544
import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenFilter;
4645
import org.springframework.security.web.authentication.ott.GenerateOneTimeTokenRequestResolver;
4746
import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationConverter;
47+
import org.springframework.security.web.authentication.ott.OneTimeTokenAuthenticationFilter;
4848
import org.springframework.security.web.authentication.ott.OneTimeTokenGenerationSuccessHandler;
4949
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
5050
import org.springframework.security.web.authentication.ui.DefaultOneTimeTokenSubmitPageGeneratingFilter;
@@ -74,7 +74,7 @@ public final class OneTimeTokenLoginConfigurer<H extends HttpSecurityBuilder<H>>
7474

7575
private boolean submitPageEnabled = true;
7676

77-
private String loginProcessingUrl = "/login/ott";
77+
private String loginProcessingUrl = OneTimeTokenAuthenticationFilter.DEFAULT_LOGIN_PROCESSING_URL;
7878

7979
private String tokenGeneratingUrl = "/ott/generate";
8080

@@ -119,12 +119,15 @@ public void configure(H http) {
119119

120120
private void configureOttAuthenticationFilter(H http) {
121121
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
122-
AuthenticationFilter oneTimeTokenAuthenticationFilter = new AuthenticationFilter(authenticationManager,
123-
this.authenticationConverter);
122+
OneTimeTokenAuthenticationFilter oneTimeTokenAuthenticationFilter = new OneTimeTokenAuthenticationFilter();
123+
oneTimeTokenAuthenticationFilter.setAuthenticationManager(authenticationManager);
124+
if (this.loginProcessingUrl != null) {
125+
oneTimeTokenAuthenticationFilter
126+
.setRequiresAuthenticationRequestMatcher(antMatcher(HttpMethod.POST, this.loginProcessingUrl));
127+
}
128+
oneTimeTokenAuthenticationFilter.setAuthenticationSuccessHandler(this.authenticationSuccessHandler);
129+
oneTimeTokenAuthenticationFilter.setAuthenticationFailureHandler(getAuthenticationFailureHandler());
124130
oneTimeTokenAuthenticationFilter.setSecurityContextRepository(getSecurityContextRepository(http));
125-
oneTimeTokenAuthenticationFilter.setRequestMatcher(antMatcher(HttpMethod.POST, this.loginProcessingUrl));
126-
oneTimeTokenAuthenticationFilter.setFailureHandler(getAuthenticationFailureHandler());
127-
oneTimeTokenAuthenticationFilter.setSuccessHandler(this.authenticationSuccessHandler);
128131
http.addFilter(postProcess(oneTimeTokenAuthenticationFilter));
129132
}
130133

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.authentication.ott;
18+
19+
import java.io.IOException;
20+
21+
import jakarta.servlet.ServletException;
22+
import jakarta.servlet.http.HttpServletRequest;
23+
import jakarta.servlet.http.HttpServletResponse;
24+
25+
import org.springframework.security.authentication.BadCredentialsException;
26+
import org.springframework.security.core.Authentication;
27+
import org.springframework.security.core.AuthenticationException;
28+
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
29+
import org.springframework.security.web.authentication.AuthenticationConverter;
30+
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
31+
import org.springframework.util.Assert;
32+
33+
/**
34+
* Filter that processes a one-time token for log in.
35+
* <p>
36+
* By default, it uses {@link OneTimeTokenAuthenticationConverter} to extract the token
37+
* from the request.
38+
*
39+
* @author Daniel Garnier-Moiroux
40+
* @since 6.5
41+
*/
42+
public final class OneTimeTokenAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
43+
44+
public static final String DEFAULT_LOGIN_PROCESSING_URL = "/login/ott";
45+
46+
private AuthenticationConverter authenticationConverter = new OneTimeTokenAuthenticationConverter();
47+
48+
public OneTimeTokenAuthenticationFilter() {
49+
super(new AntPathRequestMatcher(DEFAULT_LOGIN_PROCESSING_URL, "POST"));
50+
}
51+
52+
@Override
53+
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
54+
throws AuthenticationException, IOException, ServletException {
55+
Authentication authentication = this.authenticationConverter.convert(request);
56+
if (authentication == null) {
57+
throw new BadCredentialsException("Unable to authenticate with the one-time token");
58+
}
59+
return getAuthenticationManager().authenticate(authentication);
60+
}
61+
62+
/**
63+
* Use this {@link AuthenticationConverter} when converting incoming requests to an
64+
* {@link Authentication}. By default, the {@link OneTimeTokenAuthenticationConverter}
65+
* is used.
66+
* @param authenticationConverter the {@link AuthenticationConverter} to use
67+
*/
68+
public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) {
69+
Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
70+
this.authenticationConverter = authenticationConverter;
71+
}
72+
73+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2002-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.authentication.ott;
18+
19+
import java.io.IOException;
20+
21+
import jakarta.servlet.FilterChain;
22+
import jakarta.servlet.ServletException;
23+
import jakarta.servlet.http.HttpServletResponse;
24+
import org.junit.jupiter.api.BeforeEach;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.api.extension.ExtendWith;
27+
import org.mockito.Mock;
28+
import org.mockito.junit.jupiter.MockitoExtension;
29+
30+
import org.springframework.http.HttpStatus;
31+
import org.springframework.mock.web.MockHttpServletResponse;
32+
import org.springframework.security.authentication.AuthenticationManager;
33+
import org.springframework.security.authentication.BadCredentialsException;
34+
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationToken;
35+
import org.springframework.security.core.authority.AuthorityUtils;
36+
import org.springframework.security.web.servlet.MockServletContext;
37+
38+
import static org.assertj.core.api.Assertions.assertThat;
39+
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
40+
import static org.mockito.ArgumentMatchers.any;
41+
import static org.mockito.BDDMockito.given;
42+
import static org.mockito.Mockito.mock;
43+
import static org.mockito.Mockito.verify;
44+
import static org.mockito.Mockito.verifyNoInteractions;
45+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
46+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
47+
48+
/**
49+
* Tests for {@link OneTimeTokenAuthenticationFilter}.
50+
*
51+
* @author Daniel Garnier-Moiroux
52+
* @since 6.5
53+
*/
54+
@ExtendWith(MockitoExtension.class)
55+
class OneTimeTokenAuthenticationFilterTests {
56+
57+
@Mock
58+
private FilterChain chain;
59+
60+
@Mock
61+
private AuthenticationManager authenticationManager;
62+
63+
private final OneTimeTokenAuthenticationFilter filter = new OneTimeTokenAuthenticationFilter();
64+
65+
private final HttpServletResponse response = new MockHttpServletResponse();
66+
67+
@BeforeEach
68+
void setUp() {
69+
this.filter.setAuthenticationManager(this.authenticationManager);
70+
}
71+
72+
@Test
73+
void setAuthenticationConverterWhenNullThenIllegalArgumentException() {
74+
assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setAuthenticationConverter(null));
75+
}
76+
77+
@Test
78+
void doFilterWhenUrlDoesNotMatchThenContinues() throws ServletException, IOException {
79+
OneTimeTokenAuthenticationConverter converter = mock(OneTimeTokenAuthenticationConverter.class);
80+
HttpServletResponse response = mock(HttpServletResponse.class);
81+
this.filter.setAuthenticationConverter(converter);
82+
this.filter.doFilter(post("/nomatch").buildRequest(new MockServletContext()), response, this.chain);
83+
verifyNoInteractions(converter, response);
84+
verify(this.chain).doFilter(any(), any());
85+
}
86+
87+
@Test
88+
void doFilterWhenMethodDoesNotMatchThenContinues() throws ServletException, IOException {
89+
OneTimeTokenAuthenticationConverter converter = mock(OneTimeTokenAuthenticationConverter.class);
90+
HttpServletResponse response = mock(HttpServletResponse.class);
91+
this.filter.setAuthenticationConverter(converter);
92+
this.filter.doFilter(get("/login/ott").buildRequest(new MockServletContext()), response, this.chain);
93+
verifyNoInteractions(converter, response);
94+
verify(this.chain).doFilter(any(), any());
95+
}
96+
97+
@Test
98+
void doFilterWhenMissingTokenThenUnauthorized() throws ServletException, IOException {
99+
this.filter.doFilter(post("/login/ott").buildRequest(new MockServletContext()), this.response, this.chain);
100+
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
101+
verifyNoInteractions(this.chain);
102+
}
103+
104+
@Test
105+
void doFilterWhenInvalidTokenThenUnauthorized() throws ServletException, IOException {
106+
given(this.authenticationManager.authenticate(any())).willThrow(new BadCredentialsException("invalid token"));
107+
this.filter.doFilter(
108+
post("/login/ott").param("token", "some-token-value").buildRequest(new MockServletContext()),
109+
this.response, this.chain);
110+
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED.value());
111+
verifyNoInteractions(this.chain);
112+
}
113+
114+
@Test
115+
void doFilterWhenValidThenRedirectsToSavedRequest() throws ServletException, IOException {
116+
given(this.authenticationManager.authenticate(any()))
117+
.willReturn(OneTimeTokenAuthenticationToken.authenticated("username", AuthorityUtils.NO_AUTHORITIES));
118+
this.filter.doFilter(
119+
post("/login/ott").param("token", "some-token-value").buildRequest(new MockServletContext()),
120+
this.response, this.chain);
121+
assertThat(this.response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
122+
assertThat(this.response.getHeader("location")).endsWith("/");
123+
}
124+
125+
}

0 commit comments

Comments
 (0)