diff --git a/core/src/main/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManager.java b/core/src/main/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManager.java index 1c62c03689..98cd90367d 100644 --- a/core/src/main/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManager.java +++ b/core/src/main/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManager.java @@ -20,6 +20,7 @@ import java.util.function.Function; import reactor.core.publisher.Mono; +import org.springframework.security.authentication.AccountStatusUserDetailsChecker; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.ott.InvalidOneTimeTokenException; import org.springframework.security.authentication.ott.OneTimeTokenAuthentication; @@ -27,6 +28,7 @@ import org.springframework.security.authentication.ott.OneTimeTokenAuthenticatio import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsChecker; import org.springframework.util.Assert; /** @@ -41,6 +43,9 @@ public final class OneTimeTokenReactiveAuthenticationManager implements Reactive private final ReactiveUserDetailsService userDetailsService; + private UserDetailsChecker userDetailsChecker = (user) -> { + }; + public OneTimeTokenReactiveAuthenticationManager(ReactiveOneTimeTokenService oneTimeTokenService, ReactiveUserDetailsService userDetailsService) { Assert.notNull(oneTimeTokenService, "oneTimeTokenService cannot be null"); @@ -57,6 +62,7 @@ public final class OneTimeTokenReactiveAuthenticationManager implements Reactive return this.oneTimeTokenService.consume(otpAuthenticationToken) .switchIfEmpty(Mono.defer(() -> Mono.error(new InvalidOneTimeTokenException("Invalid token")))) .flatMap((consumed) -> this.userDetailsService.findByUsername(consumed.getUsername())) + .doOnNext(this.userDetailsChecker::check) .map(onSuccess(otpAuthenticationToken)); } @@ -68,4 +74,21 @@ public final class OneTimeTokenReactiveAuthenticationManager implements Reactive }; } + /** + * Use this {@link UserDetailsChecker} to verify the status of the loaded + * {@link UserDetails} after authentication. + * + *
+ * By default, no checks are performed, keeping this manager'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/reactive/OneTimeTokenReactiveAuthenticationManagerTests.java b/core/src/test/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManagerTests.java
index de5213d480..02ed665d37 100644
--- a/core/src/test/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManagerTests.java
+++ b/core/src/test/java/org/springframework/security/authentication/ott/reactive/OneTimeTokenReactiveAuthenticationManagerTests.java
@@ -18,11 +18,16 @@ package org.springframework.security.authentication.ott.reactive;
import java.time.Instant;
import java.util.Collection;
+import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import reactor.core.publisher.Mono;
+import org.springframework.security.authentication.AccountExpiredException;
+import org.springframework.security.authentication.AccountStatusUserDetailsChecker;
+import org.springframework.security.authentication.DisabledException;
+import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.ReactiveAuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.ott.DefaultOneTimeToken;
@@ -105,6 +110,106 @@ public class OneTimeTokenReactiveAuthenticationManagerTests {
assertThat(CollectionUtils.isEmpty(authorities)).isFalse();
}
+ @Test
+ @SuppressWarnings("removal")
+ void authenticateWhenAccountStatusInvalidAndNoUserDetailsCheckerThenAuthenticates() {
+ ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+ given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
+ .willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
+ ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
+ User testUser = new User(USERNAME, PASSWORD, false, false, false, false, List.of());
+ given(userDetailsService.findByUsername(eq(USERNAME))).willReturn(Mono.just(testUser));
+
+ this.authenticationManager = new OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService,
+ userDetailsService);
+
+ Authentication authentication = this.authenticationManager
+ .authenticate(OneTimeTokenAuthenticationToken.unauthenticated(TOKEN))
+ .block();
+
+ assertThat(authentication.isAuthenticated()).isTrue();
+ }
+
+ @Test
+ @SuppressWarnings("removal")
+ void authenticateWhenUserDetailsCheckerConfiguredAndAccountLockedThenThrowsLockedException() {
+ ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+ given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
+ .willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
+ ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
+ User testUser = new User(USERNAME, PASSWORD, true, true, true, false, List.of());
+ given(userDetailsService.findByUsername(eq(USERNAME))).willReturn(Mono.just(testUser));
+
+ OneTimeTokenReactiveAuthenticationManager manager = new OneTimeTokenReactiveAuthenticationManager(
+ oneTimeTokenService, userDetailsService);
+ manager.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
+ this.authenticationManager = manager;
+
+ // @formatter:off
+ assertThatExceptionOfType(LockedException.class)
+ .isThrownBy(() -> this.authenticationManager.authenticate(OneTimeTokenAuthenticationToken.unauthenticated(TOKEN))
+ .block());
+ // @formatter:on
+ }
+
+ @Test
+ @SuppressWarnings("removal")
+ void authenticateWhenUserDetailsCheckerConfiguredAndAccountDisabledThenThrowsDisabledException() {
+ ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+ given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
+ .willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
+ ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
+ User testUser = new User(USERNAME, PASSWORD, false, true, true, true, List.of());
+ given(userDetailsService.findByUsername(eq(USERNAME))).willReturn(Mono.just(testUser));
+
+ OneTimeTokenReactiveAuthenticationManager manager = new OneTimeTokenReactiveAuthenticationManager(
+ oneTimeTokenService, userDetailsService);
+ manager.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
+ this.authenticationManager = manager;
+
+ // @formatter:off
+ assertThatExceptionOfType(DisabledException.class)
+ .isThrownBy(() -> this.authenticationManager.authenticate(OneTimeTokenAuthenticationToken.unauthenticated(TOKEN))
+ .block());
+ // @formatter:on
+ }
+
+ @Test
+ @SuppressWarnings("removal")
+ void authenticateWhenUserDetailsCheckerConfiguredAndAccountExpiredThenThrowsAccountExpiredException() {
+ ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+ given(oneTimeTokenService.consume(ArgumentMatchers.any(OneTimeTokenAuthenticationToken.class)))
+ .willReturn(Mono.just(new DefaultOneTimeToken(TOKEN, USERNAME, Instant.now())));
+ ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
+ User testUser = new User(USERNAME, PASSWORD, true, false, true, true, List.of());
+ given(userDetailsService.findByUsername(eq(USERNAME))).willReturn(Mono.just(testUser));
+
+ OneTimeTokenReactiveAuthenticationManager manager = new OneTimeTokenReactiveAuthenticationManager(
+ oneTimeTokenService, userDetailsService);
+ manager.setUserDetailsChecker(new AccountStatusUserDetailsChecker());
+ this.authenticationManager = manager;
+
+ // @formatter:off
+ assertThatExceptionOfType(AccountExpiredException.class)
+ .isThrownBy(() -> this.authenticationManager.authenticate(OneTimeTokenAuthenticationToken.unauthenticated(TOKEN))
+ .block());
+ // @formatter:on
+ }
+
+ @Test
+ void setUserDetailsCheckerWhenNullThenThrowsIllegalArgumentException() {
+ ReactiveOneTimeTokenService oneTimeTokenService = mock(ReactiveOneTimeTokenService.class);
+ ReactiveUserDetailsService userDetailsService = mock(ReactiveUserDetailsService.class);
+ OneTimeTokenReactiveAuthenticationManager manager = new OneTimeTokenReactiveAuthenticationManager(
+ oneTimeTokenService, userDetailsService);
+
+ // @formatter:off
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> manager.setUserDetailsChecker(null))
+ .withMessage("userDetailsChecker cannot be null");
+ // @formatter:on
+ }
+
@Test
@SuppressWarnings("removal")
void authenticateWhenInvalidOneTimeTokenAuthenticationTokenIsPresentThenFail() {
diff --git a/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc b/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc
index 9d5412e4b1..e9855da71e 100644
--- a/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc
+++ b/docs/modules/ROOT/pages/reactive/authentication/onetimetoken.adoc
@@ -38,6 +38,7 @@ In the following sections we will explore how to configure OTT Login for your ne
- <