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:
+20
-50
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+53
-3
@@ -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]
|
||||
|
||||
+59
@@ -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[]
|
||||
|
||||
}
|
||||
+63
@@ -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[]
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user