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 e59d438193a..125a64bdb3a 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 @@ -1778,7 +1778,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 37870e0aca2..f6543d5c6ca 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 @@ -37,6 +37,9 @@ import org.springframework.security.core.context.SecurityContextHolderStrategy; import org.springframework.security.web.FilterChainProxy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.password.HttpSessionPasswordAdviceRepository; +import org.springframework.security.web.authentication.password.PasswordAdviceMethodArgumentResolver; +import org.springframework.security.web.authentication.password.PasswordAdviceRepository; import org.springframework.security.web.debug.DebugFilter; import org.springframework.security.web.firewall.HttpFirewall; import org.springframework.security.web.firewall.RequestRejectedHandler; @@ -72,6 +75,8 @@ class WebMvcSecurityConfiguration implements WebMvcConfigurer, ApplicationContex private AnnotationTemplateExpressionDefaults templateDefaults; + private PasswordAdviceRepository passwordAdviceRepository = new HttpSessionPasswordAdviceRepository(); + @Override @SuppressWarnings("deprecation") public void addArgumentResolvers(List argumentResolvers) { @@ -88,6 +93,9 @@ public void addArgumentResolvers(List argumentRes currentSecurityContextArgumentResolver.setTemplateDefaults(this.templateDefaults); argumentResolvers.add(currentSecurityContextArgumentResolver); argumentResolvers.add(new CsrfTokenArgumentResolver()); + PasswordAdviceMethodArgumentResolver resolver = new PasswordAdviceMethodArgumentResolver(); + resolver.setPasswordAdviceRepository(this.passwordAdviceRepository); + argumentResolvers.add(resolver); } @Bean @@ -104,6 +112,9 @@ public void setApplicationContext(ApplicationContext applicationContext) throws if (applicationContext.getBeanNamesForType(AnnotationTemplateExpressionDefaults.class).length == 1) { this.templateDefaults = applicationContext.getBean(AnnotationTemplateExpressionDefaults.class); } + if (applicationContext.getBeanNamesForType(PasswordAdviceRepository.class).length == 1) { + this.passwordAdviceRepository = applicationContext.getBean(PasswordAdviceRepository.class); + } } /** @@ -209,7 +220,7 @@ private static Filter createDoFilterDelegate(List filters) { /** * Find the FilterChainProxy in a List of Filter - * @param filters + * @param filterssetChangePass * @return non-null FilterChainProxy * @throws IllegalStateException if the FilterChainProxy cannot be found */ 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 03cf95b3901..bea56042c77 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 0de4c03b513..e7bc9255731 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,9 +16,19 @@ package org.springframework.security.config.annotation.web.configurers; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.security.authentication.password.PasswordAdvisor; +import org.springframework.security.authentication.password.UserDetailsPasswordAdvisor; import org.springframework.security.config.annotation.web.HttpSecurityBuilder; import org.springframework.security.web.RequestMatcherRedirectFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.password.HttpSessionPasswordAdviceRepository; +import org.springframework.security.web.authentication.password.PasswordAdviceRepository; +import org.springframework.security.web.authentication.password.PasswordAdviceSessionAuthenticationStrategy; +import org.springframework.security.web.authentication.password.PasswordAdvisingFilter; +import org.springframework.security.web.savedrequest.RequestCache; +import org.springframework.security.web.savedrequest.RequestCacheAwareFilter; import org.springframework.util.Assert; /** @@ -28,14 +38,22 @@ * @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 ApplicationContext context; + + private boolean customChangePasswordPage = false; + private String changePasswordPage = DEFAULT_CHANGE_PASSWORD_PAGE; + private PasswordAdviceRepository passwordAdviceRepository; + + private PasswordAdvisor passwordAdvisor; + /** * Sets the change password page. Defaults to * {@link PasswordManagementConfigurer#DEFAULT_CHANGE_PASSWORD_PAGE}. @@ -45,9 +63,44 @@ 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 passwordAdviceRepository(PasswordAdviceRepository passwordAdviceRepository) { + this.passwordAdviceRepository = passwordAdviceRepository; + return this; + } + + public PasswordManagementConfigurer passwordAdvisor(PasswordAdvisor passwordAdvisor) { + this.passwordAdvisor = passwordAdvisor; return this; } + @Override + public void init(B http) throws Exception { + PasswordAdviceRepository passwordAdviceRepository = (this.passwordAdviceRepository != null) + ? this.passwordAdviceRepository : this.context.getBeanProvider(PasswordAdviceRepository.class) + .getIfUnique(HttpSessionPasswordAdviceRepository::new); + + PasswordAdvisor passwordAdvisor = (this.passwordAdvisor != null) ? this.passwordAdvisor + : this.context.getBeanProvider(PasswordAdvisor.class).getIfUnique(UserDetailsPasswordAdvisor::new); + + http.setSharedObject(PasswordAdviceRepository.class, passwordAdviceRepository); + + String passwordParameter = "password"; + FormLoginConfigurer form = http.getConfigurer(FormLoginConfigurer.class); + if (form != null) { + passwordParameter = form.getPasswordParameter(); + } + PasswordAdviceSessionAuthenticationStrategy sessionAuthenticationStrategy = new PasswordAdviceSessionAuthenticationStrategy( + passwordParameter); + sessionAuthenticationStrategy.setPasswordAdviceRepository(passwordAdviceRepository); + sessionAuthenticationStrategy.setPasswordAdvisor(passwordAdvisor); + http.getConfigurer(SessionManagementConfigurer.class) + .addSessionAuthenticationStrategy(sessionAuthenticationStrategy); + } + /** * {@inheritDoc} */ @@ -56,6 +109,16 @@ public void configure(B http) throws Exception { RequestMatcherRedirectFilter changePasswordFilter = new RequestMatcherRedirectFilter( getRequestMatcherBuilder().matcher(WELL_KNOWN_CHANGE_PASSWORD_PATTERN), this.changePasswordPage); http.addFilterBefore(postProcess(changePasswordFilter), UsernamePasswordAuthenticationFilter.class); + + PasswordAdvisingFilter advising = new PasswordAdvisingFilter(this.changePasswordPage); + advising.setPasswordAdviceRepository(http.getSharedObject(PasswordAdviceRepository.class)); + advising.setRequestCache(http.getSharedObject(RequestCache.class)); + 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 0b65e42d4dd..08cc6433ce6 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,58 @@ package org.springframework.security.config.annotation.web.configurers; +import java.net.URI; +import java.util.UUID; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; 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.PasswordAction; +import org.springframework.security.authentication.password.PasswordAdvice; +import org.springframework.security.authentication.password.UpdatePasswordAdvisor; +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.PasswordEncodedUser; +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.provisioning.UserDetailsManager; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.password.CompromisedPasswordAdvisor; +import org.springframework.security.web.authentication.password.HttpSessionPasswordAdviceRepository; +import org.springframework.security.web.authentication.password.PasswordAdviceRepository; 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 +123,52 @@ 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("password", random)) + .andExpect(status().isOk()); + // 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("password", random)) + .andExpect(status().isOk()); + // now we're good + this.mvc.perform(get("/").session(session)).andExpect(status().isOk()); + } + + @Test + void whenShouldChangeThenUserLoginAllowed() throws Exception { + this.spring.register(PasswordManagementConfig.class, AdminController.class, HomeController.class).autowire(); + MvcResult result = this.mvc + .perform(post("/login").with(csrf()).param("username", "user").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("SHOULD_CHANGE"))); + } + @Configuration @EnableWebSecurity static class PasswordManagementWithDefaultChangePasswordPageConfig { @@ -119,4 +201,106 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception { } + @Configuration + @EnableWebSecurity + @EnableWebMvc + static class PasswordManagementConfig { + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, UserDetailsService users) 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 + InMemoryUserDetailsManager users() { + UserDetails shouldChange = User.withUserDetails(PasswordEncodedUser.user()) + .passwordAction(PasswordAction.SHOULD_CHANGE) + .build(); + UserDetails admin = PasswordEncodedUser.admin(); + return new InMemoryUserDetailsManager(shouldChange, admin); + } + + } + + @RequestMapping("/admin/passwords") + @RestController + static class AdminController { + + private final UserDetailsManager users; + + AdminController(InMemoryUserDetailsManager users) { + this.users = users; + } + + @GetMapping("/advice/{username}") + ResponseEntity requireChangePassword(@PathVariable("username") String username) { + UserDetails user = this.users.loadUserByUsername(username); + if (user == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(user.getPasswordAction()); + } + + @PostMapping("/expire/{username}") + ResponseEntity expirePassword(@PathVariable("username") String username) { + UserDetails user = this.users.loadUserByUsername(username); + if (user == null) { + return ResponseEntity.notFound().build(); + } + UserDetails mustChange = User.withUserDetails(user).passwordAction(PasswordAction.MUST_CHANGE).build(); + this.users.updateUser(mustChange); + URI uri = URI.create("/admin/passwords/advice/" + username); + return ResponseEntity.created(uri).body(PasswordAction.MUST_CHANGE); + } + + } + + @RestController + static class HomeController { + + private final InMemoryUserDetailsManager passwords; + + private final UpdatePasswordAdvisor passwordAdvisor = new CompromisedPasswordAdvisor(); + + private final PasswordAdviceRepository passwordAdviceRepository = new HttpSessionPasswordAdviceRepository(); + + private final PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + HomeController(InMemoryUserDetailsManager passwords) { + this.passwords = passwords; + } + + @GetMapping + PasswordAdvice index(PasswordAdvice advice) { + return advice; + } + + @PostMapping("/change-password") + ResponseEntity changePassword(@AuthenticationPrincipal UserDetails user, + @RequestParam("password") String password, HttpServletRequest request, HttpServletResponse response) { + PasswordAdvice advice = this.passwordAdvisor.advise(user, null, password); + if (advice.getAction() != PasswordAction.NONE) { + return ResponseEntity.badRequest().body(advice); + } + UserDetails updated = User.withUserDetails(user) + .passwordEncoder(this.encoder::encode) + .password(password) + .passwordAction(PasswordAction.NONE) + .build(); + this.passwords.updateUser(updated); + this.passwordAdviceRepository.removePasswordAdvice(request, response); + return ResponseEntity.ok().build(); + } + + } + } diff --git a/core/src/main/java/org/springframework/security/authentication/password/CompositePasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/CompositePasswordAdvisor.java new file mode 100644 index 00000000000..13e0132a66f --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/CompositePasswordAdvisor.java @@ -0,0 +1,89 @@ +/* + * Copyright 2004-present 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.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.userdetails.UserDetails; + +public final class CompositePasswordAdvisor implements PasswordAdvisor { + + private final Collection advisors; + + private CompositePasswordAdvisor(Collection advisors) { + this.advisors = Collections.unmodifiableCollection(advisors); + } + + public static PasswordAdvisor of(PasswordAdvisor... advisors) { + return new CompositePasswordAdvisor(List.of(advisors)); + } + + public static PasswordAdvisor withDefaults(PasswordAdvisor... advisors) { + Map, PasswordAdvisor> defaults = new HashMap<>(); + defaults.put(UserDetailsPasswordAdvisor.class, new UserDetailsPasswordAdvisor()); + defaults.put(PasswordLengthAdvisor.class, new PasswordLengthAdvisor()); + for (PasswordAdvisor advisor : advisors) { + defaults.put(advisor.getClass(), advisor); + } + return new CompositePasswordAdvisor(defaults.values()); + } + + @Override + public PasswordAdvice advise(UserDetails user, @Nullable String password) { + Collection advice = this.advisors.stream() + .map((advisor) -> advisor.advise(user, password)) + .toList(); + return new CompositePasswordAdvice(advice); + } + + public static final class CompositePasswordAdvice extends SimplePasswordAdvice { + + private final Collection advice; + + private CompositePasswordAdvice(Collection advice) { + super(findMostUrgentAction(advice)); + this.advice = advice; + } + + private static PasswordAction findMostUrgentAction(Collection advice) { + PasswordAction mostUrgentAction = PasswordAction.NONE; + for (PasswordAdvice a : advice) { + if (mostUrgentAction.ordinal() < a.getAction().ordinal()) { + mostUrgentAction = a.getAction(); + } + } + return mostUrgentAction; + } + + public Collection getAdvice() { + return this.advice; + } + + @Override + public String toString() { + return "Composite [" + "action=" + super.toString() + ", advice=" + this.advice + "]"; + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/CompositeUpdatePasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/CompositeUpdatePasswordAdvisor.java new file mode 100644 index 00000000000..681a9d70160 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/CompositeUpdatePasswordAdvisor.java @@ -0,0 +1,88 @@ +/* + * Copyright 2004-present 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.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.userdetails.UserDetails; + +public final class CompositeUpdatePasswordAdvisor implements UpdatePasswordAdvisor { + + private final Collection advisors; + + private CompositeUpdatePasswordAdvisor(Collection advisors) { + this.advisors = Collections.unmodifiableCollection(advisors); + } + + public static UpdatePasswordAdvisor of(UpdatePasswordAdvisor... advisors) { + return new CompositeUpdatePasswordAdvisor(List.of(advisors)); + } + + public static UpdatePasswordAdvisor withDefaults(UpdatePasswordAdvisor... advisors) { + Map, UpdatePasswordAdvisor> defaults = new HashMap<>(); + defaults.put(RepeatedPasswordAdvisor.class, new RepeatedPasswordAdvisor()); + defaults.put(PasswordLengthAdvisor.class, new PasswordLengthAdvisor()); + for (UpdatePasswordAdvisor advisor : advisors) { + defaults.put(advisor.getClass(), advisor); + } + return new CompositeUpdatePasswordAdvisor(defaults.values()); + } + @Override + public PasswordAdvice advise(UserDetails user, @Nullable String oldPassword, @Nullable String newPassword) { + Collection advice = this.advisors.stream() + .map((advisor) -> advisor.advise(user, oldPassword, newPassword)) + .toList(); + return new CompositePasswordAdvice(advice); + } + + public static final class CompositePasswordAdvice extends SimplePasswordAdvice { + + private final Collection advice; + + private CompositePasswordAdvice(Collection advice) { + super(findMostUrgentAction(advice)); + this.advice = advice; + } + + private static PasswordAction findMostUrgentAction(Collection advice) { + PasswordAction mostUrgentAction = PasswordAction.NONE; + for (PasswordAdvice a : advice) { + if (mostUrgentAction.ordinal() < a.getAction().ordinal()) { + mostUrgentAction = a.getAction(); + } + } + return mostUrgentAction; + } + + public Collection getAdvice() { + return this.advice; + } + + @Override + public String toString() { + return "Composite [" + "action=" + super.toString() + ", advice=" + this.advice + "]"; + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/PasswordAction.java b/core/src/main/java/org/springframework/security/authentication/password/PasswordAction.java new file mode 100644 index 00000000000..c95ab63c504 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/PasswordAction.java @@ -0,0 +1,27 @@ +/* + * Copyright 2004-present 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 PasswordAction { + + NONE, SHOULD_CHANGE, MUST_CHANGE; + + public boolean advisedBy(PasswordAdvice advice) { + return advice.getAction().equals(this); + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/PasswordAdvice.java b/core/src/main/java/org/springframework/security/authentication/password/PasswordAdvice.java new file mode 100644 index 00000000000..30e89663962 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/PasswordAdvice.java @@ -0,0 +1,26 @@ +/* + * Copyright 2004-present 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.jspecify.annotations.NullMarked; + +@NullMarked +public interface PasswordAdvice { + + PasswordAction getAction(); + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/PasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/PasswordAdvisor.java new file mode 100644 index 00000000000..01ddb2ec9aa --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/PasswordAdvisor.java @@ -0,0 +1,29 @@ +/* + * Copyright 2004-present 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.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.userdetails.UserDetails; + +@NullMarked +public interface PasswordAdvisor { + + PasswordAdvice advise(UserDetails user, @Nullable String password); + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/PasswordLengthAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/PasswordLengthAdvisor.java new file mode 100644 index 00000000000..fae525b1d58 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/PasswordLengthAdvisor.java @@ -0,0 +1,112 @@ +/* + * Copyright 2004-present 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.jspecify.annotations.Nullable; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.util.Assert; + +public final class PasswordLengthAdvisor implements PasswordAdvisor, UpdatePasswordAdvisor { + + /** + * The ASVS v5.0 minimum password length + * @see ASVS + * 5.0 Password Security Standard + */ + private static final int ASVS_V5_MINIMUM_PASSWORD_LENGTH = 8; + + private final int minLength; + + private final int maxLength; + + private PasswordAction passwordAction = PasswordAction.SHOULD_CHANGE; + + public PasswordLengthAdvisor() { + this(ASVS_V5_MINIMUM_PASSWORD_LENGTH); + } + + public PasswordLengthAdvisor(int minLength) { + this(minLength, Integer.MAX_VALUE); + } + + public PasswordLengthAdvisor(int minLength, int maxLength) { + Assert.isTrue(minLength > 0, "minLength must be greater than 0"); + this.minLength = minLength; + this.maxLength = maxLength; + } + + @Override + public PasswordAdvice advise(UserDetails user, @Nullable String password) { + if (password == null) { + return new PasswordLengthAdvice(this.passwordAction, this.minLength, this.maxLength, 0); + } + if (password.length() < this.minLength) { + return new PasswordLengthAdvice(this.passwordAction, this.minLength, this.maxLength, password.length()); + } + if (password.length() > this.maxLength) { + return new PasswordLengthAdvice(this.passwordAction, this.minLength, this.maxLength, password.length()); + } + return SimplePasswordAdvice.NONE; + } + + @Override + public PasswordAdvice advise(UserDetails user, @Nullable String oldPassword, @Nullable String newPassword) { + return advise(user, newPassword); + } + + public void setPasswordAction(PasswordAction passwordAction) { + this.passwordAction = passwordAction; + } + + public static final class PasswordLengthAdvice extends SimplePasswordAdvice { + + private final int minLength; + + private final int maxLength; + + private final int actualLength; + + private PasswordLengthAdvice(PasswordAction action, int minLength, int maxLength, int actualLength) { + super(action); + this.minLength = minLength; + this.maxLength = maxLength; + this.actualLength = actualLength; + } + + public int getMinLength() { + return this.minLength; + } + + public int getMaxLength() { + return this.maxLength; + } + + public int getActualLength() { + return this.actualLength; + } + + @Override + public String toString() { + return "Length [action=" + super.toString() + ", minLength=" + this.minLength + ", maxLength=" + + this.maxLength + ", actualLength=" + this.actualLength + "]"; + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/RepeatedPasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/RepeatedPasswordAdvisor.java new file mode 100644 index 00000000000..a3322ef0398 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/RepeatedPasswordAdvisor.java @@ -0,0 +1,41 @@ +/* + * Copyright 2004-present 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.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.userdetails.UserDetails; + +public final class RepeatedPasswordAdvisor implements UpdatePasswordAdvisor { + + @Override + public PasswordAdvice advise(UserDetails user, @Nullable String oldPassword, @Nullable String newPassword) { + boolean repeated = Objects.equals(oldPassword, newPassword); + return new RepeatedPasswordAdvice(repeated ? PasswordAction.MUST_CHANGE : PasswordAction.NONE); + } + + public static final class RepeatedPasswordAdvice extends SimplePasswordAdvice { + + RepeatedPasswordAdvice(PasswordAction action) { + super(action); + } + + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/SimplePasswordAdvice.java b/core/src/main/java/org/springframework/security/authentication/password/SimplePasswordAdvice.java new file mode 100644 index 00000000000..e32028ed17c --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/SimplePasswordAdvice.java @@ -0,0 +1,42 @@ +/* + * Copyright 2004-present 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.jspecify.annotations.NullMarked; + +@NullMarked +public class SimplePasswordAdvice implements PasswordAdvice { + + public static final PasswordAdvice NONE = new SimplePasswordAdvice(PasswordAction.NONE); + + private final PasswordAction action; + + public SimplePasswordAdvice(PasswordAction action) { + this.action = action; + } + + @Override + public PasswordAction getAction() { + return this.action; + } + + @Override + public String toString() { + return "Simple [action=" + this.action + "]"; + } + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/UpdatePasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/UpdatePasswordAdvisor.java new file mode 100644 index 00000000000..f9a1f46bb85 --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/UpdatePasswordAdvisor.java @@ -0,0 +1,29 @@ +/* + * Copyright 2004-present 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.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import org.springframework.security.core.userdetails.UserDetails; + +@NullMarked +public interface UpdatePasswordAdvisor { + + PasswordAdvice advise(UserDetails user, @Nullable String oldPassword, @Nullable String newPassword); + +} diff --git a/core/src/main/java/org/springframework/security/authentication/password/UserDetailsPasswordAdvisor.java b/core/src/main/java/org/springframework/security/authentication/password/UserDetailsPasswordAdvisor.java new file mode 100644 index 00000000000..d417a510aef --- /dev/null +++ b/core/src/main/java/org/springframework/security/authentication/password/UserDetailsPasswordAdvisor.java @@ -0,0 +1,30 @@ +/* + * Copyright 2004-present 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.jspecify.annotations.Nullable; + +import org.springframework.security.core.userdetails.UserDetails; + +public final class UserDetailsPasswordAdvisor implements PasswordAdvisor { + + @Override + public PasswordAdvice advise(UserDetails user, @Nullable String password) { + return new SimplePasswordAdvice(user.getPasswordAction()); + } + +} diff --git a/core/src/main/java/org/springframework/security/core/userdetails/User.java b/core/src/main/java/org/springframework/security/core/userdetails/User.java index 3b9a871ba0f..cd7ee5f233c 100644 --- a/core/src/main/java/org/springframework/security/core/userdetails/User.java +++ b/core/src/main/java/org/springframework/security/core/userdetails/User.java @@ -32,6 +32,7 @@ import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; +import org.springframework.security.authentication.password.PasswordAction; import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.SpringSecurityCoreVersion; @@ -81,6 +82,8 @@ public class User implements UserDetails, CredentialsContainer { private final boolean enabled; + private final PasswordAction passwordAction; + /** * Calls the more complex constructor with all boolean arguments set to {@code true}. */ @@ -111,6 +114,21 @@ public User(String username, @Nullable String password, boolean enabled, boolean Assert.isTrue(username != null && !"".equals(username), "Cannot pass null or empty values to constructor"); this.username = username; this.password = password; + this.passwordAction = PasswordAction.NONE; + this.enabled = enabled; + this.accountNonExpired = accountNonExpired; + this.credentialsNonExpired = credentialsNonExpired; + this.accountNonLocked = accountNonLocked; + this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities)); + } + + private User(String username, @Nullable String password, PasswordAction passwordAction, boolean enabled, + boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, + Collection authorities) { + Assert.isTrue(username != null && !"".equals(username), "Cannot pass null or empty values to constructor"); + this.username = username; + this.password = password; + this.passwordAction = passwordAction; this.enabled = enabled; this.accountNonExpired = accountNonExpired; this.credentialsNonExpired = credentialsNonExpired; @@ -153,6 +171,11 @@ public boolean isCredentialsNonExpired() { return this.credentialsNonExpired; } + @Override + public PasswordAction getPasswordAction() { + return this.passwordAction; + } + @Override public void eraseCredentials() { this.password = null; @@ -290,6 +313,7 @@ public static UserBuilder withDefaultPasswordEncoder() { public static UserBuilder withUserDetails(UserDetails userDetails) { // @formatter:off UserBuilder result = withUsername(userDetails.getUsername()) + .passwordAction(userDetails.getPasswordAction()) .accountExpired(!userDetails.isAccountNonExpired()) .accountLocked(!userDetails.isAccountNonLocked()) .authorities(userDetails.getAuthorities()) @@ -332,6 +356,8 @@ public static final class UserBuilder { private @Nullable String password; + private PasswordAction passwordAction = PasswordAction.NONE; + private List authorities = new ArrayList<>(); private boolean accountExpired; @@ -373,6 +399,11 @@ public UserBuilder password(@Nullable String password) { return this; } + public UserBuilder passwordAction(PasswordAction passwordAction) { + this.passwordAction = passwordAction; + return this; + } + /** * Encodes the current password (if non-null) and any future passwords supplied to * {@link #password(String)}. @@ -507,7 +538,7 @@ public UserBuilder disabled(boolean disabled) { public UserDetails build() { Assert.notNull(this.username, "username cannot be null"); String encodedPassword = (this.password != null) ? this.passwordEncoder.apply(this.password) : null; - return new User(this.username, encodedPassword, !this.disabled, !this.accountExpired, + return new User(this.username, encodedPassword, this.passwordAction, !this.disabled, !this.accountExpired, !this.credentialsExpired, !this.accountLocked, this.authorities); } diff --git a/core/src/main/java/org/springframework/security/core/userdetails/UserDetails.java b/core/src/main/java/org/springframework/security/core/userdetails/UserDetails.java index a80523f1570..c9dd503c2ed 100644 --- a/core/src/main/java/org/springframework/security/core/userdetails/UserDetails.java +++ b/core/src/main/java/org/springframework/security/core/userdetails/UserDetails.java @@ -21,6 +21,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.security.authentication.password.PasswordAction; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -102,4 +103,8 @@ default boolean isEnabled() { return true; } + default PasswordAction getPasswordAction() { + return PasswordAction.NONE; + } + } diff --git a/core/src/main/java/org/springframework/security/jackson2/UserMixin.java b/core/src/main/java/org/springframework/security/jackson2/UserMixin.java index bef444fd112..c2c58b4f88d 100644 --- a/core/src/main/java/org/springframework/security/jackson2/UserMixin.java +++ b/core/src/main/java/org/springframework/security/jackson2/UserMixin.java @@ -17,10 +17,13 @@ package org.springframework.security.jackson2; import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.springframework.security.authentication.password.PasswordAction; + /** * This mixin class helps in serialize/deserialize * {@link org.springframework.security.core.userdetails.User}. This class also register a @@ -49,4 +52,7 @@ @JsonIgnoreProperties(ignoreUnknown = true) abstract class UserMixin { + @JsonIgnore + abstract PasswordAction getPasswordAction(); + } 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 6380bdd7064..f3a34062f1c 100644 --- a/core/src/main/java/org/springframework/security/provisioning/InMemoryUserDetailsManager.java +++ b/core/src/main/java/org/springframework/security/provisioning/InMemoryUserDetailsManager.java @@ -31,6 +31,7 @@ import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.authentication.password.PasswordAction; import org.springframework.security.core.Authentication; import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.context.SecurityContextHolder; @@ -152,6 +153,7 @@ public void changePassword(String oldPassword, String newPassword) { MutableUserDetails user = this.users.get(username); Assert.state(user != null, "Current user doesn't exist in database."); user.setPassword(newPassword); + user.setPasswordAction(PasswordAction.NONE); } @Override @@ -174,8 +176,7 @@ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx if (user instanceof CredentialsContainer) { return user; } - return new User(user.getUsername(), user.getPassword(), user.isEnabled(), user.isAccountNonExpired(), - user.isCredentialsNonExpired(), user.isAccountNonLocked(), user.getAuthorities()); + return User.withUserDetails(user).build(); } /** diff --git a/core/src/main/java/org/springframework/security/provisioning/MutableUser.java b/core/src/main/java/org/springframework/security/provisioning/MutableUser.java index b8a41836f38..fe6e2abf739 100644 --- a/core/src/main/java/org/springframework/security/provisioning/MutableUser.java +++ b/core/src/main/java/org/springframework/security/provisioning/MutableUser.java @@ -20,6 +20,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.security.authentication.password.PasswordAction; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.SpringSecurityCoreVersion; import org.springframework.security.core.userdetails.UserDetails; @@ -34,11 +35,14 @@ class MutableUser implements MutableUserDetails { private @Nullable String password; + private PasswordAction action; + private final UserDetails delegate; MutableUser(UserDetails user) { this.delegate = user; this.password = user.getPassword(); + this.action = user.getPasswordAction(); } @Override @@ -51,6 +55,16 @@ public void setPassword(@Nullable String password) { this.password = password; } + @Override + public PasswordAction getPasswordAction() { + return this.action; + } + + @Override + public void setPasswordAction(PasswordAction advice) { + this.action = advice; + } + @Override public Collection getAuthorities() { return this.delegate.getAuthorities(); diff --git a/core/src/main/java/org/springframework/security/provisioning/MutableUserDetails.java b/core/src/main/java/org/springframework/security/provisioning/MutableUserDetails.java index cc3854f5ba2..b403ce522ab 100644 --- a/core/src/main/java/org/springframework/security/provisioning/MutableUserDetails.java +++ b/core/src/main/java/org/springframework/security/provisioning/MutableUserDetails.java @@ -18,6 +18,7 @@ import org.jspecify.annotations.Nullable; +import org.springframework.security.authentication.password.PasswordAction; import org.springframework.security.core.userdetails.UserDetails; /** @@ -28,4 +29,6 @@ interface MutableUserDetails extends UserDetails { void setPassword(@Nullable String password); + void setPasswordAction(PasswordAction action); + } diff --git a/core/src/test/java/org/springframework/security/provisioning/InMemoryUserDetailsManagerTests.java b/core/src/test/java/org/springframework/security/provisioning/InMemoryUserDetailsManagerTests.java index 7d311f4c18f..e4ba2594892 100644 --- a/core/src/test/java/org/springframework/security/provisioning/InMemoryUserDetailsManagerTests.java +++ b/core/src/test/java/org/springframework/security/provisioning/InMemoryUserDetailsManagerTests.java @@ -30,6 +30,7 @@ import org.springframework.security.authentication.TestAuthentication; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.authentication.password.PasswordAction; import org.springframework.security.core.Authentication; import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.GrantedAuthority; @@ -207,9 +208,12 @@ static class CustomUser implements MutableUserDetails, CredentialsContainer { private String password; + private PasswordAction action; + CustomUser(UserDetails user) { this.delegate = user; this.password = user.getPassword(); + this.action = user.getPasswordAction(); } @Override @@ -227,6 +231,16 @@ public void setPassword(final String password) { this.password = password; } + @Override + public PasswordAction getPasswordAction() { + return this.action; + } + + @Override + public void setPasswordAction(PasswordAction action) { + this.action = action; + } + @Override public String getUsername() { return this.delegate.getUsername(); diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 65e88a720ea..aa8be7c6fc6 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -48,6 +48,7 @@ ***** xref:servlet/authentication/passwords/password-encoder.adoc[PasswordEncoder] ***** xref:servlet/authentication/passwords/dao-authentication-provider.adoc[DaoAuthenticationProvider] ***** xref:servlet/authentication/passwords/ldap.adoc[LDAP] +***** xref:servlet/authentication/passwords/management.adoc[Password Management] *** xref:servlet/authentication/persistence.adoc[Persistence] *** xref:servlet/authentication/passkeys.adoc[Passkeys] *** xref:servlet/authentication/onetimetoken.adoc[One-Time Token] diff --git a/docs/modules/ROOT/pages/servlet/authentication/passwords/management.adoc b/docs/modules/ROOT/pages/servlet/authentication/passwords/management.adoc new file mode 100644 index 00000000000..9b32cf6a26c --- /dev/null +++ b/docs/modules/ROOT/pages/servlet/authentication/passwords/management.adoc @@ -0,0 +1,213 @@ += Password Management + +Spring Security can offer advice about passwords through its `PasswordAdvisor` API. +It aims to help applications apply https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/docs_en/OWASP_Application_Security_Verification_Standard_5.0.0_en.csv#L108[the ASVS 5.0 standard for Password Security]. + +Consider the following configuration: + +.Java +[source,java,role="primary"] +---- +http + .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated()) + .formLogin(Customizer.withDefaults()) + .passwordManagement(Customizer.withDefaults()) +---- + +By adding the `passwordManagement` DSL, your application now has the ability to suggest or require a user to change their password if certain criteria are met. + +By default, `UserDetailsPasswordAdvisor` is consulted in a `SessionAuthenticationStrategy` after login is complete. +It calls `UserDetails#getPasswordAdvice` to look up any password advice stored on the user object. +If the advice on the user object is `PasswordAdvice.MUST_CHANGE`, then Spring Security will redirect the application to `/change-password` for every request in the application. + +== Configuring a Password Advisor + +There are wo kinds of advisors, `PasswordAdvisor` and `UpdatePasswordAdvisor`. +The first advisor type is for analyzing the password at authentication time. +This is useful should your website's password standards change or should the password become compromised and leaked. +It is also useful when your administrator has marked a certain user as needing to update their password, regardless of any other analysis. + +To take advantage of these advisors, you can publish a `PasswordAdvisor` as a bean: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +PasswordAdvisor passwordAdvisor() { + return CompositePasswordAdvisor.withDefaults( // <1> + new CompromisedPasswordAdvisor() // <2> + ); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun passwordAdvisor(): PasswordAdvisor { + return CompositePasswordAdvisor.withDefaults( // <1> + CompromisedPasswordAdvisor() // <2> + ) +} +---- +====== +<1> - `withDefaults` adds an advisor that checks the `UserDetails` object for any admin-set password action and an advisor that checks password length +<2> - An advisor that checks the HaveIBeenPwned breached password database + +== Requiring Password Changes + +By default password advisors mark a password as `SHOULD_CHANGE`. +This allows you to add this to your application passively. + +In the event you want to start requiring that users change their password, you can configure each password advisor with a policy. +For example, you can state that whenever a password is compromised, force the user to update their password at that time by configuring the `CompromisedPasswordAdvisor` policy as `MUST_CHANGE`: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Bean +PasswordAdvisor passwordAdvisor() { + CompromisedPasswordAdvisor compromised = new CompromisedPasswordAdvisor(); + compromised.setAction(PasswordAction.MUST_CHANGE); + return CompositePasswordAdvisor.withDefaults(compromised); +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Bean +fun passwordAdvisor(): PasswordAdvisor { + val compromised = CompromisedPasswordAdvisor() + compromised.setAction(PasswordAction.MUST_CHANGE) + return CompositePasswordAdvisor.withDefaults(compromise) +} +---- +====== + +[TIP] +==== +While optional, it's helpful to include `UserDetailsPasswordAdvisor` in the set of password advisors as this allows admins to update the `passwordAction` value in `UserDetails` out-of-band and thus require password changes en masse. +This is included by default when calling `withDefaults`. +==== + +== Updating Passwords + +When a user updates their password, the following are recommended: + +1. You require the user provide their old password +2. You require the user provide and confirm their new password +3. You test the password against a set of `UpdatePasswordAdvisor` instances +4. You update both the password and any remaining password action +5. You log out the individual so they can re-login with their new password + +Here is a sample controller that does this: + +[tabs] +====== +Java:: ++ +[source,java,role="primary"] +---- +@Controller +class ChangePasswordController { + private final InMemoryUserDetailsManager users; + + private final PasswordEncoder passwordEncoder = + PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + private final UpdatePasswordAdvisor passwordAdvisor = CompositeUpdatePasswordAdvisor.withDefaults( + new CompromisedPasswordAdvisor(), + new LengthPasswordAdvisor(12) // <1> + ); + + // constructor + + @PostMapping("/change-password") + String changePassword(Passwords passwords, @AuthenticationPrincipal UserDetails user, + HttpServletRequest request, HttpServletResponse response) { + + UserDetails latest = this.users.findUserByUsername(user.getUsername()); + if (!this.passwordEncoder.matches(latest.getPassword(), passwords.current())) { // <2> + request.setAttribute("error", "The provided current password doesn't match your password on file."); + return "change-password"; + } + if (!passwords.change().equals(passwords.confirm())) { // <3> + request.setAttribute("error", "The new password doesn't match its confirmation."); + return "change-password"; + } + PasswordAdvice advice = this.passwordAdvisor.advise(latest, latest.getPassword(), passwords.change()); // <4> + if (PasswordAction.NONE.advisedBy(advice)) { + UserDetails updated = User.withUserDetails(latest) + .passwordEncoder(this.passwordEncoder::encode) + .password(passwords.change()) + .passwordAction(PasswordAction.NONE).build(); // <5> + this.users.updateUser(updated); + return "forward:/logout"; // <6> + } + request.setAttribute(error, "Your password was rejected since " + advice); + return "change-password"; + } +} +---- + +Kotlin:: ++ +[source,kotlin,role="secondary"] +---- +@Controller +open class ChangePasswordController { + private val users: InMemoryUserDetailsManager + + private val passwordEncoder = + PasswordEncoderFactories.createDelegatingPasswordEncoder() + + private val passwordAdvisor = CompositeUpdatePasswordAdvisor.of( + CompromisedPasswordAdvisor(), + LengthPasswordAdvisor(12) // <1> + ) + + // constructor + + @PostMapping("/change-password") + fun changePassword(val passwords: Passwords, @AuthenticationPrincipal val user: UserDetails, + val request: HttpServletRequest): String { + + val latest = this.users.findUserByUsername(user.getUsername()) + if (!this.passwordEncoder.matches(latest.getPassword(), passwords.current())) { // <2> + request.setAttribute("error", "The provided current password doesn't match your password on file.") + return "change-password" + } + if (!passwords.change().equals(passwords.confirm())) { // <3> + request.setAttribute("error", "The new password doesn't match its confirmation.") + return "change-password" + } + val advice = this.passwordAdvisor.advise(latest, latest.getPassword(), passwords.change()) // <4> + if (PasswordAction.NONE.advisedBy(advice)) { + val updated = User.withUserDetails(latest) + .passwordEncoder(this.passwordEncoder::encode) + .password(passwords.change()) + .passwordAction(PasswordAction.NONE).build() // <5> + this.users.updateUser(updated) + return "forward:/logout" // <6> + } + request.setAttribute(error, "Your password was rejected since " + advice) + return "change-password" + } +} +---- +====== +<1> - Override the default `PasswordLengthAdvisor` to require a minimum length of 12 +<2> - Test that the user's current password matches the provided password; note that since credentials are often erased during login, you'll need to look up the user in order to check their password +<3> - Test that the new password and the confirmation fields match +<4> - Test the password against various criteria +<5> - If all the is met, then update the `UserDetails` object to have the new password and no more password advice +<6> - Forward to /logout to get the person logged out diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/CompromisedPasswordAdvisor.java b/web/src/main/java/org/springframework/security/web/authentication/password/CompromisedPasswordAdvisor.java new file mode 100644 index 00000000000..41a24291e77 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/CompromisedPasswordAdvisor.java @@ -0,0 +1,79 @@ +/* + * Copyright 2004-present 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 org.jspecify.annotations.Nullable; + +import org.springframework.security.authentication.password.CompromisedPasswordChecker; +import org.springframework.security.authentication.password.CompromisedPasswordDecision; +import org.springframework.security.authentication.password.PasswordAction; +import org.springframework.security.authentication.password.PasswordAdvice; +import org.springframework.security.authentication.password.PasswordAdvisor; +import org.springframework.security.authentication.password.SimplePasswordAdvice; +import org.springframework.security.authentication.password.UpdatePasswordAdvisor; +import org.springframework.security.core.userdetails.UserDetails; + +public final class CompromisedPasswordAdvisor implements PasswordAdvisor, UpdatePasswordAdvisor { + + private final CompromisedPasswordChecker pwned = new HaveIBeenPwnedRestApiPasswordChecker(); + + private PasswordAction action = PasswordAction.SHOULD_CHANGE; + + @Override + public PasswordAdvice advise(UserDetails user, @Nullable String password) { + if (password == null) { + return SimplePasswordAdvice.NONE; + } + CompromisedPasswordDecision decision = this.pwned.check(password); + if (decision.isCompromised()) { + return new CompromisedPasswordAdvice(this.action, decision); + } + else { + return new CompromisedPasswordAdvice(PasswordAction.NONE, decision); + } + } + + @Override + public PasswordAdvice advise(UserDetails user, String oldPassword, String newPassword) { + return advise(user, newPassword); + } + + public void setAction(PasswordAction action) { + this.action = action; + } + + public static final class CompromisedPasswordAdvice extends SimplePasswordAdvice { + + private final CompromisedPasswordDecision decision; + + public CompromisedPasswordAdvice(PasswordAction action, CompromisedPasswordDecision decision) { + super(action); + this.decision = decision; + } + + public CompromisedPasswordDecision getCompromisedPasswordDecision() { + return this.decision; + } + + @Override + public String toString() { + return "Compromised [" + "action=" + super.toString() + ", decision=" + this.decision + "]"; + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/HttpSessionPasswordAdviceRepository.java b/web/src/main/java/org/springframework/security/web/authentication/password/HttpSessionPasswordAdviceRepository.java new file mode 100644 index 00000000000..c964c1de553 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/HttpSessionPasswordAdviceRepository.java @@ -0,0 +1,83 @@ +/* + * Copyright 2004-present 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.function.Supplier; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.password.PasswordAction; +import org.springframework.security.authentication.password.PasswordAdvice; +import org.springframework.security.authentication.password.SimplePasswordAdvice; +import org.springframework.util.function.SingletonSupplier; + +public final class HttpSessionPasswordAdviceRepository implements PasswordAdviceRepository { + + private static final String PASSWORD_ADVICE_ATTRIBUTE_NAME = HttpSessionPasswordAdviceRepository.class.getName() + + ".PASSWORD_ADVICE"; + + @Override + public PasswordAdvice loadPasswordAdvice(HttpServletRequest request) { + return new DeferredPasswordAdvice(() -> { + PasswordAdvice advice = (PasswordAdvice) request.getSession().getAttribute(PASSWORD_ADVICE_ATTRIBUTE_NAME); + if (advice != null) { + return advice; + } + return SimplePasswordAdvice.NONE; + }); + } + + @Override + public void savePasswordAdvice(HttpServletRequest request, HttpServletResponse response, PasswordAdvice advice) { + if (PasswordAction.NONE.advisedBy(advice)) { + 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 DeferredPasswordAdvice implements PasswordAdvice { + + private final Supplier advice; + + DeferredPasswordAdvice(Supplier advice) { + this.advice = SingletonSupplier.of(advice); + } + + @Override + public PasswordAction getAction() { + return this.advice.get().getAction(); + } + + PasswordAdvice getChangePasswordAdvice() { + return this.advice.get(); + } + + @Override + public String toString() { + return this.advice.get().toString(); + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdviceMethodArgumentResolver.java b/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdviceMethodArgumentResolver.java new file mode 100644 index 00000000000..e3f69b220b8 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdviceMethodArgumentResolver.java @@ -0,0 +1,47 @@ +/* + * Copyright 2004-present 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.PasswordAdvice; +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 PasswordAdviceMethodArgumentResolver implements HandlerMethodArgumentResolver { + + PasswordAdviceRepository passwordAdviceRepository = new HttpSessionPasswordAdviceRepository(); + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return PasswordAdvice.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + return this.passwordAdviceRepository.loadPasswordAdvice(webRequest.getNativeRequest(HttpServletRequest.class)); + } + + public void setPasswordAdviceRepository(PasswordAdviceRepository passwordAdviceRepository) { + this.passwordAdviceRepository = passwordAdviceRepository; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdviceRepository.java b/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdviceRepository.java new file mode 100644 index 00000000000..4587d74b40d --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdviceRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright 2004-present 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.PasswordAdvice; + +public interface PasswordAdviceRepository { + + PasswordAdvice loadPasswordAdvice(HttpServletRequest request); + + void savePasswordAdvice(HttpServletRequest request, HttpServletResponse response, PasswordAdvice advice); + + void removePasswordAdvice(HttpServletRequest request, HttpServletResponse response); + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdviceSessionAuthenticationStrategy.java b/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdviceSessionAuthenticationStrategy.java new file mode 100644 index 00000000000..ed1187a8ea7 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdviceSessionAuthenticationStrategy.java @@ -0,0 +1,60 @@ +/* + * Copyright 2004-present 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.PasswordAdvice; +import org.springframework.security.authentication.password.PasswordAdvisor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.session.SessionAuthenticationException; +import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy; +import org.springframework.util.Assert; + +public final class PasswordAdviceSessionAuthenticationStrategy implements SessionAuthenticationStrategy { + + private PasswordAdviceRepository passwordAdviceRepository = new HttpSessionPasswordAdviceRepository(); + + private PasswordAdvisor passwordAdvisor = new CompromisedPasswordAdvisor(); + + private final String passwordParameter; + + public PasswordAdviceSessionAuthenticationStrategy(String passwordParameter) { + this.passwordParameter = passwordParameter; + } + + @Override + public void onAuthentication(Authentication authentication, HttpServletRequest request, + HttpServletResponse response) throws SessionAuthenticationException { + UserDetails user = (UserDetails) authentication.getPrincipal(); + Assert.notNull(user, "cannot persist password advice since user principal is null"); + String password = request.getParameter(this.passwordParameter); + PasswordAdvice advice = this.passwordAdvisor.advise(user, password); + this.passwordAdviceRepository.savePasswordAdvice(request, response, advice); + } + + public void setPasswordAdviceRepository(PasswordAdviceRepository passwordAdviceRepository) { + this.passwordAdviceRepository = passwordAdviceRepository; + } + + public void setPasswordAdvisor(PasswordAdvisor passwordAdvisor) { + this.passwordAdvisor = passwordAdvisor; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdvisingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdvisingFilter.java new file mode 100644 index 00000000000..b2138add656 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/authentication/password/PasswordAdvisingFilter.java @@ -0,0 +1,83 @@ +/* + * Copyright 2004-present 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.PasswordAction; +import org.springframework.security.authentication.password.PasswordAdvice; +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.NegatedRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import static org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher.pathPattern; + +public class PasswordAdvisingFilter extends OncePerRequestFilter { + + private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); + + private final String changePasswordUrl; + + private RequestCache requestCache = new NullRequestCache(); + + private PasswordAdviceRepository passwordAdviceRepository = new HttpSessionPasswordAdviceRepository(); + + private RequestMatcher requestMatcher; + + public PasswordAdvisingFilter(String changePasswordUrl) { + this.changePasswordUrl = changePasswordUrl; + this.requestMatcher = new NegatedRequestMatcher(pathPattern(changePasswordUrl)); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + if (!this.requestMatcher.matches(request)) { + chain.doFilter(request, response); + return; + } + PasswordAdvice advice = this.passwordAdviceRepository.loadPasswordAdvice(request); + if (!PasswordAction.MUST_CHANGE.advisedBy(advice)) { + chain.doFilter(request, response); + return; + } + this.requestCache.saveRequest(request, response); + this.redirectStrategy.sendRedirect(request, response, this.changePasswordUrl); + } + + public void setPasswordAdviceRepository(PasswordAdviceRepository passwordAdviceRepository) { + this.passwordAdviceRepository = passwordAdviceRepository; + } + + public void setRequestCache(RequestCache requestCache) { + this.requestCache = requestCache; + } + + public void setRequestMatcher(RequestMatcher requestMatcher) { + this.requestMatcher = requestMatcher; + } + +}