diff --git a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java index 462c991026..b5c427de3d 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProvider.java @@ -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 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. + * + *

+ * 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; } } diff --git a/core/src/test/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProviderTests.java b/core/src/test/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProviderTests.java index a5db5f5883..7e3f797678 100644 --- a/core/src/test/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProviderTests.java +++ b/core/src/test/java/org/springframework/security/authentication/ott/OneTimeTokenAuthenticationProviderTests.java @@ -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 diff --git a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc index f78f60a8a2..2c725da80f 100644 --- a/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc +++ b/docs/modules/ROOT/pages/servlet/authentication/onetimetoken.adoc @@ -38,6 +38,7 @@ In the following sections we will explore how to configure OTT Login for your ne - <> - <> - <> +- <> [[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] diff --git a/docs/src/test/java/org/springframework/security/docs/servlet/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.java b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.java new file mode 100644 index 0000000000..6e4218f382 --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/servlet/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.java @@ -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[] + +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.kt new file mode 100644 index 0000000000..f4bcc8a99b --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/servlet/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.kt @@ -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[] + +}