Add Compromised Password Checker

Closes gh-7395
This commit is contained in:
Marcus Hert Da Coregio 2024-03-05 14:45:33 -03:00
parent 89175dfed0
commit 7d66525e23
19 changed files with 1100 additions and 3 deletions

View File

@ -21,6 +21,7 @@ import org.springframework.core.Ordered;
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 @@ class InitializeUserDetailsBeanManagerConfigurer extends GlobalAuthenticationCon
}
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 @@ class InitializeUserDetailsBeanManagerConfigurer extends GlobalAuthenticationCon
if (passwordManager != null) {
provider.setUserDetailsPasswordService(passwordManager);
}
if (passwordChecker != null) {
provider.setCompromisedPasswordChecker(passwordChecker);
}
provider.afterPropertiesSet();
auth.authenticationProvider(provider);
}

View File

@ -33,6 +33,7 @@ import org.springframework.security.authentication.ObservationReactiveAuthentica
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 @@ class ServerHttpSecurityConfiguration {
this.observationRegistry = observationRegistry;
}
@Autowired(required = false)
void setCompromisedPasswordChecker(ReactiveCompromisedPasswordChecker compromisedPasswordChecker) {
this.compromisedPasswordChecker = compromisedPasswordChecker;
}
@Bean
static WebFluxConfigurer authenticationPrincipalArgumentResolverConfigurer(
ObjectProvider<AuthenticationPrincipalArgumentResolver> authenticationPrincipalArgumentResolver) {
@ -153,6 +161,7 @@ class ServerHttpSecurityConfiguration {
manager.setPasswordEncoder(this.passwordEncoder);
}
manager.setUserDetailsPasswordService(this.userDetailsPasswordService);
manager.setCompromisedPasswordChecker(this.compromisedPasswordChecker);
if (!this.observationRegistry.isNoop()) {
return new ObservationReactiveAuthenticationManager(this.observationRegistry, manager);
}

View File

@ -58,10 +58,13 @@ import org.springframework.security.config.test.SpringTestContextExtension;
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 class HttpSecurityConfigurationTests {
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 @@ public class HttpSecurityConfigurationTests {
static class UserDetailsConfig {
@Bean
UserDetailsService userDetailsService() {
InMemoryUserDetailsManager userDetailsService() {
// @formatter:off
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
@ -732,4 +770,52 @@ public class HttpSecurityConfigurationTests {
}
@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);
}
}
}

View File

@ -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 class ServerHttpSecurityConfigurationTests {
assertThat(serverHttpSecurity).isNotNull();
}
@Test
void loginWhenCompromisePasswordCheckerConfiguredAndPasswordCompromisedThenUnauthorized() {
this.spring.register(FormLoginConfig.class, UserDetailsConfig.class, CompromisedPasswordCheckerConfig.class)
.autowire();
MultiValueMap<String, String> 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<String, String> 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<String, String> 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<CompromisedPasswordCheckResult> check(String password) {
if ("password".equals(password)) {
return Mono.just(new CompromisedPasswordCheckResult(true));
}
return Mono.just(new CompromisedPasswordCheckResult(false));
}
}
}

View File

@ -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'
}

View File

@ -27,6 +27,10 @@ import org.springframework.context.MessageSourceAware;
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 abstract class AbstractUserDetailsReactiveAuthenticationManager
.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<Void> 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<UserDetails> upgradeEncodingIfNecessary(UserDetails userDetails, String presentedPassword) {
boolean upgradeEncoding = this.userDetailsPasswordService != null
&& this.passwordEncoder.upgradeEncoding(userDetails.getPassword());
@ -176,6 +193,16 @@ public abstract class AbstractUserDetailsReactiveAuthenticationManager
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 <code>UserDetails</code> from an
* implementation-specific location.

View File

@ -22,6 +22,8 @@ import org.springframework.security.authentication.InternalAuthenticationService
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 @@ public class DaoAuthenticationProvider extends AbstractUserDetailsAuthentication
@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 class DaoAuthenticationProvider extends AbstractUserDetailsAuthentication
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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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
* <a href="https://www.haveibeenpwned.com/API/v3#PwnedPasswords">Have I Been Pwned REST
* API</a>. 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<String> 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<String> passwords, String suffix) {
for (String pw : passwords) {
if (pw.startsWith(suffix)) {
return true;
}
}
return false;
}
private List<String> 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());
}
}
}

View File

@ -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
* <a href="https://www.haveibeenpwned.com/API/v3#PwnedPasswords">Have I Been Pwned REST
* API</a>. 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<CompromisedPasswordCheckResult> check(String password) {
return getHash(password).map((hash) -> new String(Hex.encode(hash)))
.flatMap(this::findLeakedPassword)
.map(CompromisedPasswordCheckResult::new);
}
private Mono<Boolean> 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<String> 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<byte[]> 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());
}
}
}

View File

@ -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<CompromisedPasswordCheckResult> check(String password);
}

View File

@ -24,9 +24,13 @@ import org.mockito.junit.jupiter.MockitoExtension;
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.UserDetails;
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 class UserDetailsRepositoryReactiveAuthenticationManagerTests {
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 class UserDetailsRepositoryReactiveAuthenticationManagerTests {
verify(source).getMessage(eq(code), any(), any());
}
static class TestReactivePasswordChecker implements ReactiveCompromisedPasswordChecker {
@Override
public Mono<CompromisedPasswordCheckResult> check(String password) {
if ("password".equals(password)) {
return Mono.just(new CompromisedPasswordCheckResult(true));
}
return Mono.just(new CompromisedPasswordCheckResult(false));
}
}
}

View File

@ -36,6 +36,9 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
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.bcrypt.BCryptPasswordEncoder;
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 class DaoAuthenticationProviderTests {
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 class DaoAuthenticationProviderTests {
}
private static class TestCompromisedPasswordChecker implements CompromisedPasswordChecker {
@Override
public CompromisedPasswordCheckResult check(String password) {
if ("password".equals(password)) {
return new CompromisedPasswordCheckResult(true);
}
return new CompromisedPasswordCheckResult(false);
}
}
}

View File

@ -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"));
}
}

View File

@ -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();
}
}

View File

@ -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")
}
}
----
======

View File

@ -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