parent
89175dfed0
commit
7d66525e23
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
----
|
||||
======
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue