Extending Spring Security Part 2: Change Password
Tom Wetjens
Spring Security provides out of the box a simple form login, logout and JDBC implementation of a users table. However, it does not provide a change password functionality which is needed to make a fully functional webapp with users.
This post is part of a series of posts about extending Spring Security:
The goal is to implement these features fully generic in a way that is completely aligned with Spring Security and the way it allows for customization and extension. And it could be added to the Spring Security project.
It should be secure by default and apply all OWASP security best practices with regard to the above topics.
Examining the SecurityFilterChain
If we look at the way the SecurityFilterChain is configured in Spring Security, usually it looks something like this:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) {
httpSecurity
.passwordManagement(Customizer.withDefaults())
.formLogin(formLogin -> formLogin.loginPage("/login").permitAll())
.logout(logout -> logout.logoutUrl("/logout").logoutSuccessUrl("/"))
.authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated());
return httpSecurity.build();
}
Login and logout are already implemented by Spring Security and are added using these FormLoginConfigurer and LogoutConfigurer classes.
So the FormLoginConfigurer adds a UsernamePasswordAuthenticationFilter to the filter chain that handles the login form submission.
Similarly the LogoutConfigurer adds a LogoutFilter to the filter chain that handles the logout form submission.
It’s up to the developer how to render the login and logout forms.
Setting up the Login Form
In our example, we’ll use Thymeleaf and set up a controller for it:
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login";
}
}
And the Thymeleaf template for the login form:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<main class="form-signin w-100 m-auto">
<form th:action="@{/login}" method="post">
<div th:if="${param.error}" class="alert alert-danger" role="alert">
Invalid username and password.
</div>
<div class="form-floating">
<input class="form-control" id="username" name="username" autofocus="autofocus">
<label for="username">Username</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="password" name="password">
<label for="password">Password</label>
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Log in</button>
<p>New user? <a th:href="@{/signup}">Register</a></p>
</form>
</main>
</body>
</html>
Now we got this working, we can start on the part to change the password.
Creating the filter
The idea is to add a ChangePasswordConfigurer that will be added to the SecurityFilterChain in the same way.
First we create a new filter that will handle the change password form submission:
public class ChangePasswordFilter extends GenericFilterBean {
private final RequestMatcher requestMatcher;
public ChangePasswordFilter(@NonNull String changePasswordFilterUrl) {
requestMatcher = PathPatternRequestMatcher.pathPattern(HttpMethod.POST, "/change-password");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
doFilterHttp((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilterHttp(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (!requestMatcher.matches(request)) {
chain.doFilter(request, response);
return;
}
// TODO
chain.doFilter(request, response);
}
}
It matches form POSTs on /change-password and we will implement the logic for changing the password in the next part.
Adding the filter using a SecurityConfigurer
First, we have to define a SecurityConfigurer that will add the ChangePasswordFilter to the filter chain:
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ChangePasswordConfigurer<H extends HttpSecurityBuilder<H>> extends AbstractHttpConfigurer<ChangePasswordConfigurer<H>, H> {
public static <H extends HttpSecurityBuilder<H>> ChangePasswordConfigurer<H> withDefaults() {
return new ChangePasswordConfigurer<>();
}
@Override
public void configure(H builder) {
builder.addFilterAfter(new ChangePasswordFilter(), CsrfFilter.class);
}
}
And then we can add it to the SecurityFilterChain like this:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) {
httpSecurity
.passwordManagement(Customizer.withDefaults())
.formLogin(formLogin -> formLogin.loginPage("/login").permitAll())
.logout(logout -> logout.logoutUrl("/logout").logoutSuccessUrl("/"))
.with(ChangePasswordConfigurer.withDefaults());
//...
return httpSecurity.build();
}
Implementing the logic
We continue with implementing the logic of the ChangePasswordFilter.
Let’s inject UserDetailsPasswordService into the ChangePasswordFilter;
then we read the new password from the request and change the password using the UserDetailsPasswordService:
private void doFilterHttp(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
//...
var authentication = securityContextHolderStrategy.getContext().getAuthentication();
var newPassword = request.getParameter("new_password");
userDetailsPasswordService.updatePassword((UserDetails) authentication.getPrincipal(), passwordEncoder.encode(newPassword));
chain.doFilter(request, response);
}
Before we update the password, we must encode it using the PasswordEncoder that we can inject too.
When updatePassword is succesful, the user must be logged out and redirected to the login page to log in with the new password.
For this, we inject a LogoutHandler and call it like this and then redirect back to the login page:
logoutHandler.logout(request, response, authentication);
redirectStrategy.sendRedirect(request, response, "/login?password_reset");
The default LogoutHandler implementation is SecurityContextLogoutHandler.
Making it more secure
Now we got the basics done, but we are not OWASP compliant yet!
Because changing the password is a security-sensitive operation, we should authenticate first on the AuthenticationManager using the old password:
var oldPassword = request.getParameter("old_password");
try {
authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(authentication.getName(), oldPassword));
} catch (BadCredentialsException e) {
redirectStrategy.sendRedirect(request, response, "/change-password?invalid_password");
return;
}
Additionally, we should also check the new password against the PasswordPolicy:
if (newPassword == null || !passwordPolicy.check(newPassword)) {
redirectStrategy.sendRedirect(request, response, "/change-password?password_policy");
return;
}
Now we have everthing in place to make the change password fully secure.
Conclusion
In my real project, I have the ChangePasswordConfigurer fully fleshed out to be able to configure all aspects of the process:
.with(ChangePasswordConfigurer.withDefaults(), changePassword -> changePassword
.changePasswordFilterUrl("/change-password")
.changePasswordPage("/change-password")
.passwordEncoder(passwordEncoder)
.passwordPolicy(passwordPolicy)
.userDetailsPasswordService(userDetailsPasswordService)
.changePasswordSuccessHandler(passwordChangedMailer))
This is left as an exercise to the reader.
Continue reading here: Part 3: Forgot Password.