diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java index 0727ba33bdf..1f8657ee809 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/builders/HttpSecurity.java @@ -3321,7 +3321,9 @@ public HttpSecurity httpBasic(Customizer> http */ public HttpSecurity passwordManagement( Customizer> passwordManagementCustomizer) throws Exception { - passwordManagementCustomizer.customize(getOrApply(new PasswordManagementConfigurer<>())); + PasswordManagementConfigurer passwordManagement = new PasswordManagementConfigurer<>(); + passwordManagement.setApplicationContext(getContext()); + passwordManagementCustomizer.customize(getOrApply(passwordManagement)); return HttpSecurity.this; } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebMvcSecurityConfiguration.java b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebMvcSecurityConfiguration.java index 73a56456d85..6368b4b7ec8 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebMvcSecurityConfiguration.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configuration/WebMvcSecurityConfiguration.java @@ -46,6 +46,9 @@ import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.HandlerMappingIntrospectorRequestTransformer; +import org.springframework.security.web.authentication.password.ChangePasswordAdviceMethodArgumentResolver; +import org.springframework.security.web.authentication.password.ChangePasswordAdviceRepository; +import org.springframework.security.web.authentication.password.HttpSessionChangePasswordAdviceRepository; import org.springframework.security.web.context.AbstractSecurityWebApplicationInitializer; import org.springframework.security.web.debug.DebugFilter; import org.springframework.security.web.firewall.HttpFirewall; @@ -87,6 +90,8 @@ class WebMvcSecurityConfiguration implements WebMvcConfigurer, ApplicationContex private AnnotationTemplateExpressionDefaults templateDefaults; + private ChangePasswordAdviceRepository changePasswordAdviceRepository = new HttpSessionChangePasswordAdviceRepository(); + @Override @SuppressWarnings("deprecation") public void addArgumentResolvers(List argumentResolvers) { @@ -103,6 +108,9 @@ public void addArgumentResolvers(List argumentRes currentSecurityContextArgumentResolver.setTemplateDefaults(this.templateDefaults); argumentResolvers.add(currentSecurityContextArgumentResolver); argumentResolvers.add(new CsrfTokenArgumentResolver()); + ChangePasswordAdviceMethodArgumentResolver resolver = new ChangePasswordAdviceMethodArgumentResolver(); + resolver.setChangePasswordAdviceRepository(this.changePasswordAdviceRepository); + argumentResolvers.add(resolver); } @Bean @@ -119,6 +127,9 @@ public void setApplicationContext(ApplicationContext applicationContext) throws if (applicationContext.getBeanNamesForType(AnnotationTemplateExpressionDefaults.class).length == 1) { this.templateDefaults = applicationContext.getBean(AnnotationTemplateExpressionDefaults.class); } + if (applicationContext.getBeanNamesForType(ChangePasswordAdviceRepository.class).length == 1) { + this.changePasswordAdviceRepository = applicationContext.getBean(ChangePasswordAdviceRepository.class); + } } @Bean diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java index 1dfbed3036c..39c39b7d233 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/FormLoginConfigurer.java @@ -250,7 +250,7 @@ private String getUsernameParameter() { * Gets the HTTP parameter that is used to submit the password. * @return the HTTP parameter that is used to submit the password */ - private String getPasswordParameter() { + String getPasswordParameter() { return getAuthenticationFilter().getPasswordParameter(); } diff --git a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java index 7d5794b8261..365b1dc6db3 100644 --- a/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java +++ b/config/src/main/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurer.java @@ -16,10 +16,31 @@ package org.springframework.security.config.annotation.web.configurers; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.security.authentication.password.ChangeExistingPasswordAdvisor; +import org.springframework.security.authentication.password.ChangePasswordAdvice; +import org.springframework.security.authentication.password.ChangePasswordServiceAdvisor; +import org.springframework.security.authentication.password.ChangeUpdatingPasswordAdvisor; +import org.springframework.security.authentication.password.DelegatingChangePasswordAdvisor; +import org.springframework.security.authentication.password.UserDetailsPasswordManager; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.config.annotation.web.RequestMatcherFactory; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.RequestMatcherRedirectFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.password.ChangeCompromisedPasswordAdvisor; +import org.springframework.security.web.authentication.password.ChangePasswordAdviceHandler; +import org.springframework.security.web.authentication.password.ChangePasswordAdviceRepository; +import org.springframework.security.web.authentication.password.ChangePasswordAdvisingFilter; +import org.springframework.security.web.authentication.password.ChangePasswordProcessingFilter; +import org.springframework.security.web.authentication.password.DefaultChangePasswordPageGeneratingFilter; +import org.springframework.security.web.authentication.password.HttpSessionChangePasswordAdviceRepository; +import org.springframework.security.web.authentication.password.SimpleChangePasswordAdviceHandler; +import org.springframework.security.web.savedrequest.RequestCacheAwareFilter; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.util.Assert; /** @@ -29,14 +50,30 @@ * @since 5.6 */ public final class PasswordManagementConfigurer> - extends AbstractHttpConfigurer, B> { + extends AbstractHttpConfigurer, B> implements ApplicationContextAware { private static final String WELL_KNOWN_CHANGE_PASSWORD_PATTERN = "/.well-known/change-password"; - private static final String DEFAULT_CHANGE_PASSWORD_PAGE = "/change-password"; + private static final String DEFAULT_CHANGE_PASSWORD_PAGE = DefaultChangePasswordPageGeneratingFilter.DEFAULT_CHANGE_PASSWORD_URL; + + private ApplicationContext context; + + private boolean customChangePasswordPage = false; private String changePasswordPage = DEFAULT_CHANGE_PASSWORD_PAGE; + private String changePasswordProcessingUrl = ChangePasswordProcessingFilter.DEFAULT_PASSWORD_CHANGE_PROCESSING_URL; + + private ChangePasswordAdviceRepository changePasswordAdviceRepository; + + private ChangeExistingPasswordAdvisor changeExistingPasswordAdvisor; + + private ChangeUpdatingPasswordAdvisor changeUpdatingPasswordAdvisor; + + private ChangePasswordAdviceHandler changePasswordAdviceHandler; + + private UserDetailsPasswordManager userDetailsPasswordManager; + /** * Sets the change password page. Defaults to * {@link PasswordManagementConfigurer#DEFAULT_CHANGE_PASSWORD_PAGE}. @@ -46,9 +83,84 @@ public final class PasswordManagementConfigurer public PasswordManagementConfigurer changePasswordPage(String changePasswordPage) { Assert.hasText(changePasswordPage, "changePasswordPage cannot be empty"); this.changePasswordPage = changePasswordPage; + this.customChangePasswordPage = true; + return this; + } + + public PasswordManagementConfigurer changePasswordProcessingUrl(String changePasswordProcessingUrl) { + this.changePasswordProcessingUrl = changePasswordProcessingUrl; return this; } + public PasswordManagementConfigurer changePasswordAdviceRepository( + ChangePasswordAdviceRepository changePasswordAdviceRepository) { + this.changePasswordAdviceRepository = changePasswordAdviceRepository; + return this; + } + + public PasswordManagementConfigurer changeExistingPasswordAdvisor( + ChangeExistingPasswordAdvisor changePasswordAdvisor) { + this.changeExistingPasswordAdvisor = changePasswordAdvisor; + return this; + } + + public PasswordManagementConfigurer changeUpdatingPasswordAdvisor( + ChangeUpdatingPasswordAdvisor changePasswordAdvisor) { + this.changeUpdatingPasswordAdvisor = changePasswordAdvisor; + return this; + } + + public PasswordManagementConfigurer changePasswordAdviceHandler( + ChangePasswordAdviceHandler changePasswordAdviceHandler) { + this.changePasswordAdviceHandler = changePasswordAdviceHandler; + return this; + } + + public PasswordManagementConfigurer userDetailsPasswordManager( + UserDetailsPasswordManager userDetailsPasswordManager) { + this.userDetailsPasswordManager = userDetailsPasswordManager; + return this; + } + + @Override + public void init(B http) throws Exception { + UserDetailsPasswordManager passwordManager = (this.userDetailsPasswordManager == null) + ? this.context.getBeanProvider(UserDetailsPasswordManager.class).getIfUnique() + : this.userDetailsPasswordManager; + + if (passwordManager == null) { + return; + } + + ChangePasswordAdviceRepository changePasswordAdviceRepository = (this.changePasswordAdviceRepository != null) + ? this.changePasswordAdviceRepository + : this.context.getBeanProvider(ChangePasswordAdviceRepository.class) + .getIfUnique(HttpSessionChangePasswordAdviceRepository::new); + + ChangeExistingPasswordAdvisor changeExistingPasswordAdvisor = (this.changeExistingPasswordAdvisor != null) + ? this.changeExistingPasswordAdvisor + : this.context.getBeanProvider(ChangeExistingPasswordAdvisor.class) + .getIfUnique(() -> DelegatingChangePasswordAdvisor.forExisting( + new ChangePasswordServiceAdvisor(passwordManager), new ChangeCompromisedPasswordAdvisor())); + ChangeUpdatingPasswordAdvisor changeUpdatingPasswordAdvisor = (this.changeExistingPasswordAdvisor != null) + ? this.changeUpdatingPasswordAdvisor : this.context.getBeanProvider(ChangeUpdatingPasswordAdvisor.class) + .getIfUnique(ChangeCompromisedPasswordAdvisor::new); + + http.setSharedObject(ChangePasswordAdviceRepository.class, changePasswordAdviceRepository); + http.setSharedObject(UserDetailsPasswordManager.class, passwordManager); + http.setSharedObject(ChangeUpdatingPasswordAdvisor.class, changeUpdatingPasswordAdvisor); + + FormLoginConfigurer form = http.getConfigurer(FormLoginConfigurer.class); + String passwordParameter = (form != null) ? form.getPasswordParameter() : "password"; + http.getConfigurer(SessionManagementConfigurer.class) + .addSessionAuthenticationStrategy((authentication, request, response) -> { + UserDetails user = (UserDetails) authentication.getPrincipal(); + String password = request.getParameter(passwordParameter); + ChangePasswordAdvice advice = changeExistingPasswordAdvisor.advise(user, password); + changePasswordAdviceRepository.savePasswordAdvice(request, response, advice); + }); + } + /** * {@inheritDoc} */ @@ -57,6 +169,42 @@ public void configure(B http) throws Exception { RequestMatcherRedirectFilter changePasswordFilter = new RequestMatcherRedirectFilter( RequestMatcherFactory.matcher(WELL_KNOWN_CHANGE_PASSWORD_PATTERN), this.changePasswordPage); http.addFilterBefore(postProcess(changePasswordFilter), UsernamePasswordAuthenticationFilter.class); + + if (http.getSharedObject(UserDetailsPasswordManager.class) == null) { + return; + } + + PasswordEncoder passwordEncoder = this.context.getBeanProvider(PasswordEncoder.class) + .getIfUnique(PasswordEncoderFactories::createDelegatingPasswordEncoder); + + ChangePasswordAdviceHandler changePasswordAdviceHandler = (this.changePasswordAdviceHandler != null) + ? this.changePasswordAdviceHandler : this.context.getBeanProvider(ChangePasswordAdviceHandler.class) + .getIfUnique(() -> new SimpleChangePasswordAdviceHandler(this.changePasswordPage)); + + if (!this.customChangePasswordPage) { + DefaultChangePasswordPageGeneratingFilter page = new DefaultChangePasswordPageGeneratingFilter(); + http.addFilterBefore(page, RequestCacheAwareFilter.class); + } + + ChangePasswordProcessingFilter processing = new ChangePasswordProcessingFilter( + http.getSharedObject(UserDetailsPasswordManager.class)); + processing + .setRequestMatcher(PathPatternRequestMatcher.withDefaults().matcher(this.changePasswordProcessingUrl)); + processing.setChangePasswordAdvisor(http.getSharedObject(ChangeUpdatingPasswordAdvisor.class)); + processing.setChangePasswordAdviceRepository(http.getSharedObject(ChangePasswordAdviceRepository.class)); + processing.setPasswordEncoder(passwordEncoder); + processing.setSecurityContextHolderStrategy(getSecurityContextHolderStrategy()); + http.addFilterBefore(processing, RequestCacheAwareFilter.class); + + ChangePasswordAdvisingFilter advising = new ChangePasswordAdvisingFilter(); + advising.setChangePasswordAdviceRepository(http.getSharedObject(ChangePasswordAdviceRepository.class)); + advising.setChangePasswordAdviceHandler(changePasswordAdviceHandler); + http.addFilterBefore(advising, RequestCacheAwareFilter.class); + } + + @Override + public void setApplicationContext(ApplicationContext context) { + this.context = context; } } diff --git a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java index 18017b16b1e..9e80c7735cc 100644 --- a/config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java +++ b/config/src/test/java/org/springframework/security/config/annotation/web/configurers/PasswordManagementConfigurerTests.java @@ -16,22 +16,52 @@ package org.springframework.security.config.annotation.web.configurers; +import java.net.URI; +import java.util.UUID; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.authentication.password.ChangePasswordAdvice; +import org.springframework.security.authentication.password.ChangePasswordReason; +import org.springframework.security.authentication.password.SimpleChangePasswordAdvice; +import org.springframework.security.authentication.password.UserDetailsPasswordManager; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.test.SpringTestContext; import org.springframework.security.config.test.SpringTestContextExtension; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.web.SecurityFilterChain; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.hamcrest.Matchers.containsString; import static org.springframework.security.config.Customizer.withDefaults; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -87,6 +117,54 @@ public void whenSettingBlankChangePasswordPage() { .withMessage("changePasswordPage cannot be empty"); } + @Test + void whenAdminSetsExpiredAdviceThenUserLoginRedirectsToResetPassword() throws Exception { + this.spring.register(PasswordManagementConfig.class, AdminController.class, HomeController.class).autowire(); + UserDetailsService users = this.spring.getContext().getBean(UserDetailsService.class); + UserDetails admin = users.loadUserByUsername("admin"); + this.mvc.perform(get("/").with(user(admin))).andExpect(status().isOk()); + // change the password to a test value + String random = UUID.randomUUID().toString(); + this.mvc.perform(post("/change-password").with(csrf()).with(user(admin)).param("newPassword", random)) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/")); + // admin "expires" their own password + this.mvc.perform(post("/admin/passwords/expire/admin").with(csrf()).with(user(admin))) + .andExpect(status().isCreated()); + // .andExpect(jsonPath("$.action").value(ChangePasswordAdvice.Action.MUST_CHANGE.toString())); + // requests redirect to /change-password + MvcResult result = this.mvc + .perform(post("/login").with(csrf()).param("username", "admin").param("password", random)) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/")) + .andReturn(); + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); + this.mvc.perform(get("/").session(session)) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/change-password")); + // reset the password to update + random = UUID.randomUUID().toString(); + this.mvc.perform(post("/change-password").with(csrf()).session(session).param("newPassword", random)) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/")); + // now we're good + this.mvc.perform(get("/").session(session)).andExpect(status().isOk()); + } + + @Test + void whenCompromisedThenUserLoginAllowed() throws Exception { + this.spring.register(PasswordManagementConfig.class, AdminController.class, HomeController.class).autowire(); + MvcResult result = this.mvc + .perform(post("/login").with(csrf()).param("username", "compromised").param("password", "password")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/")) + .andReturn(); + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(); + this.mvc.perform(get("/").session(session)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("COMPROMISED"))); + } + @Configuration @EnableWebSecurity static class PasswordManagementWithDefaultChangePasswordPageConfig { @@ -119,4 +197,90 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { } + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class PasswordManagementConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // @formatter:off + http + .authorizeHttpRequests((authz) -> authz + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .formLogin(Customizer.withDefaults()) + .passwordManagement(Customizer.withDefaults()); + // @formatter:on + return http.build(); + } + + @Bean + UserDetailsService users() { + String adminPassword = UUID.randomUUID().toString(); + UserDetails compromised = User.withUsername("compromised").password("{noop}password").roles("USER").build(); + UserDetails admin = User.withUsername("admin").password("{noop}" + adminPassword).roles("ADMIN").build(); + return new InMemoryUserDetailsManager(compromised, admin); + } + + } + + @RequestMapping("/admin/passwords") + @RestController + static class AdminController { + + private final UserDetailsService users; + + private final UserDetailsPasswordManager passwords; + + private final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + AdminController(UserDetailsService users) { + this.users = users; + this.passwords = (UserDetailsPasswordManager) users; + } + + @GetMapping("/advice/{username}") + ResponseEntity requireChangePassword(@PathVariable("username") String username) { + UserDetails user = this.users.loadUserByUsername(username); + if (user == null) { + return ResponseEntity.notFound().build(); + } + ChangePasswordAdvice advice = this.passwords.loadPasswordAdvice(user); + return ResponseEntity.ok(advice); + } + + @PostMapping("/expire/{username}") + ResponseEntity expirePassword(@PathVariable("username") String username) { + UserDetails user = this.users.loadUserByUsername(username); + if (user == null) { + return ResponseEntity.notFound().build(); + } + ChangePasswordAdvice advice = new SimpleChangePasswordAdvice(ChangePasswordAdvice.Action.MUST_CHANGE, + ChangePasswordReason.EXPIRED); + this.passwords.savePasswordAdvice(user, advice); + URI uri = URI.create("/admin/passwords/advice/" + username); + return ResponseEntity.created(uri).body(advice); + } + + @PostMapping("/change") + ResponseEntity changePassword(@AuthenticationPrincipal UserDetails user, + @RequestParam("password") String password) { + this.passwords.updatePassword(user, this.encoder.encode(password)); + return ResponseEntity.ok().build(); + } + + } + + @RestController + static class HomeController { + + @GetMapping + ChangePasswordAdvice index(ChangePasswordAdvice advice) { + return advice; + } + + } + } diff --git a/core/src/main/java/org/springframework/security/authentication/password/ChangeExistingPasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/ChangeExistingPasswordAdvisor.java new file mode 100644 index 00000000000..60c0ddfef5c --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/ChangeExistingPasswordAdvisor.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 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.authentication.password; + +import org.springframework.security.core.userdetails.UserDetails; + +public interface ChangeExistingPasswordAdvisor { + + ChangePasswordAdvice advise(UserDetails user, String password); + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/ChangeLengthPasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/ChangeLengthPasswordAdvisor.java new file mode 100644 index 00000000000..100fe166a0e --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/ChangeLengthPasswordAdvisor.java @@ -0,0 +1,60 @@ +/* + * Copyright 2025 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.authentication.password; + +import org.springframework.security.authentication.password.ChangePasswordAdvice.Action; +import org.springframework.security.core.userdetails.UserDetails; + +public class ChangeLengthPasswordAdvisor implements ChangeExistingPasswordAdvisor, ChangeUpdatingPasswordAdvisor { + + private final int minLength; + + private final int maxLength; + + private Action tooShortAction = Action.MUST_CHANGE; + + private Action tooLongAction = Action.SHOULD_CHANGE; + + public ChangeLengthPasswordAdvisor(int minLength) { + this(minLength, Integer.MAX_VALUE); + } + + public ChangeLengthPasswordAdvisor(int minLength, int maxLength) { + this.minLength = minLength; + this.maxLength = maxLength; + } + + @Override + public ChangePasswordAdvice advise(UserDetails user, String password) { + if (password.length() < this.minLength) { + return new SimpleChangePasswordAdvice(this.tooShortAction, ChangePasswordReason.TOO_SHORT); + } + if (password.length() > this.maxLength) { + return new SimpleChangePasswordAdvice(this.tooLongAction, ChangePasswordReason.TOO_LONG); + } + return ChangePasswordAdvice.keep(); + } + + public void setTooShortAction(Action tooShortAction) { + this.tooShortAction = tooShortAction; + } + + public void setTooLongAction(Action tooLongAction) { + this.tooLongAction = tooLongAction; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/ChangePasswordAdvice.java b/core/src/main/java/org/springframework/security/authentication/password/ChangePasswordAdvice.java new file mode 100644 index 00000000000..71d4b3d6cfe --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/ChangePasswordAdvice.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 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.authentication.password; + +import java.util.Collection; + +public interface ChangePasswordAdvice { + + Action getAction(); + + Collection getReasons(); + + static ChangePasswordAdvice keep() { + return SimpleChangePasswordAdvice.KEEP; + } + + enum Action { + + KEEP, SHOULD_CHANGE, MUST_CHANGE + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/ChangePasswordReason.java b/core/src/main/java/org/springframework/security/authentication/password/ChangePasswordReason.java new file mode 100644 index 00000000000..fd95c11e62b --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/ChangePasswordReason.java @@ -0,0 +1,23 @@ +/* + * Copyright 2025 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.authentication.password; + +public enum ChangePasswordReason { + + COMPROMISED, EXPIRED, MISSING_CHARACTERS, REPEATED, TOO_SHORT, TOO_LONG, UNSUPPORTED_CHARACTERS + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/ChangePasswordServiceAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/ChangePasswordServiceAdvisor.java new file mode 100644 index 00000000000..c25bd1f9563 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/ChangePasswordServiceAdvisor.java @@ -0,0 +1,34 @@ +/* + * Copyright 2025 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.authentication.password; + +import org.springframework.security.core.userdetails.UserDetails; + +public final class ChangePasswordServiceAdvisor implements ChangeExistingPasswordAdvisor { + + private final UserDetailsPasswordManager passwordManager; + + public ChangePasswordServiceAdvisor(UserDetailsPasswordManager passwordManager) { + this.passwordManager = passwordManager; + } + + @Override + public ChangePasswordAdvice advise(UserDetails user, String password) { + return this.passwordManager.loadPasswordAdvice(user); + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/ChangeRepeatedPasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/ChangeRepeatedPasswordAdvisor.java new file mode 100644 index 00000000000..3d31a69885f --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/ChangeRepeatedPasswordAdvisor.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025 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.authentication.password; + +import org.springframework.security.authentication.password.ChangePasswordAdvice.Action; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.util.Assert; + +public final class ChangeRepeatedPasswordAdvisor implements ChangeUpdatingPasswordAdvisor { + + private final UserDetailsService userDetailsService; + + private PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + private Action action = Action.MUST_CHANGE; + + public ChangeRepeatedPasswordAdvisor(UserDetailsService userDetailsService) { + this.userDetailsService = userDetailsService; + } + + @Override + public ChangePasswordAdvice advise(UserDetails user, String password) { + UserDetails withPassword = this.userDetailsService.loadUserByUsername(user.getUsername()); + if (this.passwordEncoder.matches(password, withPassword.getPassword())) { + return new SimpleChangePasswordAdvice(this.action, ChangePasswordReason.REPEATED); + } + return ChangePasswordAdvice.keep(); + } + + public void setPasswordEncoder(PasswordEncoder passwordEncoder) { + Assert.notNull(passwordEncoder, "passwordEncoder cannot be null"); + this.passwordEncoder = passwordEncoder; + } + + public void setAction(Action action) { + this.action = action; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/ChangeUpdatingPasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/ChangeUpdatingPasswordAdvisor.java new file mode 100644 index 00000000000..77a0a13a511 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/ChangeUpdatingPasswordAdvisor.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 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.authentication.password; + +import org.springframework.security.core.userdetails.UserDetails; + +public interface ChangeUpdatingPasswordAdvisor { + + ChangePasswordAdvice advise(UserDetails user, String password); + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/DelegatingChangePasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/DelegatingChangePasswordAdvisor.java new file mode 100644 index 00000000000..323c75e13fa --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/DelegatingChangePasswordAdvisor.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025 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.authentication.password; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.BiFunction; +import java.util.stream.Stream; + +import org.springframework.security.core.userdetails.UserDetails; + +public final class DelegatingChangePasswordAdvisor + implements ChangeExistingPasswordAdvisor, ChangeUpdatingPasswordAdvisor { + + private final List> advisors; + + private DelegatingChangePasswordAdvisor(List> advisors) { + this.advisors = Collections.unmodifiableList(advisors); + } + + public static ChangeExistingPasswordAdvisor forExisting(ChangeExistingPasswordAdvisor... advisors) { + return new DelegatingChangePasswordAdvisor(Stream.of(advisors) + .map((advisor) -> (BiFunction) advisor::advise) + .toList()); + } + + public static ChangeUpdatingPasswordAdvisor forUpdating(ChangeUpdatingPasswordAdvisor... advisors) { + return new DelegatingChangePasswordAdvisor(Stream.of(advisors) + .map((advisor) -> (BiFunction) advisor::advise) + .toList()); + } + + @Override + public ChangePasswordAdvice advise(UserDetails user, String password) { + Collection advice = this.advisors.stream() + .map((advisor) -> advisor.apply(user, password)) + .filter(Objects::nonNull) + .toList(); + return new CompositeChangePasswordAdvice(advice); + } + + private static final class CompositeChangePasswordAdvice implements ChangePasswordAdvice { + + private final Collection advice; + + private final Action action; + + private final Collection reasons; + + private CompositeChangePasswordAdvice(Collection advice) { + this.advice = advice; + Action action = Action.KEEP; + Collection reasons = new ArrayList<>(); + for (ChangePasswordAdvice a : advice) { + if (a.getAction() == Action.KEEP) { + continue; + } + if (action.ordinal() < a.getAction().ordinal()) { + action = a.getAction(); + } + reasons.addAll(a.getReasons()); + } + this.action = action; + this.reasons = reasons; + } + + @Override + public Action getAction() { + return this.action; + } + + @Override + public Collection getReasons() { + return this.reasons; + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/SimpleChangePasswordAdvice.java b/core/src/main/java/org/springframework/security/authentication/password/SimpleChangePasswordAdvice.java new file mode 100644 index 00000000000..712c227d546 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/SimpleChangePasswordAdvice.java @@ -0,0 +1,50 @@ +/* + * Copyright 2002-2025 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.authentication.password; + +import java.util.Collection; +import java.util.List; + +public class SimpleChangePasswordAdvice implements ChangePasswordAdvice { + + static final SimpleChangePasswordAdvice KEEP = new SimpleChangePasswordAdvice(Action.KEEP); + + private final Action action; + + private final Collection reasons; + + public SimpleChangePasswordAdvice(Action action, Collection reasons) { + this.action = action; + this.reasons = reasons; + } + + public SimpleChangePasswordAdvice(Action action, ChangePasswordReason... reasons) { + this.action = action; + this.reasons = List.of(reasons); + } + + @Override + public Action getAction() { + return this.action; + } + + @Override + public Collection getReasons() { + return this.reasons; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/UserDetailsPasswordManager.java b/core/src/main/java/org/springframework/security/authentication/password/UserDetailsPasswordManager.java new file mode 100644 index 00000000000..988736a86db --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/UserDetailsPasswordManager.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 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.authentication.password; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsPasswordService; + +public interface UserDetailsPasswordManager extends UserDetailsPasswordService { + + /** + * Update the password and remove any related password advice + * @param user the user to modify the password for + * @param newPassword the password to change to, encoded by the configured + * {@code PasswordEncoder} + * @return the updated {@link UserDetails} + */ + @Override + UserDetails updatePassword(UserDetails user, String newPassword); + + ChangePasswordAdvice loadPasswordAdvice(UserDetails user); + + void savePasswordAdvice(UserDetails user, ChangePasswordAdvice advice); + + void removePasswordAdvice(UserDetails user); + +} diff --git a/core/src/main/java/org/springframework/security/provisioning/InMemoryUserDetailsManager.java b/core/src/main/java/org/springframework/security/provisioning/InMemoryUserDetailsManager.java index 498e165342a..c3be1012719 100644 --- a/core/src/main/java/org/springframework/security/provisioning/InMemoryUserDetailsManager.java +++ b/core/src/main/java/org/springframework/security/provisioning/InMemoryUserDetailsManager.java @@ -30,13 +30,14 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.password.ChangePasswordAdvice; +import org.springframework.security.authentication.password.UserDetailsPasswordManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsPasswordService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.memory.UserAttribute; import org.springframework.security.core.userdetails.memory.UserAttributeEditor; @@ -52,12 +53,14 @@ * @author Luke Taylor * @since 3.1 */ -public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService { +public class InMemoryUserDetailsManager implements UserDetailsManager, UserDetailsPasswordManager { protected final Log logger = LogFactory.getLog(getClass()); private final Map users = new HashMap<>(); + private final Map advice = new HashMap<>(); + private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder .getContextHolderStrategy(); @@ -157,6 +160,7 @@ public UserDetails updatePassword(UserDetails user, String newPassword) { String username = user.getUsername(); MutableUserDetails mutableUser = this.users.get(username.toLowerCase(Locale.ROOT)); mutableUser.setPassword(newPassword); + removePasswordAdvice(mutableUser); return mutableUser; } @@ -173,6 +177,23 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities()); } + @Override + public ChangePasswordAdvice loadPasswordAdvice(UserDetails user) { + return this.advice.get(user.getUsername()); + } + + @Override + public void savePasswordAdvice(UserDetails user, ChangePasswordAdvice advice) { + Assert.notNull(advice, + "advice must not be null; if you want to remove advice, please call removePasswordAdvice"); + this.advice.put(user.getUsername(), advice); + } + + @Override + public void removePasswordAdvice(UserDetails user) { + this.advice.remove(user.getUsername()); + } + /** * Sets the {@link SecurityContextHolderStrategy} to use. The default action is to use * the {@link SecurityContextHolderStrategy} stored in {@link SecurityContextHolder}. diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/ChangeCompromisedPasswordAdvisor.java b/web/src/main/java/org/springframework/security/web/authentication/password/ChangeCompromisedPasswordAdvisor.java new file mode 100644 index 00000000000..3fe0126dd56 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/ChangeCompromisedPasswordAdvisor.java @@ -0,0 +1,79 @@ +/* + * Copyright 2025 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.web.authentication.password; + +import java.util.Collection; + +import org.springframework.security.authentication.password.ChangeExistingPasswordAdvisor; +import org.springframework.security.authentication.password.ChangePasswordAdvice; +import org.springframework.security.authentication.password.ChangePasswordAdvice.Action; +import org.springframework.security.authentication.password.ChangePasswordReason; +import org.springframework.security.authentication.password.ChangeUpdatingPasswordAdvisor; +import org.springframework.security.authentication.password.CompromisedPasswordChecker; +import org.springframework.security.authentication.password.CompromisedPasswordDecision; +import org.springframework.security.authentication.password.SimpleChangePasswordAdvice; +import org.springframework.security.core.userdetails.UserDetails; + +public final class ChangeCompromisedPasswordAdvisor + implements ChangeExistingPasswordAdvisor, ChangeUpdatingPasswordAdvisor { + + private final CompromisedPasswordChecker pwned = new HaveIBeenPwnedRestApiPasswordChecker(); + + private Action action = Action.SHOULD_CHANGE; + + @Override + public ChangePasswordAdvice advise(UserDetails user, String password) { + return new Advice(this.action, this.pwned.check(password)); + } + + public void setAction(Action action) { + this.action = action; + } + + public static final class Advice implements ChangePasswordAdvice { + + private final CompromisedPasswordDecision decision; + + private final ChangePasswordAdvice advice; + + public Advice(Action action, CompromisedPasswordDecision decision) { + this.decision = decision; + if (decision.isCompromised()) { + this.advice = new SimpleChangePasswordAdvice(action, ChangePasswordReason.COMPROMISED); + } + else { + this.advice = ChangePasswordAdvice.keep(); + } + } + + public CompromisedPasswordDecision getDecision() { + return this.decision; + } + + @Override + public Action getAction() { + return this.advice.getAction(); + } + + @Override + public Collection getReasons() { + return this.advice.getReasons(); + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdviceHandler.java b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdviceHandler.java new file mode 100644 index 00000000000..96a75d556cd --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdviceHandler.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025 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.web.authentication.password; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.password.ChangePasswordAdvice; + +public interface ChangePasswordAdviceHandler { + + void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + ChangePasswordAdvice advice) throws ServletException, IOException; + +} + +// authentication request process +// -------------- ------- ------- +// KEEP redirect to home continue filter redirect to home +// RESET redirect to home continue filter redirect to home +// REQUIRE_RESET redirect to home redirect to reset redirect to home diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdviceMethodArgumentResolver.java b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdviceMethodArgumentResolver.java new file mode 100644 index 00000000000..3a5cd334767 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdviceMethodArgumentResolver.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 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.web.authentication.password; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.core.MethodParameter; +import org.springframework.security.authentication.password.ChangePasswordAdvice; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +public final class ChangePasswordAdviceMethodArgumentResolver implements HandlerMethodArgumentResolver { + + ChangePasswordAdviceRepository changePasswordAdviceRepository = new HttpSessionChangePasswordAdviceRepository(); + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return ChangePasswordAdvice.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + return this.changePasswordAdviceRepository + .loadPasswordAdvice(webRequest.getNativeRequest(HttpServletRequest.class)); + } + + public void setChangePasswordAdviceRepository(ChangePasswordAdviceRepository changePasswordAdviceRepository) { + this.changePasswordAdviceRepository = changePasswordAdviceRepository; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdviceRepository.java b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdviceRepository.java new file mode 100644 index 00000000000..7cee802964b --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdviceRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 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.web.authentication.password; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.password.ChangePasswordAdvice; + +public interface ChangePasswordAdviceRepository { + + ChangePasswordAdvice loadPasswordAdvice(HttpServletRequest request); + + void savePasswordAdvice(HttpServletRequest request, HttpServletResponse response, ChangePasswordAdvice advice); + + void removePasswordAdvice(HttpServletRequest request, HttpServletResponse response); + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdvisingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdvisingFilter.java new file mode 100644 index 00000000000..aec9b12765b --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordAdvisingFilter.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025 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.web.authentication.password; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.password.ChangePasswordAdvice; +import org.springframework.web.filter.OncePerRequestFilter; + +public class ChangePasswordAdvisingFilter extends OncePerRequestFilter { + + private ChangePasswordAdviceHandler changePasswordAdviceHandler = new SimpleChangePasswordAdviceHandler( + DefaultChangePasswordPageGeneratingFilter.DEFAULT_CHANGE_PASSWORD_URL); + + private ChangePasswordAdviceRepository changePasswordAdviceRepository = new HttpSessionChangePasswordAdviceRepository(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + ChangePasswordAdvice advice = this.changePasswordAdviceRepository.loadPasswordAdvice(request); + this.changePasswordAdviceHandler.handle(request, response, chain, advice); + } + + public void setChangePasswordAdviceRepository(ChangePasswordAdviceRepository changePasswordAdviceRepository) { + this.changePasswordAdviceRepository = changePasswordAdviceRepository; + } + + public void setChangePasswordAdviceHandler(ChangePasswordAdviceHandler changePasswordAdviceHandler) { + this.changePasswordAdviceHandler = changePasswordAdviceHandler; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordProcessingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordProcessingFilter.java new file mode 100644 index 00000000000..ebc17fda3ed --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/ChangePasswordProcessingFilter.java @@ -0,0 +1,148 @@ +/* + * Copyright 2025 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.web.authentication.password; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.password.ChangePasswordAdvice; +import org.springframework.security.authentication.password.ChangeUpdatingPasswordAdvisor; +import org.springframework.security.authentication.password.UserDetailsPasswordManager; +import org.springframework.security.authorization.AuthenticatedAuthorizationManager; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.context.SecurityContextHolderStrategy; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.HttpStatusAccessDeniedHandler; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +public class ChangePasswordProcessingFilter extends OncePerRequestFilter { + + public static final String DEFAULT_PASSWORD_CHANGE_PROCESSING_URL = "/change-password"; + + private final AuthenticationFailureHandler failureHandler = new AuthenticationEntryPointFailureHandler( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)); + + private final AuthorizationManager authorizationManager = AuthenticatedAuthorizationManager + .authenticated(); + + private final AccessDeniedHandler deniedHandler = new HttpStatusAccessDeniedHandler(HttpStatus.FORBIDDEN); + + private final AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler(); + + private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder + .getContextHolderStrategy(); + + private RequestMatcher requestMatcher = PathPatternRequestMatcher.withDefaults() + .matcher(HttpMethod.POST, DEFAULT_PASSWORD_CHANGE_PROCESSING_URL); + + private PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + private ChangePasswordAdviceRepository changePasswordAdviceRepository = new HttpSessionChangePasswordAdviceRepository(); + + private ChangeUpdatingPasswordAdvisor changePasswordAdvisor = new ChangeCompromisedPasswordAdvisor(); + + private final UserDetailsPasswordManager passwordManager; + + public ChangePasswordProcessingFilter(UserDetailsPasswordManager passwordManager) { + this.passwordManager = passwordManager; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + RequestMatcher.MatchResult match = this.requestMatcher.matcher(request); + if (!match.isMatch()) { + chain.doFilter(request, response); + return; + } + String password = request.getParameter("newPassword"); + if (password == null) { + chain.doFilter(request, response); + return; + } + Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication(); + if (authentication == null) { + this.failureHandler.onAuthenticationFailure(request, response, + new InsufficientAuthenticationException("Authentication required to change password")); + return; + } + AuthorizationResult authorization = this.authorizationManager.authorize(() -> authentication, + new RequestAuthorizationContext(request, match.getVariables())); + if (authorization == null) { + this.deniedHandler.handle(request, response, new AuthorizationDeniedException("denied")); + return; + } + if (!authorization.isGranted()) { + this.deniedHandler.handle(request, response, + new AuthorizationDeniedException("access denied", authorization)); + return; + } + UserDetails user = (UserDetails) authentication.getPrincipal(); + ChangePasswordAdvice advice = this.changePasswordAdvisor.advise(user, password); + if (advice.getAction() == ChangePasswordAdvice.Action.KEEP) { + this.passwordManager.updatePassword(user, this.passwordEncoder.encode(password)); + this.changePasswordAdviceRepository.removePasswordAdvice(request, response); + } + else { + this.changePasswordAdviceRepository.savePasswordAdvice(request, response, advice); + } + this.successHandler.onAuthenticationSuccess(request, response, authentication); + } + + public void setChangePasswordAdviceRepository(ChangePasswordAdviceRepository advice) { + this.changePasswordAdviceRepository = advice; + } + + public void setChangePasswordAdvisor(ChangeUpdatingPasswordAdvisor advisor) { + this.changePasswordAdvisor = advisor; + } + + public void setRequestMatcher(RequestMatcher requestMatcher) { + this.requestMatcher = requestMatcher; + } + + public void setPasswordEncoder(PasswordEncoder passwordEncoder) { + this.passwordEncoder = passwordEncoder; + } + + public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) { + this.securityContextHolderStrategy = securityContextHolderStrategy; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/DefaultChangePasswordPageGeneratingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/password/DefaultChangePasswordPageGeneratingFilter.java new file mode 100644 index 00000000000..c6681b88214 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/DefaultChangePasswordPageGeneratingFilter.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 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.web.authentication.password; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.http.HttpMethod; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +public class DefaultChangePasswordPageGeneratingFilter extends OncePerRequestFilter { + + public static final String DEFAULT_CHANGE_PASSWORD_URL = "/change-password"; + + private RequestMatcher requestMatcher = PathPatternRequestMatcher.withDefaults() + .matcher(HttpMethod.GET, DEFAULT_CHANGE_PASSWORD_URL); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + if (!this.requestMatcher.matches(request)) { + chain.doFilter(request, response); + return; + } + String page = PASSWORD_RESET_TEMPLATE; + CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); + if (token != null) { + page = page.replace("{{parameter}}", token.getParameterName()).replace("{{value}}", token.getToken()); + } + response.setContentType("text/html;charset=UTF-8"); + response.getWriter().println(page); + } + + private static final String PASSWORD_RESET_TEMPLATE = """ + + + + + + Change Your Password + + + +
+ +
+ + + """; + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/HttpSessionChangePasswordAdviceRepository.java b/web/src/main/java/org/springframework/security/web/authentication/password/HttpSessionChangePasswordAdviceRepository.java new file mode 100644 index 00000000000..ca73e174481 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/HttpSessionChangePasswordAdviceRepository.java @@ -0,0 +1,83 @@ +/* + * Copyright 2025 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.web.authentication.password; + +import java.util.Collection; +import java.util.function.Supplier; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.password.ChangePasswordAdvice; +import org.springframework.security.authentication.password.ChangePasswordReason; +import org.springframework.util.function.SingletonSupplier; + +public final class HttpSessionChangePasswordAdviceRepository implements ChangePasswordAdviceRepository { + + private static final String PASSWORD_ADVICE_ATTRIBUTE_NAME = HttpSessionChangePasswordAdviceRepository.class + .getName() + ".PASSWORD_ADVICE"; + + @Override + @NonNull + public ChangePasswordAdvice loadPasswordAdvice(HttpServletRequest request) { + return new DeferredChangePasswordAdvice(() -> { + ChangePasswordAdvice advice = (ChangePasswordAdvice) request.getSession() + .getAttribute(PASSWORD_ADVICE_ATTRIBUTE_NAME); + if (advice != null) { + return advice; + } + return ChangePasswordAdvice.keep(); + }); + } + + @Override + public void savePasswordAdvice(HttpServletRequest request, HttpServletResponse response, + ChangePasswordAdvice advice) { + if (advice.getAction() == ChangePasswordAdvice.Action.KEEP) { + removePasswordAdvice(request, response); + return; + } + request.getSession().setAttribute(PASSWORD_ADVICE_ATTRIBUTE_NAME, advice); + } + + @Override + public void removePasswordAdvice(HttpServletRequest request, HttpServletResponse response) { + request.getSession().removeAttribute(PASSWORD_ADVICE_ATTRIBUTE_NAME); + } + + private static final class DeferredChangePasswordAdvice implements ChangePasswordAdvice { + + private final Supplier advice; + + DeferredChangePasswordAdvice(Supplier advice) { + this.advice = SingletonSupplier.of(advice); + } + + @Override + public Action getAction() { + return this.advice.get().getAction(); + } + + @Override + public Collection getReasons() { + return this.advice.get().getReasons(); + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/SimpleChangePasswordAdviceHandler.java b/web/src/main/java/org/springframework/security/web/authentication/password/SimpleChangePasswordAdviceHandler.java new file mode 100644 index 00000000000..a9f63da1284 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/SimpleChangePasswordAdviceHandler.java @@ -0,0 +1,76 @@ +/* + * Copyright 2025 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.web.authentication.password; + +import java.io.IOException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.password.ChangePasswordAdvice; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.savedrequest.NullRequestCache; +import org.springframework.security.web.savedrequest.RequestCache; +import org.springframework.security.web.util.matcher.AnyRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.util.Assert; + +public final class SimpleChangePasswordAdviceHandler implements ChangePasswordAdviceHandler { + + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + private final String changePasswordUrl; + + private RequestCache requestCache = new NullRequestCache(); + + private RequestMatcher requestMatcher = AnyRequestMatcher.INSTANCE; + + public SimpleChangePasswordAdviceHandler(String changePasswordUrl) { + Assert.hasText(changePasswordUrl, "changePasswordUrl cannot be empty"); + this.changePasswordUrl = changePasswordUrl; + } + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain, + ChangePasswordAdvice advice) throws IOException, ServletException { + if (!this.requestMatcher.matches(request)) { + return; + } + if (request.getRequestURI().equals(this.changePasswordUrl)) { + chain.doFilter(request, response); + return; + } + if (advice.getAction() != ChangePasswordAdvice.Action.MUST_CHANGE) { + chain.doFilter(request, response); + return; + } + this.requestCache.saveRequest(request, response); + this.redirectStrategy.sendRedirect(request, response, this.changePasswordUrl); + } + + public void setRequestCache(RequestCache requestCache) { + this.requestCache = requestCache; + } + + public void setRequestMatcher(RequestMatcher requestMatcher) { + this.requestMatcher = requestMatcher; + } + +}