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

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