diff --git a/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java index 07fff8886ba..bf46c1acf60 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/authentication/configuration/InitializeUserDetailsBeanManagerConfigurer.java @@ -21,6 +21,7 @@ import org.springframework.core.annotation.Order; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.core.password.CompromisedPasswordChecker; import org.springframework.security.core.userdetails.UserDetailsPasswordService; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; @@ -65,6 +66,7 @@ public void configure(AuthenticationManagerBuilder auth) throws Exception { } PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class); UserDetailsPasswordService passwordManager = getBeanOrNull(UserDetailsPasswordService.class); + CompromisedPasswordChecker passwordChecker = getBeanOrNull(CompromisedPasswordChecker.class); DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setUserDetailsService(userDetailsService); if (passwordEncoder != null) { @@ -73,6 +75,9 @@ public void configure(AuthenticationManagerBuilder auth) throws Exception { if (passwordManager != null) { provider.setUserDetailsPasswordService(passwordManager); } + if (passwordChecker != null) { + provider.setCompromisedPasswordChecker(passwordChecker); + } provider.afterPropertiesSet(); auth.authenticationProvider(provider); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfiguration.java index 74b8337a4b7..18065662a02 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfiguration.java @@ -33,6 +33,7 @@ import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.authentication.UserDetailsRepositoryReactiveAuthenticationManager; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.password.ReactiveCompromisedPasswordChecker; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; @@ -63,6 +64,8 @@ class ServerHttpSecurityConfiguration { private ReactiveUserDetailsPasswordService userDetailsPasswordService; + private ReactiveCompromisedPasswordChecker compromisedPasswordChecker; + private ObservationRegistry observationRegistry = ObservationRegistry.NOOP; @Autowired(required = false) @@ -98,6 +101,11 @@ void setObservationRegistry(ObservationRegistry observationRegistry) { this.observationRegistry = observationRegistry; } + @Autowired(required = false) + void setCompromisedPasswordChecker(ReactiveCompromisedPasswordChecker compromisedPasswordChecker) { + this.compromisedPasswordChecker = compromisedPasswordChecker; + } + @Bean static WebFluxConfigurer authenticationPrincipalArgumentResolverConfigurer( ObjectProvider authenticationPrincipalArgumentResolver) { @@ -153,6 +161,7 @@ private ReactiveAuthenticationManager authenticationManager() { manager.setPasswordEncoder(this.passwordEncoder); } manager.setUserDetailsPasswordService(this.userDetailsPasswordService); + manager.setCompromisedPasswordChecker(this.compromisedPasswordChecker); if (!this.observationRegistry.isNoop()) { return new ObservationReactiveAuthenticationManager(this.observationRegistry, manager); } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfigurationTests.java index 2d95161fc6d..ab25569e12c 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfigurationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configuration/HttpSecurityConfigurationTests.java @@ -58,10 +58,13 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.core.password.CompromisedPasswordCheckResult; +import org.springframework.security.core.password.CompromisedPasswordChecker; +import org.springframework.security.core.password.CompromisedPasswordException; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.provisioning.UserDetailsManager; import org.springframework.security.test.web.servlet.RequestCacheResultMatcher; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -395,6 +398,41 @@ public void configureWhenCustomDslAddedFromFactoriesAndDisablingUsingWithThenNot this.mockMvc.perform(formLogin()).andExpectAll(status().isNotFound(), unauthenticated()); } + @Test + void loginWhenCompromisePasswordCheckerConfiguredAndPasswordCompromisedThenUnauthorized() throws Exception { + this.spring + .register(SecurityEnabledConfig.class, UserDetailsConfig.class, CompromisedPasswordCheckerConfig.class) + .autowire(); + this.mockMvc.perform(formLogin().password("password")) + .andExpectAll(status().isFound(), redirectedUrl("/login?error"), unauthenticated()); + } + + @Test + void loginWhenCompromisedPasswordAndRedirectIfPasswordExceptionThenRedirectedToResetPassword() throws Exception { + this.spring + .register(SecurityEnabledRedirectIfPasswordExceptionConfig.class, UserDetailsConfig.class, + CompromisedPasswordCheckerConfig.class) + .autowire(); + this.mockMvc.perform(formLogin().password("password")) + .andExpectAll(status().isFound(), redirectedUrl("/reset-password"), unauthenticated()); + } + + @Test + void loginWhenCompromisePasswordCheckerConfiguredAndPasswordNotCompromisedThenSuccess() throws Exception { + this.spring + .register(SecurityEnabledConfig.class, UserDetailsConfig.class, CompromisedPasswordCheckerConfig.class) + .autowire(); + UserDetailsManager userDetailsManager = this.spring.getContext().getBean(UserDetailsManager.class); + UserDetails notCompromisedPwUser = User.withDefaultPasswordEncoder() + .username("user2") + .password("password2") + .roles("USER") + .build(); + userDetailsManager.createUser(notCompromisedPwUser); + this.mockMvc.perform(formLogin().user("user2").password("password2")) + .andExpectAll(status().isFound(), redirectedUrl("/"), authenticated()); + } + @RestController static class NameController { @@ -455,7 +493,7 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { static class UserDetailsConfig { @Bean - UserDetailsService userDetailsService() { + InMemoryUserDetailsManager userDetailsService() { // @formatter:off UserDetails user = User.withDefaultPasswordEncoder() .username("user") @@ -732,4 +770,52 @@ public void init(HttpSecurity builder) throws Exception { } + @Configuration(proxyBeanMethods = false) + static class CompromisedPasswordCheckerConfig { + + @Bean + TestCompromisedPasswordChecker compromisedPasswordChecker() { + return new TestCompromisedPasswordChecker(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + static class SecurityEnabledRedirectIfPasswordExceptionConfig { + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // @formatter:off + return http + .authorizeHttpRequests((authorize) -> authorize + .anyRequest().authenticated() + ) + .formLogin((form) -> form + .failureHandler((request, response, exception) -> { + if (exception instanceof CompromisedPasswordException) { + response.sendRedirect("/reset-password"); + return; + } + response.sendRedirect("/login?error"); + }) + ) + .build(); + // @formatter:on + } + + } + + private static class TestCompromisedPasswordChecker implements CompromisedPasswordChecker { + + @Override + public CompromisedPasswordCheckResult check(String password) { + if ("password".equals(password)) { + return new CompromisedPasswordCheckResult(true); + } + return new CompromisedPasswordCheckResult(false); + } + + } + } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfigurationTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfigurationTests.java index 5e6d4080dd5..5ad158eef21 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfigurationTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/reactive/ServerHttpSecurityConfigurationTests.java @@ -16,16 +16,39 @@ package org.springframework.security.config.annotation.web.reactive; +import java.net.URI; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Mono; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; import org.springframework.security.config.users.ReactiveAuthenticationTestConfiguration; import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.password.CompromisedPasswordCheckResult; +import org.springframework.security.core.password.CompromisedPasswordException; +import org.springframework.security.core.password.ReactiveCompromisedPasswordChecker; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.PasswordEncodedUser; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.server.DefaultServerRedirectStrategy; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; /** * Tests for {@link ServerHttpSecurityConfiguration}. @@ -37,6 +60,16 @@ public class ServerHttpSecurityConfigurationTests { public final SpringTestContext spring = new SpringTestContext(this); + WebTestClient webClient; + + @Autowired + void setup(ApplicationContext context) { + if (!context.containsBean(WebHttpHandlerBuilder.WEB_HANDLER_BEAN_NAME)) { + return; + } + this.webClient = WebTestClient.bindToApplicationContext(context).configureClient().build(); + } + @Test public void loadConfigWhenReactiveUserDetailsServiceConfiguredThenServerHttpSecurityExists() { this.spring @@ -57,9 +90,151 @@ public void loadConfigWhenProxyingEnabledAndSubclassThenServerHttpSecurityExists assertThat(serverHttpSecurity).isNotNull(); } + @Test + void loginWhenCompromisePasswordCheckerConfiguredAndPasswordCompromisedThenUnauthorized() { + this.spring.register(FormLoginConfig.class, UserDetailsConfig.class, CompromisedPasswordCheckerConfig.class) + .autowire(); + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + // @formatter:off + this.webClient.mutateWith(csrf()) + .post() + .uri("/login") + .body(BodyInserters.fromFormData(data)) + .exchange() + .expectStatus().is3xxRedirection() + .expectHeader().location("/login?error"); + // @formatter:on + } + + @Test + void loginWhenCompromisePasswordCheckerConfiguredAndPasswordNotCompromisedThenUnauthorized() { + this.spring.register(FormLoginConfig.class, UserDetailsConfig.class, CompromisedPasswordCheckerConfig.class) + .autowire(); + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "admin"); + data.add("password", "password2"); + // @formatter:off + this.webClient.mutateWith(csrf()) + .post() + .uri("/login") + .body(BodyInserters.fromFormData(data)) + .exchange() + .expectStatus().is3xxRedirection() + .expectHeader().location("/"); + // @formatter:on + } + + @Test + void loginWhenCompromisedPasswordAndRedirectIfPasswordExceptionThenRedirectedToResetPassword() { + this.spring + .register(FormLoginRedirectToResetPasswordConfig.class, UserDetailsConfig.class, + CompromisedPasswordCheckerConfig.class) + .autowire(); + MultiValueMap data = new LinkedMultiValueMap<>(); + data.add("username", "user"); + data.add("password", "password"); + // @formatter:off + this.webClient.mutateWith(csrf()) + .post() + .uri("/login") + .body(BodyInserters.fromFormData(data)) + .exchange() + .expectStatus().is3xxRedirection() + .expectHeader().location("/reset-password"); + // @formatter:on + } + @Configuration static class SubclassConfig extends ServerHttpSecurityConfiguration { } + @Configuration(proxyBeanMethods = false) + @EnableWebFlux + @EnableWebFluxSecurity + static class FormLoginConfig { + + @Bean + SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + // @formatter:off + http + .authorizeExchange((exchange) -> exchange + .anyExchange().authenticated() + ) + .formLogin(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebFlux + @EnableWebFluxSecurity + static class FormLoginRedirectToResetPasswordConfig { + + @Bean + SecurityWebFilterChain filterChain(ServerHttpSecurity http) { + // @formatter:off + http + .authorizeExchange((exchange) -> exchange + .anyExchange().authenticated() + ) + .formLogin((form) -> form + .authenticationFailureHandler((webFilterExchange, exception) -> { + String redirectUrl = "/login?error"; + if (exception instanceof CompromisedPasswordException) { + redirectUrl = "/reset-password"; + } + return new DefaultServerRedirectStrategy().sendRedirect(webFilterExchange.getExchange(), URI.create(redirectUrl)); + }) + ); + // @formatter:on + return http.build(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class UserDetailsConfig { + + @Bean + MapReactiveUserDetailsService userDetailsService() { + // @formatter:off + UserDetails user = PasswordEncodedUser.user(); + UserDetails admin = User.withDefaultPasswordEncoder() + .username("admin") + .password("password2") + .roles("USER", "ADMIN") + .build(); + // @formatter:on + return new MapReactiveUserDetailsService(user, admin); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CompromisedPasswordCheckerConfig { + + @Bean + TestReactivePasswordChecker compromisedPasswordChecker() { + return new TestReactivePasswordChecker(); + } + + } + + static class TestReactivePasswordChecker implements ReactiveCompromisedPasswordChecker { + + @Override + public Mono check(String password) { + if ("password".equals(password)) { + return Mono.just(new CompromisedPasswordCheckResult(true)); + } + return Mono.just(new CompromisedPasswordCheckResult(false)); + } + + } + } diff --git a/core/spring-security-core.gradle b/core/spring-security-core.gradle index fd5857a20c8..9a58737d36a 100644 --- a/core/spring-security-core.gradle +++ b/core/spring-security-core.gradle @@ -18,6 +18,8 @@ dependencies { optional 'org.aspectj:aspectjrt' optional 'org.springframework:spring-jdbc' optional 'org.springframework:spring-tx' + optional 'org.springframework:spring-web' + optional 'org.springframework:spring-webflux' optional 'org.jetbrains.kotlinx:kotlinx-coroutines-reactor' testImplementation 'commons-collections:commons-collections' @@ -31,6 +33,7 @@ dependencies { testImplementation "org.springframework:spring-test" testImplementation 'org.skyscreamer:jsonassert' testImplementation 'org.springframework:spring-test' + testImplementation 'com.squareup.okhttp3:mockwebserver' testRuntimeOnly 'org.hsqldb:hsqldb' } diff --git a/core/src/main/java/org/springframework/security/authentication/AbstractUserDetailsReactiveAuthenticationManager.java b/core/src/main/java/org/springframework/security/authentication/AbstractUserDetailsReactiveAuthenticationManager.java index 0970b79aa50..de51471e0f9 100644 --- a/core/src/main/java/org/springframework/security/authentication/AbstractUserDetailsReactiveAuthenticationManager.java +++ b/core/src/main/java/org/springframework/security/authentication/AbstractUserDetailsReactiveAuthenticationManager.java @@ -27,6 +27,10 @@ import org.springframework.context.support.MessageSourceAccessor; import org.springframework.security.core.Authentication; import org.springframework.security.core.SpringSecurityMessageSource; +import org.springframework.security.core.password.CompromisedPasswordCheckResult; +import org.springframework.security.core.password.CompromisedPasswordChecker; +import org.springframework.security.core.password.CompromisedPasswordException; +import org.springframework.security.core.password.ReactiveCompromisedPasswordChecker; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsChecker; @@ -64,6 +68,8 @@ public abstract class AbstractUserDetailsReactiveAuthenticationManager private UserDetailsChecker postAuthenticationChecks = this::defaultPostAuthenticationChecks; + private ReactiveCompromisedPasswordChecker compromisedPasswordChecker; + private void defaultPreAuthenticationChecks(UserDetails user) { if (!user.isAccountNonLocked()) { this.logger.debug("User account is locked"); @@ -100,12 +106,23 @@ public Mono authenticate(Authentication authentication) { .publishOn(this.scheduler) .filter((userDetails) -> this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) .switchIfEmpty(Mono.defer(() -> Mono.error(new BadCredentialsException("Invalid Credentials")))) + .flatMap((userDetails) -> checkCompromisedPassword(presentedPassword).thenReturn(userDetails)) .flatMap((userDetails) -> upgradeEncodingIfNecessary(userDetails, presentedPassword)) .doOnNext(this.postAuthenticationChecks::check) .map(this::createUsernamePasswordAuthenticationToken); // @formatter:on } + private Mono checkCompromisedPassword(String password) { + if (this.compromisedPasswordChecker == null) { + return Mono.empty(); + } + return this.compromisedPasswordChecker.check(password) + .filter(CompromisedPasswordCheckResult::isCompromised) + .flatMap((compromised) -> Mono.error(new CompromisedPasswordException( + "The provided password is compromised, please change your password"))); + } + private Mono upgradeEncodingIfNecessary(UserDetails userDetails, String presentedPassword) { boolean upgradeEncoding = this.userDetailsPasswordService != null && this.passwordEncoder.upgradeEncoding(userDetails.getPassword()); @@ -176,6 +193,16 @@ public void setMessageSource(MessageSource messageSource) { this.messages = new MessageSourceAccessor(messageSource); } + /** + * Sets the {@link ReactiveCompromisedPasswordChecker} to be used before creating a + * successful authentication. Defaults to {@code null}. + * @param compromisedPasswordChecker the {@link CompromisedPasswordChecker} to use + * @since 6.3 + */ + public void setCompromisedPasswordChecker(ReactiveCompromisedPasswordChecker compromisedPasswordChecker) { + this.compromisedPasswordChecker = compromisedPasswordChecker; + } + /** * Allows subclasses to retrieve the UserDetails from an * implementation-specific location. diff --git a/core/src/main/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.java b/core/src/main/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.java index 24fe918164b..0a35804a3c3 100644 --- a/core/src/main/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.java +++ b/core/src/main/java/org/springframework/security/authentication/dao/DaoAuthenticationProvider.java @@ -22,6 +22,8 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.password.CompromisedPasswordChecker; +import org.springframework.security.core.password.CompromisedPasswordException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsPasswordService; import org.springframework.security.core.userdetails.UserDetailsService; @@ -60,6 +62,8 @@ public class DaoAuthenticationProvider extends AbstractUserDetailsAuthentication private UserDetailsPasswordService userDetailsPasswordService; + private CompromisedPasswordChecker compromisedPasswordChecker; + public DaoAuthenticationProvider() { this(PasswordEncoderFactories.createDelegatingPasswordEncoder()); } @@ -122,10 +126,15 @@ protected final UserDetails retrieveUser(String username, UsernamePasswordAuthen @Override protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) { + String presentedPassword = authentication.getCredentials().toString(); + boolean isPasswordCompromised = this.compromisedPasswordChecker != null + && this.compromisedPasswordChecker.check(presentedPassword).isCompromised(); + if (isPasswordCompromised) { + throw new CompromisedPasswordException("The provided password is compromised, please change your password"); + } boolean upgradeEncoding = this.userDetailsPasswordService != null && this.passwordEncoder.upgradeEncoding(user.getPassword()); if (upgradeEncoding) { - String presentedPassword = authentication.getCredentials().toString(); String newPassword = this.passwordEncoder.encode(presentedPassword); user = this.userDetailsPasswordService.updatePassword(user, newPassword); } @@ -174,4 +183,14 @@ public void setUserDetailsPasswordService(UserDetailsPasswordService userDetails this.userDetailsPasswordService = userDetailsPasswordService; } + /** + * Sets the {@link CompromisedPasswordChecker} to be used before creating a successful + * authentication. Defaults to {@code null}. + * @param compromisedPasswordChecker the {@link CompromisedPasswordChecker} to use + * @since 6.3 + */ + public void setCompromisedPasswordChecker(CompromisedPasswordChecker compromisedPasswordChecker) { + this.compromisedPasswordChecker = compromisedPasswordChecker; + } + } diff --git a/core/src/main/java/org/springframework/security/core/password/CompromisedPasswordCheckResult.java b/core/src/main/java/org/springframework/security/core/password/CompromisedPasswordCheckResult.java new file mode 100644 index 00000000000..e5742c51b25 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/password/CompromisedPasswordCheckResult.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2024 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.core.password; + +public class CompromisedPasswordCheckResult { + + private final boolean compromised; + + public CompromisedPasswordCheckResult(boolean compromised) { + this.compromised = compromised; + } + + public boolean isCompromised() { + return this.compromised; + } + +} diff --git a/core/src/main/java/org/springframework/security/core/password/CompromisedPasswordChecker.java b/core/src/main/java/org/springframework/security/core/password/CompromisedPasswordChecker.java new file mode 100644 index 00000000000..edb8ce875c2 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/password/CompromisedPasswordChecker.java @@ -0,0 +1,37 @@ +/* + * Copyright 2002-2024 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.core.password; + +import org.springframework.lang.NonNull; + +/** + * An API for checking if a password has been compromised. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public interface CompromisedPasswordChecker { + + /** + * Check whether the password is compromised + * @param password the password to check + * @return a non-null {@link CompromisedPasswordCheckResult} + */ + @NonNull + CompromisedPasswordCheckResult check(String password); + +} diff --git a/core/src/main/java/org/springframework/security/core/password/CompromisedPasswordException.java b/core/src/main/java/org/springframework/security/core/password/CompromisedPasswordException.java new file mode 100644 index 00000000000..4c73cfa0e58 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/password/CompromisedPasswordException.java @@ -0,0 +1,38 @@ +/* + * Copyright 2002-2024 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.core.password; + +import org.springframework.security.core.AuthenticationException; + +/** + * Indicates that the provided password is compromised + * + * @author Marcus da Coregio + * @since 6.3 + * @see HaveIBeenPwnedRestApiPasswordChecker + */ +public class CompromisedPasswordException extends AuthenticationException { + + public CompromisedPasswordException(String message) { + super(message); + } + + public CompromisedPasswordException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/core/src/main/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiPasswordChecker.java b/core/src/main/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiPasswordChecker.java new file mode 100644 index 00000000000..b6500d43c81 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiPasswordChecker.java @@ -0,0 +1,115 @@ +/* + * Copyright 2002-2024 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.core.password; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jetbrains.annotations.NotNull; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +/** + * Checks if the provided password was leaked by relying on + * Have I Been Pwned REST + * API. This implementation uses the Search by Range in order to protect the value of + * the source password being searched for. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public final class HaveIBeenPwnedRestApiPasswordChecker implements CompromisedPasswordChecker { + + private static final String API_URL = "https://api.pwnedpasswords.com/range/"; + + private static final int PREFIX_LENGTH = 5; + + private final Log logger = LogFactory.getLog(getClass()); + + private final MessageDigest sha1Digest; + + private RestClient restClient = RestClient.builder().baseUrl(API_URL).build(); + + public HaveIBeenPwnedRestApiPasswordChecker() { + this.sha1Digest = getSha1Digest(); + } + + @Override + @NotNull + public CompromisedPasswordCheckResult check(String password) { + byte[] hash = this.sha1Digest.digest(password.getBytes(StandardCharsets.UTF_8)); + String encoded = new String(Hex.encode(hash)).toUpperCase(); + String prefix = encoded.substring(0, PREFIX_LENGTH); + String suffix = encoded.substring(PREFIX_LENGTH); + + List passwords = getLeakedPasswordsForPrefix(prefix); + boolean isLeaked = findLeakedPassword(passwords, suffix); + return new CompromisedPasswordCheckResult(isLeaked); + } + + /** + * Sets the {@link RestClient} to use when making requests to Have I Been Pwned REST + * API. By default, a {@link RestClient} with a base URL of {@link #API_URL} is used. + * @param restClient the {@link RestClient} to use + */ + public void setRestClient(RestClient restClient) { + Assert.notNull(restClient, "restClient cannot be null"); + this.restClient = restClient; + } + + private boolean findLeakedPassword(List passwords, String suffix) { + for (String pw : passwords) { + if (pw.startsWith(suffix)) { + return true; + } + } + return false; + } + + private List getLeakedPasswordsForPrefix(String prefix) { + try { + String response = this.restClient.get().uri(prefix).retrieve().body(String.class); + if (!StringUtils.hasText(response)) { + return Collections.emptyList(); + } + return response.lines().toList(); + } + catch (RestClientException ex) { + this.logger.error("Request for leaked passwords failed", ex); + return Collections.emptyList(); + } + } + + private static MessageDigest getSha1Digest() { + try { + return MessageDigest.getInstance("SHA-1"); + } + catch (NoSuchAlgorithmException ex) { + throw new RuntimeException(ex.getMessage()); + } + } + +} diff --git a/core/src/main/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiReactivePasswordChecker.java b/core/src/main/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiReactivePasswordChecker.java new file mode 100644 index 00000000000..f8bc282c225 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiReactivePasswordChecker.java @@ -0,0 +1,109 @@ +/* + * Copyright 2002-2024 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.core.password; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import org.springframework.security.crypto.codec.Hex; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +/** + * Checks if the provided password was leaked by relying on + * Have I Been Pwned REST + * API. This implementation uses the Search by Range in order to protect the value of + * the source password being searched for. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public class HaveIBeenPwnedRestApiReactivePasswordChecker implements ReactiveCompromisedPasswordChecker { + + private static final String API_URL = "https://api.pwnedpasswords.com/range/"; + + private static final int PREFIX_LENGTH = 5; + + private final Log logger = LogFactory.getLog(getClass()); + + private WebClient webClient = WebClient.builder().baseUrl(API_URL).build(); + + private final MessageDigest sha1Digest; + + public HaveIBeenPwnedRestApiReactivePasswordChecker() { + this.sha1Digest = getSha1Digest(); + } + + @Override + public Mono check(String password) { + return getHash(password).map((hash) -> new String(Hex.encode(hash))) + .flatMap(this::findLeakedPassword) + .map(CompromisedPasswordCheckResult::new); + } + + private Mono findLeakedPassword(String encodedPassword) { + String prefix = encodedPassword.substring(0, PREFIX_LENGTH).toUpperCase(); + String suffix = encodedPassword.substring(PREFIX_LENGTH).toUpperCase(); + return getLeakedPasswordsForPrefix(prefix).any((leakedPw) -> leakedPw.startsWith(suffix)); + } + + private Flux getLeakedPasswordsForPrefix(String prefix) { + return this.webClient.get().uri(prefix).retrieve().bodyToMono(String.class).flatMapMany((body) -> { + if (StringUtils.hasText(body)) { + return Flux.fromStream(body.lines()); + } + return Flux.empty(); + }) + .doOnError((ex) -> this.logger.error("Request for leaked passwords failed", ex)) + .onErrorResume(WebClientResponseException.class, (ex) -> Flux.empty()); + } + + /** + * Sets the {@link WebClient} to use when making requests to Have I Been Pwned REST + * API. By default, a {@link WebClient} with a base URL of {@link #API_URL} is used. + * @param webClient the {@link WebClient} to use + */ + public void setWebClient(WebClient webClient) { + Assert.notNull(webClient, "webClient cannot be null"); + this.webClient = webClient; + } + + private Mono getHash(String password) { + return Mono.fromSupplier(() -> this.sha1Digest.digest(password.getBytes(StandardCharsets.UTF_8))) + .subscribeOn(Schedulers.boundedElastic()) + .publishOn(Schedulers.parallel()); + } + + private static MessageDigest getSha1Digest() { + try { + return MessageDigest.getInstance("SHA-1"); + } + catch (NoSuchAlgorithmException ex) { + throw new RuntimeException(ex.getMessage()); + } + } + +} diff --git a/core/src/main/java/org/springframework/security/core/password/ReactiveCompromisedPasswordChecker.java b/core/src/main/java/org/springframework/security/core/password/ReactiveCompromisedPasswordChecker.java new file mode 100644 index 00000000000..2168af0eef6 --- /dev/null +++ b/core/src/main/java/org/springframework/security/core/password/ReactiveCompromisedPasswordChecker.java @@ -0,0 +1,36 @@ +/* + * Copyright 2002-2024 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.core.password; + +import reactor.core.publisher.Mono; + +/** + * A Reactive API for checking if a password has been compromised. + * + * @author Marcus da Coregio + * @since 6.3 + */ +public interface ReactiveCompromisedPasswordChecker { + + /** + * Check whether the password is compromised + * @param password the password to check + * @return a {@link Mono} containing the {@link CompromisedPasswordCheckResult} + */ + Mono check(String password); + +} diff --git a/core/src/test/java/org/springframework/security/authentication/UserDetailsRepositoryReactiveAuthenticationManagerTests.java b/core/src/test/java/org/springframework/security/authentication/UserDetailsRepositoryReactiveAuthenticationManagerTests.java index d2680e3624f..e33606471c9 100644 --- a/core/src/test/java/org/springframework/security/authentication/UserDetailsRepositoryReactiveAuthenticationManagerTests.java +++ b/core/src/test/java/org/springframework/security/authentication/UserDetailsRepositoryReactiveAuthenticationManagerTests.java @@ -24,9 +24,13 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Scheduler; import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; import org.springframework.context.MessageSource; import org.springframework.security.core.Authentication; +import org.springframework.security.core.password.CompromisedPasswordCheckResult; +import org.springframework.security.core.password.CompromisedPasswordException; +import org.springframework.security.core.password.ReactiveCompromisedPasswordChecker; import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; import org.springframework.security.core.userdetails.User; @@ -34,6 +38,7 @@ import org.springframework.security.core.userdetails.UserDetailsChecker; import org.springframework.security.crypto.password.PasswordEncoder; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.mockito.ArgumentMatchers.any; @@ -219,6 +224,41 @@ public void authenticateWhenAccountDisabledThenException() { assertThatExceptionOfType(DisabledException.class).isThrownBy(() -> this.manager.authenticate(token).block()); } + @Test + public void authenticateWhenPasswordCompromisedThenException() { + // @formatter:off + UserDetails user = User.withUsername("user") + .password("{noop}password") + .roles("USER") + .build(); + // @formatter:on + given(this.userDetailsService.findByUsername(any())).willReturn(Mono.just(user)); + this.manager.setCompromisedPasswordChecker(new TestReactivePasswordChecker()); + UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(user, + "password"); + StepVerifier.create(this.manager.authenticate(token)) + .expectErrorSatisfies((ex) -> assertThat(ex).isInstanceOf(CompromisedPasswordException.class) + .withFailMessage("The provided password is compromised, please change your password")) + .verify(); + } + + @Test + public void authenticateWhenPasswordNotCompromisedThenSuccess() { + // @formatter:off + UserDetails user = User.withUsername("user") + .password("{noop}notcompromised") + .roles("USER") + .build(); + // @formatter:on + given(this.userDetailsService.findByUsername(any())).willReturn(Mono.just(user)); + this.manager.setCompromisedPasswordChecker(new TestReactivePasswordChecker()); + UsernamePasswordAuthenticationToken token = UsernamePasswordAuthenticationToken.unauthenticated(user, + "notcompromised"); + StepVerifier.create(this.manager.authenticate(token)) + .assertNext((authentication) -> assertThat(authentication.getPrincipal()).isEqualTo(user)) + .verifyComplete(); + } + @Test public void setMessageSourceWhenNullThenThrowsException() { assertThatIllegalArgumentException().isThrownBy(() -> this.manager.setMessageSource(null)); @@ -233,4 +273,16 @@ public void setMessageSourceWhenNotNullThenCanGet() { verify(source).getMessage(eq(code), any(), any()); } + static class TestReactivePasswordChecker implements ReactiveCompromisedPasswordChecker { + + @Override + public Mono check(String password) { + if ("password".equals(password)) { + return Mono.just(new CompromisedPasswordCheckResult(true)); + } + return Mono.just(new CompromisedPasswordCheckResult(false)); + } + + } + } diff --git a/core/src/test/java/org/springframework/security/authentication/dao/DaoAuthenticationProviderTests.java b/core/src/test/java/org/springframework/security/authentication/dao/DaoAuthenticationProviderTests.java index 08a1c4dde36..c67dd1a4e7a 100644 --- a/core/src/test/java/org/springframework/security/authentication/dao/DaoAuthenticationProviderTests.java +++ b/core/src/test/java/org/springframework/security/authentication/dao/DaoAuthenticationProviderTests.java @@ -36,6 +36,9 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.password.CompromisedPasswordCheckResult; +import org.springframework.security.core.password.CompromisedPasswordChecker; +import org.springframework.security.core.password.CompromisedPasswordException; import org.springframework.security.core.userdetails.PasswordEncodedUser; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; @@ -48,6 +51,7 @@ import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.NoOpPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -504,6 +508,42 @@ public void testUserNotFoundNullCredentials() { verify(encoder, times(0)).matches(anyString(), anyString()); } + @Test + void authenticateWhenPasswordLeakedThenException() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); + UserDetails user = User.withDefaultPasswordEncoder() + .username("user") + .password("password") + .roles("USER") + .build(); + provider.setUserDetailsService(withUsers(user)); + provider.setCompromisedPasswordChecker(new TestCompromisedPasswordChecker()); + assertThatExceptionOfType(CompromisedPasswordException.class).isThrownBy( + () -> provider.authenticate(UsernamePasswordAuthenticationToken.unauthenticated("user", "password"))) + .withMessage("The provided password is compromised, please change your password"); + } + + @Test + void authenticateWhenPasswordNotLeakedThenNoException() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); + UserDetails user = User.withDefaultPasswordEncoder() + .username("user") + .password("strongpassword") + .roles("USER") + .build(); + provider.setUserDetailsService(withUsers(user)); + provider.setCompromisedPasswordChecker(new TestCompromisedPasswordChecker()); + Authentication authentication = provider + .authenticate(UsernamePasswordAuthenticationToken.unauthenticated("user", "strongpassword")); + assertThat(authentication).isNotNull(); + } + + private UserDetailsService withUsers(UserDetails... users) { + return new InMemoryUserDetailsManager(users); + } + private DaoAuthenticationProvider createProvider() { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setPasswordEncoder(NoOpPasswordEncoder.getInstance()); @@ -594,4 +634,16 @@ public UserDetails loadUserByUsername(String username) { } + private static class TestCompromisedPasswordChecker implements CompromisedPasswordChecker { + + @Override + public CompromisedPasswordCheckResult check(String password) { + if ("password".equals(password)) { + return new CompromisedPasswordCheckResult(true); + } + return new CompromisedPasswordCheckResult(false); + } + + } + } diff --git a/core/src/test/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiPasswordCheckerTests.java b/core/src/test/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiPasswordCheckerTests.java new file mode 100644 index 00000000000..1948067a9fa --- /dev/null +++ b/core/src/test/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiPasswordCheckerTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2002-2024 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.core.password; + +import java.io.IOException; + +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +class HaveIBeenPwnedRestApiPasswordCheckerTests { + + private final String pwnedPasswords = """ + 2CDE4CDCFA5AD7D223BD1800338FBEAA04E:1 + 2CF90F92EE1941547BB13DFC7D0E0AFE504:1 + 2D10A6654B6D75908AE572559542245CBFA:6 + 2D4FCF535FE92B8B950424E16E65EFBFED3:1 + 2D6980B9098804E7A83DC5831BFBAF3927F:1 + 2D8D1B3FAACCA6A3C6A91617B2FA32E2F57:1 + 2DC183F740EE76F27B78EB39C8AD972A757:300185 + 2DE4C0087846D223DBBCCF071614590F300:3 + 2DEA2B1D02714099E4B7A874B4364D518F6:1 + 2E750AE8C4756A20CE040BF3DDF094FA7EC:1 + 2E90B7B3C5C1181D16C48E273D9AC7F3C16:5 + 2E991A9162F24F01826D8AF73CA20F2B430:1 + 2EAE5EA981BFAF29A8869A40BDDADF3879B:2 + 2F1AC09E3846595E436BBDDDD2189358AF9:1 + """; + + private final MockWebServer server = new MockWebServer(); + + private final HaveIBeenPwnedRestApiPasswordChecker passwordChecker = new HaveIBeenPwnedRestApiPasswordChecker(); + + @BeforeEach + void setup() throws IOException { + this.server.start(); + HttpUrl url = this.server.url("/range/"); + this.passwordChecker.setRestClient(RestClient.builder().baseUrl(url.toString()).build()); + } + + @AfterEach + void tearDown() throws IOException { + this.server.shutdown(); + } + + @Test + void checkWhenPasswordIsLeakedThenIsCompromised() throws InterruptedException { + this.server.enqueue(new MockResponse().setBody(this.pwnedPasswords).setResponseCode(200)); + CompromisedPasswordCheckResult check = this.passwordChecker.check("P@ssw0rd"); + assertThat(check.isCompromised()).isTrue(); + assertThat(this.server.takeRequest().getPath()).isEqualTo("/range/21BD1"); + } + + @Test + void checkWhenPasswordNotLeakedThenNotCompromised() { + this.server.enqueue(new MockResponse().setBody(this.pwnedPasswords).setResponseCode(200)); + CompromisedPasswordCheckResult check = this.passwordChecker.check("My1nCr3d!bL3P@SS0W0RD"); + assertThat(check.isCompromised()).isFalse(); + } + + @Test + void checkWhenNoPasswordsReturnedFromApiCallThenNotCompromised() { + this.server.enqueue(new MockResponse().setResponseCode(200)); + CompromisedPasswordCheckResult check = this.passwordChecker.check("123456"); + assertThat(check.isCompromised()).isFalse(); + } + + @Test + void checkWhenResponseStatusNot200ThenNotCompromised() { + this.server.enqueue(new MockResponse().setResponseCode(503)); + assertThatNoException().isThrownBy(() -> this.passwordChecker.check("123456")); + this.server.enqueue(new MockResponse().setResponseCode(404)); + assertThatNoException().isThrownBy(() -> this.passwordChecker.check("123456")); + } + +} diff --git a/core/src/test/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiReactivePasswordCheckerTests.java b/core/src/test/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiReactivePasswordCheckerTests.java new file mode 100644 index 00000000000..95bb475397f --- /dev/null +++ b/core/src/test/java/org/springframework/security/core/password/HaveIBeenPwnedRestApiReactivePasswordCheckerTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2002-2024 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.core.password; + +import java.io.IOException; + +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import org.springframework.web.reactive.function.client.WebClient; + +import static org.assertj.core.api.Assertions.assertThat; + +class HaveIBeenPwnedRestApiReactivePasswordCheckerTests { + + private final String pwnedPasswords = """ + 2CDE4CDCFA5AD7D223BD1800338FBEAA04E:1 + 2CF90F92EE1941547BB13DFC7D0E0AFE504:1 + 2D10A6654B6D75908AE572559542245CBFA:6 + 2D4FCF535FE92B8B950424E16E65EFBFED3:1 + 2D6980B9098804E7A83DC5831BFBAF3927F:1 + 2D8D1B3FAACCA6A3C6A91617B2FA32E2F57:1 + 2DC183F740EE76F27B78EB39C8AD972A757:300185 + 2DE4C0087846D223DBBCCF071614590F300:3 + 2DEA2B1D02714099E4B7A874B4364D518F6:1 + 2E750AE8C4756A20CE040BF3DDF094FA7EC:1 + 2E90B7B3C5C1181D16C48E273D9AC7F3C16:5 + 2E991A9162F24F01826D8AF73CA20F2B430:1 + 2EAE5EA981BFAF29A8869A40BDDADF3879B:2 + 2F1AC09E3846595E436BBDDDD2189358AF9:1 + """; + + private final MockWebServer server = new MockWebServer(); + + private final HaveIBeenPwnedRestApiReactivePasswordChecker passwordChecker = new HaveIBeenPwnedRestApiReactivePasswordChecker(); + + @BeforeEach + void setup() throws IOException { + this.server.start(); + HttpUrl url = this.server.url("/range/"); + this.passwordChecker.setWebClient(WebClient.builder().baseUrl(url.toString()).build()); + } + + @AfterEach + void tearDown() throws IOException { + this.server.shutdown(); + } + + @Test + void checkWhenPasswordIsLeakedThenIsCompromised() throws InterruptedException { + this.server.enqueue(new MockResponse().setBody(this.pwnedPasswords).setResponseCode(200)); + StepVerifier.create(this.passwordChecker.check("P@ssw0rd")) + .assertNext((check) -> assertThat(check.isCompromised()).isTrue()) + .verifyComplete(); + assertThat(this.server.takeRequest().getPath()).isEqualTo("/range/21BD1"); + } + + @Test + void checkWhenPasswordNotLeakedThenNotCompromised() { + this.server.enqueue(new MockResponse().setBody(this.pwnedPasswords).setResponseCode(200)); + StepVerifier.create(this.passwordChecker.check("My1nCr3d!bL3P@SS0W0RD")) + .assertNext((check) -> assertThat(check.isCompromised()).isFalse()) + .verifyComplete(); + } + + @Test + void checkWhenNoPasswordsReturnedFromApiCallThenNotCompromised() { + this.server.enqueue(new MockResponse().setResponseCode(200)); + StepVerifier.create(this.passwordChecker.check("P@ssw0rd")) + .assertNext((check) -> assertThat(check.isCompromised()).isFalse()) + .verifyComplete(); + } + + @Test + void checkWhenResponseStatusNot200ThenNotCompromised() { + this.server.enqueue(new MockResponse().setResponseCode(503)); + StepVerifier.create(this.passwordChecker.check("123456")) + .assertNext((check) -> assertThat(check.isCompromised()).isFalse()) + .verifyComplete(); + this.server.enqueue(new MockResponse().setResponseCode(404)); + StepVerifier.create(this.passwordChecker.check("123456")) + .assertNext((check) -> assertThat(check.isCompromised()).isFalse()) + .verifyComplete(); + } + +} diff --git a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc index 08aa0778ede..c10967b7c44 100644 --- a/docs/modules/ROOT/pages/features/authentication/password-storage.adoc +++ b/docs/modules/ROOT/pages/features/authentication/password-storage.adoc @@ -591,3 +591,99 @@ http { ====== With the above configuration, when a password manager navigates to `/.well-known/change-password`, then Spring Security will redirect to `/update-password`. + +[[authentication-compromised-password-check]] +== Compromised Password Checking + +There are some scenarios where you need to check whether a password has been compromised, for example, if you are creating an application that deals with sensitive data, it is often needed that you perform some check on user's passwords in order to assert its reliability. +One of these checks can be if the password has been compromised, usually because it has been found in a https://wikipedia.org/wiki/Data_breach[data breach]. + +To facilitate that, Spring Security provides integration with the https://haveibeenpwned.com/API/v3#PwnedPasswords[Have I Been Pwned API] via the {security-api-url}org/springframework/security/core/password/HaveIBeenPwnedRestApiPasswordChecker.html[`HaveIBeenPwnedRestApiPasswordChecker` implementation] of the {security-api-url}org/springframework/security/core/password/CompromisedPasswordChecker.html[`CompromisedPasswordChecker` interface]. + +You can either use the `CompromisedPasswordChecker` API by yourself or, if you are using xref:servlet/authentication/passwords/dao-authentication-provider.adoc[the `DaoAuthenticationProvider]` via xref:servlet/authentication/passwords/index.adoc[Spring Security authentication mechanisms], you can provide a `CompromisedPasswordChecker` bean, and it will be automatically picked up by Spring Security configuration. + +.Using CompromisedPasswordChecker as a bean +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> authorize + .anyRequest().authenticated() + ) + .formLogin(withDefaults()) + .httpBasic(withDefaults()); + return http.build(); +} + +@Bean +public CompromisedPasswordChecker compromisedPasswordChecker() { + return new HaveIBeenPwnedRestApiPasswordChecker(); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +open fun filterChain(http:HttpSecurity): SecurityFilterChain { + http { + authorizeHttpRequests { + authorize(anyRequest, authenticated) + } + formLogin {} + httpBasic {} + } + return http.build() +} + +@Bean +open fun compromisedPasswordChecker(): CompromisedPasswordChecker { + return HaveIBeenPwnedRestApiPasswordChecker() +} +---- +====== + +By doing that, when you try to authenticate via HTTP Basic or Form Login using a weak password, let's say `123456`, you will receive a 401 response status code. +However, just a 401 is not so useful in that case, it will cause some confusion because the user provided the right password and still was not allowed to log in. +In such cases, you can handle the `CompromisedPasswordException` to perform your desired logic, like redirecting the user-agent to `/reset-password`, for example: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@ControllerAdvice +public class MyControllerAdvice { + + @ExceptionHandler(CompromisedPasswordException.class) + public String handleCompromisedPasswordException(CompromisedPasswordException ex, RedirectAttributes attributes) { + attributes.addFlashAttribute("error", ex.message); + return "redirect:/reset-password"; + } + +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@ControllerAdvice +class MyControllerAdvice { + + @ExceptionHandler(CompromisedPasswordException::class) + fun handleCompromisedPasswordException(ex: CompromisedPasswordException, attributes: RedirectAttributes): RedirectView { + attributes.addFlashAttribute("error", ex.message) + return RedirectView("/reset-password") + } + +} +---- +====== diff --git a/docs/modules/ROOT/pages/whats-new.adoc b/docs/modules/ROOT/pages/whats-new.adoc index f915ff66bc5..d71b1c501a6 100644 --- a/docs/modules/ROOT/pages/whats-new.adoc +++ b/docs/modules/ROOT/pages/whats-new.adoc @@ -8,6 +8,10 @@ Below are the highlights of the release. - https://spring.io/blog/2024/01/19/spring-security-6-3-adds-passive-jdk-serialization-deserialization-for[blog post] - Added Passive JDK Serialization/Deserialization for Seamless Upgrades +== Authentication + +- https://github.com/spring-projects/spring-security/issues/7395[gh-7395] - xref:features/authentication/password-storage.adoc#authentication-compromised-password-check[docs] - Add Compromised Password Checker + == Authorization - https://github.com/spring-projects/spring-security/issues/14596[gh-14596] - xref:servlet/authorization/method-security.adoc[docs] - Add Programmatic Proxy Support for Method Security