Extending Spring Security Part 1: The Signup
Tom Wetjens
Spring Security provides out of the box a simple form login, logout and JDBC implementation of a users table, but it does not provide:
- User registration
- Change password
- Forgot password
I am building a simple Spring Boot web app where I want users to register, login and have all the usual security features they expect.
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.
Implementing a custom UserDetails object
In Spring Security, a user is represented by an object that implements the UserDetails interface. This can be your own domain object for example, or the User class from Spring Security.
The UserDetailsService interface exists to load a user from memory or database.
And the UserDetailsManager interface exists to also create, update or delete users in memory or database.
Out-of-the-box, Spring Security includes an implementation of these interfaces: JdbcUserDetailsManager, which stores users in a database using JDBC.
However, the standard User class from Spring Security does not include an e-mail address, which we need for forgot password later on.
So we will create our own domain object User which implements UserDetails so it can be used with Spring Security:
@Getter
@ToString
@AllArgsConstructor
@Builder
public class User implements UserDetails {
@NonNull
private final String id;
@NonNull
@Setter
private String username;
@NonNull
@Setter
private String email;
@Nullable
@Setter
private String password;
@Builder.Default
private boolean enabled = true;
@Builder.Default
private final Set<Role> roles = new HashSet<>();
@Override
public @NonNull Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream().map(Role::getAuthority).collect(Collectors.toSet());
}
public static User create(String username, String email) {
var user = User.builder()
.id(UUID.randomUUID().generate())
.username(username)
.email(email)
.enabled(true)
.build();
user.roles.add(Role.USER);
return user;
}
}
We implement the UserDetailsManager interface:
@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsManager, UserDetailsService {
private final UserRepository userRepository;
@Override
public void createUser(@NonNull UserDetails userDetails) {
userRepository.add((User) userDetails);
}
@Override
public void updateUser(UserDetails user) {
userRepository.update((User) user);
}
@Override
public void deleteUser(String username) {
userRepository.deleteByUsername(username);
}
@Override
public void changePassword(String oldPassword, String newPassword) {
throw new UnsupportedOperationException();
}
@Override
public boolean userExists(String username) {
return userRepository.findByUsername(username).isPresent();
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}
I left out the code for the UserRepository, as it is mostly standard Spring Data JPA and jOOQ generated code to execute SQL queries.
The database is responsible for enforcing the uniqueness of usernames and e-mail addresses.
The signup page
Then we can start building our signup page by creating a Thymeleaf template:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<main class="form-signin w-100 m-auto">
<form th:action="@{/signup}" method="post" th:object="${form}" novalidate>
<div th:each="error : ${#fields.globalErrors()}" class="alert alert-danger" role="alert">
<span th:text="${error}">Error</span>
</div>
<div class="form-floating">
<input class="form-control" id="username" name="username"
autofocus="autofocus" th:field="*{username}"
th:errorclass="is-invalid"/>
<div class="invalid-feedback" th:if="${#fields.hasErrors('username')}">
<span th:errors="*{username}"></span>
</div>
<label for="username">Username</label>
</div>
<div class="form-floating">
<input class="form-control" id="email" name="email"
autofocus="autofocus" th:field="*{email}"
th:errorclass="is-invalid"/>
<div class="invalid-feedback" th:if="${#fields.hasErrors('email')}">
<span th:errors="*{email}"></span>
</div>
<label for="email">Email</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="password" name="password"
placeholder="Password" th:value="*{password}"
th:errorclass="is-invalid"/>
<div class="invalid-feedback" th:if="${#fields.hasErrors('password')}">
<span th:errors="*{password}"></span>
</div>
<label for="password">Password</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword"
placeholder="Confirm Password" th:value="*{confirmPassword}"
th:errorclass="is-invalid"/>
<div class="invalid-feedback" th:if="${#fields.hasErrors('confirmPassword')}">
<span th:errors="*{confirmPassword}"></span>
</div>
<label for="confirmPassword">Confirm Password</label>
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Register</button>
</form>
</main>
</body>
</html>
And the backing form object:
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
@Builder
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class SignupForm {
@NotBlank
@Size(min = 2, max = 50)
@Pattern(regexp = "[a-zA-Z0-9_-]+")
String username;
@NotBlank
@Email
String email;
@NotBlank
@Size(min = 8, max = 100)
@PasswordPolicy
String password;
@NotBlank
String confirmPassword;
@AssertTrue
public boolean isPasswordsMatch() {
return password == null || confirmPassword == null || password.equals(confirmPassword);
}
}
We use Jakarta Bean Validation annotations to validate the form, including the password policy.
The signup controller
Then we can create the controller which will use the UserDetailsManager to create the user:
@Controller
@RequestMapping("/signup")
@RequiredArgsConstructor
@Log
public class SignupController {
private final PasswordEncoder passwordEncoder;
private final UserDetailsManager userDetailsManager;
@GetMapping
public String get(Model model) {
model.addAttribute("form", new SignupForm());
return "signup";
}
@PostMapping
public Object post(@Valid @ModelAttribute("form") SignupForm form, BindingResult result, Model model, RedirectAttributes redirectAttributes) {
if (result.hasErrors()) {
log.log(Level.FINE, "Result has errors: {0}", result.getAllErrors());
return new ModelAndView("signup", model.asMap());
}
if (userDetailsManager.userExists(form.getUsername())) {
result.addError(new FieldError("form", "username", form.getUsername(), true,
new String[]{"signup.usernameAlreadyExists"}, null, "Username already exists"));
return new ModelAndView("signup", model.asMap());
}
try {
var user = User.create(form.getUsername(), form.getEmail());
user.setPassword(passwordEncoder.encode(form.getPassword()));
userDetailsManager.createUser(user);
redirectAttributes.addFlashAttribute("messages", List.of("Registered successfully. You can now sign in."));
return new RedirectView("/login");
} catch (Exception e) {
log.log(Level.WARNING, "Could not register user", e);
result.addError(new ObjectError("form", e.getLocalizedMessage()));
return new ModelAndView("signup", model.asMap());
}
}
}
Conclusion
Now we have a fully functional signup page, and we can move on to: Part 2: Change Password.