1
0
mirror of synced 2026-08-23 02:27:44 +00:00

Make OneTimeTokenAuthenticationProvider Account Status Checks Opt-In

OneTimeTokenAuthenticationProvider no longer validates account status
by default. Applications can opt in via setUserDetailsChecker, for
example by providing AccountStatusUserDetailsChecker to reject locked,
disabled, or expired accounts.

Issue gh-17655

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
This commit is contained in:
Josh Cummings
2026-08-07 13:10:33 -06:00
parent 64fa98da1a
commit b3e262187c
5 changed files with 206 additions and 53 deletions
@@ -19,21 +19,12 @@ package org.springframework.security.authentication.ott;
import java.util.Collection;
import java.util.HashSet;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsChecker;
@@ -50,7 +41,7 @@ import org.springframework.util.Assert;
* @author Andrey Litvitski
* @since 6.4
*/
public final class OneTimeTokenAuthenticationProvider implements AuthenticationProvider, MessageSourceAware {
public final class OneTimeTokenAuthenticationProvider implements AuthenticationProvider {
private static final String AUTHORITY = FactorGrantedAuthority.OTT_AUTHORITY;
@@ -58,11 +49,8 @@ public final class OneTimeTokenAuthenticationProvider implements AuthenticationP
private final UserDetailsService userDetailsService;
private final Log logger = LogFactory.getLog(getClass());
private UserDetailsChecker authenticationChecks = new DefaultAuthenticationChecks();
private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private UserDetailsChecker userDetailsChecker = (user) -> {
};
public OneTimeTokenAuthenticationProvider(OneTimeTokenService oneTimeTokenService,
UserDetailsService userDetailsService) {
@@ -81,7 +69,7 @@ public final class OneTimeTokenAuthenticationProvider implements AuthenticationP
}
try {
UserDetails user = this.userDetailsService.loadUserByUsername(consumed.getUsername());
this.authenticationChecks.check(user);
this.userDetailsChecker.check(user);
Collection<GrantedAuthority> authorities = new HashSet<>(user.getAuthorities());
authorities.add(FactorGrantedAuthority.fromAuthority(AUTHORITY));
OneTimeTokenAuthentication authenticated = new OneTimeTokenAuthentication(user, authorities);
@@ -98,39 +86,21 @@ public final class OneTimeTokenAuthenticationProvider implements AuthenticationP
return OneTimeTokenAuthenticationToken.class.isAssignableFrom(authentication);
}
@Override
public void setMessageSource(MessageSource messageSource) {
this.messages = new MessageSourceAccessor(messageSource);
}
public void setAuthenticationChecks(UserDetailsChecker authenticationChecks) {
this.authenticationChecks = authenticationChecks;
}
private class DefaultAuthenticationChecks implements UserDetailsChecker {
@Override
public void check(UserDetails user) {
if (!user.isAccountNonLocked()) {
OneTimeTokenAuthenticationProvider.this.logger
.debug("Failed to authenticate since user account is locked");
throw new LockedException(OneTimeTokenAuthenticationProvider.this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.locked", "User account is locked"));
}
if (!user.isEnabled()) {
OneTimeTokenAuthenticationProvider.this.logger
.debug("Failed to authenticate since user account is disabled");
throw new DisabledException(OneTimeTokenAuthenticationProvider.this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled"));
}
if (!user.isAccountNonExpired()) {
OneTimeTokenAuthenticationProvider.this.logger
.debug("Failed to authenticate since user account has expired");
throw new AccountExpiredException(OneTimeTokenAuthenticationProvider.this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.expired", "User account has expired"));
}
}
/**
* Use this {@link UserDetailsChecker} to verify the status of the loaded
* {@link UserDetails} after authentication.
*
* <p>
* By default, no checks are performed, keeping this provider's behavior consistent
* with earlier versions of Spring Security. To reject authentication for accounts
* that are locked, disabled, or expired, provide a
* {@link AccountStatusUserDetailsChecker}.
* @param userDetailsChecker the {@link UserDetailsChecker} to use
* @since 7.2
*/
public void setUserDetailsChecker(UserDetailsChecker userDetailsChecker) {
Assert.notNull(userDetailsChecker, "userDetailsChecker cannot be null");
this.userDetailsChecker = userDetailsChecker;
}
}
@@ -25,10 +25,13 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.AccountExpiredException;
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.SecurityAssertions;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.FactorGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
@@ -84,14 +87,61 @@ public class OneTimeTokenAuthenticationProviderTests {
}
@Test
void authenticateWhenAuthenticationTokenIsPresentThenFails() {
void authenticateWhenAccountStatusInvalidAndNoUserDetailsCheckerThenAuthenticates() {
given(this.oneTimeTokenService.consume(any()))
.willReturn(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now().plusSeconds(120)));
given(this.userDetailsService.loadUserByUsername(anyString()))
.willReturn(new User(USERNAME, PASSWORD, false, false, false, false, List.of()));
OneTimeTokenAuthenticationToken token = new OneTimeTokenAuthenticationToken(TOKEN);
assertThatExceptionOfType(AuthenticationException.class).isThrownBy(() -> this.provider.authenticate(token));
Authentication authentication = this.provider.authenticate(token);
assertThat(authentication.isAuthenticated()).isTrue();
}
@Test
void authenticateWhenUserDetailsCheckerConfiguredAndAccountLockedThenThrowsLockedException() {
given(this.oneTimeTokenService.consume(any()))
.willReturn(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now().plusSeconds(120)));
given(this.userDetailsService.loadUserByUsername(anyString()))
.willReturn(new User(USERNAME, PASSWORD, true, true, true, false, List.of()));
this.provider.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
OneTimeTokenAuthenticationToken token = new OneTimeTokenAuthenticationToken(TOKEN);
assertThatExceptionOfType(LockedException.class).isThrownBy(() -> this.provider.authenticate(token));
}
@Test
void authenticateWhenUserDetailsCheckerConfiguredAndAccountDisabledThenThrowsDisabledException() {
given(this.oneTimeTokenService.consume(any()))
.willReturn(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now().plusSeconds(120)));
given(this.userDetailsService.loadUserByUsername(anyString()))
.willReturn(new User(USERNAME, PASSWORD, false, true, true, true, List.of()));
this.provider.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
OneTimeTokenAuthenticationToken token = new OneTimeTokenAuthenticationToken(TOKEN);
assertThatExceptionOfType(DisabledException.class).isThrownBy(() -> this.provider.authenticate(token));
}
@Test
void authenticateWhenUserDetailsCheckerConfiguredAndAccountExpiredThenThrowsAccountExpiredException() {
given(this.oneTimeTokenService.consume(any()))
.willReturn(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now().plusSeconds(120)));
given(this.userDetailsService.loadUserByUsername(anyString()))
.willReturn(new User(USERNAME, PASSWORD, true, false, true, true, List.of()));
this.provider.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
OneTimeTokenAuthenticationToken token = new OneTimeTokenAuthenticationToken(TOKEN);
assertThatExceptionOfType(AccountExpiredException.class).isThrownBy(() -> this.provider.authenticate(token));
}
@Test
void setUserDetailsCheckerWhenNullThenThrowsIllegalArgumentException() {
// @formatter:off
assertThatIllegalArgumentException()
.isThrownBy(() -> this.provider.setUserDetailsChecker(null))
.withMessage("userDetailsChecker cannot be null");
// @formatter:on
}
@Test
@@ -38,6 +38,7 @@ In the following sections we will explore how to configure OTT Login for your ne
- <<changing-submit-page-url,Configuring the One-Time Token submit page>>
- <<changing-generate-url,Changing the One-Time Token generate URL>>
- <<customize-generate-consume-token,Customize how to generate and consume tokens>>
- <<validating-account-status,Validating account status>>
[[default-pages]]
== Default Login Page and Default One-Time Token Submit Page
@@ -586,3 +587,13 @@ fun generateRequestResolver() : GenerateOneTimeTokenRequestResolver {
}
----
======
[[validating-account-status]]
== Validating Account Status
By default, javadoc:org.springframework.security.authentication.ott.OneTimeTokenAuthenticationProvider[] does not validate the status of the authenticated account -- for example, whether the account is locked, disabled, or expired.
This keeps the provider's behavior consistent with earlier versions of Spring Security; this default may change in future versions of Spring Security.
If you would like `OneTimeTokenAuthenticationProvider` to reject One-Time Token authentication for such accounts, provide it a javadoc:org.springframework.security.core.userdetails.UserDetailsChecker[], such as javadoc:org.springframework.security.authentication.AccountStatusUserDetailsChecker[], with `setUserDetailsChecker`:
include-code::./OneTimeTokenAccountStatusExample[tag=userDetailsChecker,indent=0]
@@ -0,0 +1,59 @@
/*
* Copyright 2004-present 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.docs.servlet.authentication.onetimetokenaccountstatus;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationProvider;
import org.springframework.security.authentication.ott.OneTimeTokenService;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class OneTimeTokenAccountStatusExample {
@Bean
SecurityFilterChain filterChain(HttpSecurity http,
OneTimeTokenAuthenticationProvider oneTimeTokenAuthenticationProvider) {
// @formatter:off
http
// ...
.formLogin(Customizer.withDefaults())
.oneTimeTokenLogin((ott) -> ott
.authenticationProvider(oneTimeTokenAuthenticationProvider)
);
// @formatter:on
return http.build();
}
// tag::userDetailsChecker[]
@Bean
OneTimeTokenAuthenticationProvider oneTimeTokenAuthenticationProvider(OneTimeTokenService oneTimeTokenService,
UserDetailsService userDetailsService) {
OneTimeTokenAuthenticationProvider provider = new OneTimeTokenAuthenticationProvider(oneTimeTokenService,
userDetailsService);
provider.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
return provider;
}
// end::userDetailsChecker[]
}
@@ -0,0 +1,63 @@
/*
* Copyright 2004-present 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.kt.docs.servlet.authentication.onetimetokenaccountstatus
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.authentication.AccountStatusUserDetailsChecker
import org.springframework.security.authentication.ott.OneTimeTokenAuthenticationProvider
import org.springframework.security.authentication.ott.OneTimeTokenService
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.invoke
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity
@Configuration(proxyBeanMethods = false)
class OneTimeTokenAccountStatusExample {
@Bean
fun filterChain(
http: HttpSecurity,
oneTimeTokenAuthenticationProvider: OneTimeTokenAuthenticationProvider
): SecurityFilterChain {
// @formatter:off
http {
// ...
formLogin { }
oneTimeTokenLogin {
authenticationProvider = oneTimeTokenAuthenticationProvider
}
}
// @formatter:on
return http.build()
}
// tag::userDetailsChecker[]
@Bean
fun oneTimeTokenAuthenticationProvider(
oneTimeTokenService: OneTimeTokenService,
userDetailsService: UserDetailsService
): OneTimeTokenAuthenticationProvider {
val provider = OneTimeTokenAuthenticationProvider(oneTimeTokenService, userDetailsService)
provider.setUserDetailsChecker(AccountStatusUserDetailsChecker())
return provider
}
// end::userDetailsChecker[]
}