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 - <> - <> - <> +- <> [[default-pages]] == Default Login Page and Default One-Time Token Submit Page @@ -578,3 +579,13 @@ fun generateOneTimeTokenRequestResolver() : ServerGenerateOneTimeTokenRequestRes } ---- ====== + +[[validating-account-status]] +== Validating Account Status + +By default, javadoc:org.springframework.security.authentication.ott.reactive.OneTimeTokenReactiveAuthenticationManager[] does not validate the status of the authenticated account -- for example, whether the account is locked, disabled, or expired. +This keeps the manager's behavior consistent with earlier versions of Spring Security; this default may change in future versions of Spring Security. + +If you would like `OneTimeTokenReactiveAuthenticationManager` 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/reactive/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.java b/docs/src/test/java/org/springframework/security/docs/reactive/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.java new file mode 100644 index 0000000000..6002fd04ea --- /dev/null +++ b/docs/src/test/java/org/springframework/security/docs/reactive/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.reactive.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.reactive.OneTimeTokenReactiveAuthenticationManager; +import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.web.server.SecurityWebFilterChain; + +@EnableWebFluxSecurity +@Configuration(proxyBeanMethods = false) +class OneTimeTokenAccountStatusExample { + + @Bean + SecurityWebFilterChain filterChain(ServerHttpSecurity http, + OneTimeTokenReactiveAuthenticationManager oneTimeTokenAuthenticationManager) { + // @formatter:off + http + // ... + .formLogin(Customizer.withDefaults()) + .oneTimeTokenLogin((ott) -> ott + .authenticationManager(oneTimeTokenAuthenticationManager) + ); + // @formatter:on + return http.build(); + } + + // tag::userDetailsChecker[] + @Bean + OneTimeTokenReactiveAuthenticationManager oneTimeTokenAuthenticationManager( + ReactiveOneTimeTokenService oneTimeTokenService, ReactiveUserDetailsService userDetailsService) { + OneTimeTokenReactiveAuthenticationManager authenticationManager = new OneTimeTokenReactiveAuthenticationManager( + oneTimeTokenService, userDetailsService); + authenticationManager.setUserDetailsChecker(new AccountStatusUserDetailsChecker()); + return authenticationManager; + } + // end::userDetailsChecker[] + +} diff --git a/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.kt b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.kt new file mode 100644 index 0000000000..c497055dff --- /dev/null +++ b/docs/src/test/kotlin/org/springframework/security/kt/docs/reactive/authentication/onetimetokenaccountstatus/OneTimeTokenAccountStatusExample.kt @@ -0,0 +1,62 @@ +/* + * 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.reactive.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.reactive.OneTimeTokenReactiveAuthenticationManager +import org.springframework.security.authentication.ott.reactive.ReactiveOneTimeTokenService +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity +import org.springframework.security.config.web.server.ServerHttpSecurity +import org.springframework.security.config.web.server.invoke +import org.springframework.security.core.userdetails.ReactiveUserDetailsService +import org.springframework.security.web.server.SecurityWebFilterChain + +@EnableWebFluxSecurity +@Configuration(proxyBeanMethods = false) +class OneTimeTokenAccountStatusExample { + + @Bean + fun filterChain( + http: ServerHttpSecurity, + oneTimeTokenAuthenticationManager: OneTimeTokenReactiveAuthenticationManager + ): SecurityWebFilterChain { + // @formatter:off + return http { + // ... + formLogin { } + oneTimeTokenLogin { + authenticationManager = oneTimeTokenAuthenticationManager + } + } + // @formatter:on + } + + // tag::userDetailsChecker[] + @Bean + fun oneTimeTokenAuthenticationManager( + oneTimeTokenService: ReactiveOneTimeTokenService, + userDetailsService: ReactiveUserDetailsService + ): OneTimeTokenReactiveAuthenticationManager { + val authenticationManager = OneTimeTokenReactiveAuthenticationManager(oneTimeTokenService, userDetailsService) + authenticationManager.setUserDetailsChecker(AccountStatusUserDetailsChecker()) + return authenticationManager + } + // end::userDetailsChecker[] + +}