Spring Security Forgot Password Send Email Reset Password
In this tutorial we demonstrate how to use Spring Security, Spring Boot, Hibernate and Thymeleaf to program a password reset flow by sending the user an email address to verify the reset password procedure. When a user has forgot his password, he is able to request a password reset. The application will generate a unique PasswordResetToken and store it in the database. The user’ll receive an email with the unique token. When he clicks the link, the user is redirected to a page where he can change his password.
At the bottom we wrote some integration tests using spring-test, h2 in-memory database, GreenMail, JUnit and MockMvc to verify the forgot password and reset password procedures.
Project Structure
Let’s start by looking at the project structure.
Maven Dependencies
We use Apache Maven to manage our project dependencies. Make sure the following dependencies reside on the class-path.
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.memorynotfound.spring.security</groupId> <artifactId>reset-password</artifactId> <version>1.0.0-SNAPSHOT</version> <url>https://memorynotfound.com/spring-security-forgot-password-send-email-reset-password</url> <name>Spring Security - ${project.artifactId}</name> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>1.5.8.RELEASE</version> </parent> <properties> <java.version>1.8</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-mail</artifactId> </dependency> <dependency> <groupId>commons-beanutils</groupId> <artifactId>commons-beanutils</artifactId> </dependency> <!-- bootstrap and jquery --> <dependency> <groupId>org.webjars</groupId> <artifactId>bootstrap</artifactId> <version>3.3.7</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>jquery</artifactId> <version>3.2.1</version> </dependency> <!-- mysql connector --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- testing --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.icegreen</groupId> <artifactId>greenmail</artifactId> <version>1.5.5</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
Password Reset Token
We created a PasswordResetToken which is responsible for mapping the unique token to the User. This unique token is created and stored in the database when the users requests a forgot password action. We can retrieve this token again when the user received the email and changes his password.
package com.memorynotfound.spring.security.model; import javax.persistence.*; import java.util.Calendar; import java.util.Date; @Entity public class PasswordResetToken { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; @Column(nullable = false, unique = true) private String token; @OneToOne(targetEntity = User.class, fetch = FetchType.EAGER) @JoinColumn(nullable = false, name = "user_id") private User user; @Column(nullable = false) private Date expiryDate; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } public User getUser() { return user; } public void setUser(User user) { this.user = user; } public Date getExpiryDate() { return expiryDate; } public void setExpiryDate(Date expiryDate) { this.expiryDate = expiryDate; } public void setExpiryDate(int minutes){ Calendar now = Calendar.getInstance(); now.add(Calendar.MINUTE, minutes); this.expiryDate = now.getTime(); } public boolean isExpired() { return new Date().after(this.expiryDate); } }
We created the PasswordResetTokenRepository and extend the spring data JpaRepository this enables CRUD operations to our entity. This class is responsible for storing and retrieving the unique token from the database.
package com.memorynotfound.spring.security.repository; import com.memorynotfound.spring.security.model.PasswordResetToken; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long> { PasswordResetToken findByToken(String token); }
Password Forgot Controller
You can submit a form post to the PasswordForgotController which’ll handle the incoming password forgot request. We use standard hibernate validator annotations on the PasswordForgotDto to validate the incoming request. We create a new unique PasswordResetToken and store it in the database. We forward this token information to the user by email. This email contains a special link to reset his password.
package com.memorynotfound.spring.security.web; import com.memorynotfound.spring.security.model.Mail; import com.memorynotfound.spring.security.model.PasswordResetToken; import com.memorynotfound.spring.security.model.User; import com.memorynotfound.spring.security.repository.PasswordResetTokenRepository; import com.memorynotfound.spring.security.service.EmailService; import com.memorynotfound.spring.security.service.UserService; import com.memorynotfound.spring.security.web.dto.PasswordForgotDto; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import javax.servlet.http.HttpServletRequest; import javax.validation.Valid; import java.util.HashMap; import java.util.Map; import java.util.UUID; @Controller @RequestMapping("/forgot-password") public class PasswordForgotController { @Autowired private UserService userService; @Autowired private PasswordResetTokenRepository tokenRepository; @Autowired private EmailService emailService; @ModelAttribute("forgotPasswordForm") public PasswordForgotDto forgotPasswordDto() { return new PasswordForgotDto(); } @GetMapping public String displayForgotPasswordPage() { return "forgot-password"; } @PostMapping public String processForgotPasswordForm(@ModelAttribute("forgotPasswordForm") @Valid PasswordForgotDto form, BindingResult result, HttpServletRequest request) { if (result.hasErrors()){ return "forgot-password"; } User user = userService.findByEmail(form.getEmail()); if (user == null){ result.rejectValue("email", null, "We could not find an account for that e-mail address."); return "forgot-password"; } PasswordResetToken token = new PasswordResetToken(); token.setToken(UUID.randomUUID().toString()); token.setUser(user); token.setExpiryDate(30); tokenRepository.save(token); Mail mail = new Mail(); mail.setFrom("[email protected]"); mail.setTo(user.getEmail()); mail.setSubject("Password reset request"); Map<String, Object> model = new HashMap<>(); model.put("token", token); model.put("user", user); model.put("signature", "https://memorynotfound.com"); String url = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort(); model.put("resetUrl", url + "/reset-password?token=" + token.getToken()); mail.setModel(model); emailService.sendEmail(mail); return "redirect:/forgot-password?success"; } }
We created the PasswordForgotDto to validate the form submission for correct input parameters using standard hibernate validator annotations.
package com.memorynotfound.spring.security.web.dto; import org.hibernate.validator.constraints.Email; import org.hibernate.validator.constraints.NotEmpty; public class PasswordForgotDto { @Email @NotEmpty private String email; public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } }
Password Reset Email
Next, we take a look at how to send the password reset email. We start by creating a Mail object which holds the meta-data for the email.
package com.memorynotfound.spring.security.model; import java.util.List; import java.util.Map; public class Mail { private String from; private String to; private String subject; private Map<String, Object> model; public Mail() { } public String getFrom() { return from; } public void setFrom(String from) { this.from = from; } public String getTo() { return to; } public void setTo(String to) { this.to = to; } public String getSubject() { return subject; } public void setSubject(String subject) { this.subject = subject; } public Map<String, Object> getModel() { return model; } public void setModel(Map<String, Object> model) { this.model = model; } }
Next, we create an EmailService which is responsible for creating and sending the email. We used an HTML email template and added the email meta-data to the Model.
package com.memorynotfound.spring.security.service; import com.memorynotfound.spring.security.model.Mail; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; import org.thymeleaf.context.Context; import org.thymeleaf.spring4.SpringTemplateEngine; import javax.mail.internet.MimeMessage; import java.nio.charset.StandardCharsets; @Service public class EmailService { @Autowired private JavaMailSender emailSender; @Autowired private SpringTemplateEngine templateEngine; public void sendEmail(Mail mail) { try { MimeMessage message = emailSender.createMimeMessage(); MimeMessageHelper helper = new MimeMessageHelper(message, MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED, StandardCharsets.UTF_8.name()); Context context = new Context(); context.setVariables(mail.getModel()); String html = templateEngine.process("email/email-template", context); helper.setTo(mail.getTo()); helper.setText(html, true); helper.setSubject(mail.getSubject()); helper.setFrom(mail.getFrom()); emailSender.send(message); } catch (Exception e){ throw new RuntimeException(e); } } }
This email email-template.html is located in the src/main/resources/email folder. We can use the values provided in the Model to fill our e-mail template. We include a password reset link with the unique token that the user can use to reset his password.
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns:th="http://www.thymeleaf.org" xmlns="http://www.w3.org/1999/xhtml"> <head> <title>Sending Email with Thymeleaf HTML Template Example</title> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <link href='http://fonts.googleapis.com/css?family=Roboto' rel='stylesheet' type='text/css'/> <!-- use the font --> <style> body { font-family: 'Roboto', sans-serif; font-size: 48px; } </style> </head> <body style="margin: 0; padding: 0;"> <table align="center" border="0" cellpadding="0" cellspacing="0" width="600" style="border-collapse: collapse;"> <tr> <td align="center" bgcolor="#78ab46" style="padding: 40px 0 30px 0;"> <p>Memorynotfound.com</p> </td> </tr> <tr> <td bgcolor="#eaeaea" style="padding: 40px 30px 40px 30px;"> <p th:text="${'Dear ' + user.firstName + ' ' + user.lastName}"></p> <p> You've requested a password reset. <a th:href="${resetUrl}">reset your password</a> </p> <p>Thanks</p> </td> </tr> <tr> <td bgcolor="#777777" style="padding: 30px 30px 30px 30px;"> <p th:text="${signature}"></p> </td> </tr> </table> </body> </html>
Password Reset Controller
When the user has received his password reset email. He is forwarded to the PasswordResetController mapped to the /reset-password URL. This produces a HTTP GET with the token as request parameter. We read the token and if the token is present and valid we put it in the Model map. When the user posts his PasswordResetDto, the form is validated and executed if no errors occur.
package com.memorynotfound.spring.security.web; import com.memorynotfound.spring.security.model.PasswordResetToken; import com.memorynotfound.spring.security.model.User; import com.memorynotfound.spring.security.repository.PasswordResetTokenRepository; import com.memorynotfound.spring.security.service.UserService; import com.memorynotfound.spring.security.web.dto.PasswordResetDto; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Controller; import org.springframework.transaction.annotation.Transactional; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import javax.validation.Valid; @Controller @RequestMapping("/reset-password") public class PasswordResetController { @Autowired private UserService userService; @Autowired private PasswordResetTokenRepository tokenRepository; @Autowired private BCryptPasswordEncoder passwordEncoder; @ModelAttribute("passwordResetForm") public PasswordResetDto passwordReset() { return new PasswordResetDto(); } @GetMapping public String displayResetPasswordPage(@RequestParam(required = false) String token, Model model) { PasswordResetToken resetToken = tokenRepository.findByToken(token); if (resetToken == null){ model.addAttribute("error", "Could not find password reset token."); } else if (resetToken.isExpired()){ model.addAttribute("error", "Token has expired, please request a new password reset."); } else { model.addAttribute("token", resetToken.getToken()); } return "reset-password"; } @PostMapping @Transactional public String handlePasswordReset(@ModelAttribute("passwordResetForm") @Valid PasswordResetDto form, BindingResult result, RedirectAttributes redirectAttributes) { if (result.hasErrors()){ redirectAttributes.addFlashAttribute(BindingResult.class.getName() + ".passwordResetForm", result); redirectAttributes.addFlashAttribute("passwordResetForm", form); return "redirect:/reset-password?token=" + form.getToken(); } PasswordResetToken token = tokenRepository.findByToken(form.getToken()); User user = token.getUser(); String updatedPassword = passwordEncoder.encode(form.getPassword()); userService.updatePassword(updatedPassword, user.getId()); tokenRepository.delete(token); return "redirect:/login?resetSuccess"; } }
The PasswordResetDto is used to validate the incoming form parameters using standard hibernate validator annotations. We emitted the @FieldMatch annotations in this tutorial for simplicity. But you can read more about it in the Spring Security User Registration Example ” rel=”dofollow”>user registration example.
package com.memorynotfound.spring.security.web.dto; import com.memorynotfound.spring.security.constraint.FieldMatch; import org.hibernate.validator.constraints.NotEmpty; @FieldMatch(first = "password", second = "confirmPassword", message = "The password fields must match") public class PasswordResetDto { @NotEmpty private String password; @NotEmpty private String confirmPassword; @NotEmpty private String token; public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getConfirmPassword() { return confirmPassword; } public void setConfirmPassword(String confirmPassword) { this.confirmPassword = confirmPassword; } public String getToken() { return token; } public void setToken(String token) { this.token = token; } }
Spring Boot + Hibernate + Email Configuration
We configure Hibernate, JPA and Mail using the application.yml file located in the src/main/resources folder.
# =============================== # = Hibernate datasource # =============================== spring: datasource: url: jdbc:mysql://localhost:3306/spring_security_hibernate username: root password: # =============================== # = JPA configurations # =============================== jpa: show-sql: true hibernate: ddl-auto: create-drop database-platform: MYSQL properties: hibernate.dialect: org.hibernate.dialect.MySQL5Dialect # =============================== # = MAIL configurations # =============================== mail: default-encoding: UTF-8 host: smtp.gmail.com username: [email protected] password: secret port: 587 properties: mail: smtp: auth: true starttls: enable: true protocol: smtp test-connection: false # =============================== # = Logging configurations # =============================== logging: level: root: WARN com.memorynotfound: DEBUG org.springframework.web: INFO org.springframework.security: INFO
Spring Security Configuration
This is the spring security configuration. Make sure to permit all access to the /forgot-password and /reset-password URI’s.
package com.memorynotfound.spring.security.config; import com.memorynotfound.spring.security.service.UserService; import com.memorynotfound.spring.security.service.UserServiceImpl; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; @Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private UserService userService; @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers( "/registration**", "/forgot-password**", "/reset-password**").permitAll() .antMatchers( "/js/**", "/css/**", "/img/**", "/webjars/**").permitAll() .anyRequest().authenticated() .and() .formLogin() .loginPage("/login") .permitAll() .and() .logout() .invalidateHttpSession(true) .clearAuthentication(true) .logoutRequestMatcher(new AntPathRequestMatcher("/logout")) .logoutSuccessUrl("/login?logout") .permitAll(); } @Bean public BCryptPasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Bean public DaoAuthenticationProvider authenticationProvider(){ DaoAuthenticationProvider auth = new DaoAuthenticationProvider(); auth.setUserDetailsService(userService); auth.setPasswordEncoder(passwordEncoder()); return auth; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(authenticationProvider()); } }
User Management
We created a custom User object and mapped it to the database using standard java persistence annotations.
package com.memorynotfound.spring.security.model; import javax.persistence.*; import java.util.Collection; @Entity @Table(uniqueConstraints = @UniqueConstraint(columnNames = "email")) public class User { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String firstName; private String lastName; private String email; private String password; @ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) @JoinTable( name = "users_roles", joinColumns = @JoinColumn( name = "user_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn( name = "role_id", referencedColumnName = "id")) private Collection<Role> roles; public User() { } public User(String firstName, String lastName, String email, String password) { this.firstName = firstName; this.lastName = lastName; this.email = email; this.password = password; } public User(String firstName, String lastName, String email, String password, Collection<Role> roles) { this.firstName = firstName; this.lastName = lastName; this.email = email; this.password = password; this.roles = roles; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public Collection<Role> getRoles() { return roles; } public void setRoles(Collection<Role> roles) { this.roles = roles; } @Override public String toString() { return "User{" + "id=" + id + ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + ", email='" + email + '\'' + ", password='" + "*********" + '\'' + ", roles=" + roles + '}'; } }
We created a custom Role object and mapped it to the database using standard java persistence annotations.
package com.memorynotfound.spring.security.model; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; @Entity public class Role { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; public Role() { } public Role(String name) { this.name = name; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public String toString() { return "Role{" + "id=" + id + ", name='" + name + '\'' + '}'; } }
The UserService is used to manage the users.
package com.memorynotfound.spring.security.service; import com.memorynotfound.spring.security.model.User; import com.memorynotfound.spring.security.web.dto.UserRegistrationDto; import org.springframework.security.core.userdetails.UserDetailsService; public interface UserService extends UserDetailsService { User findByEmail(String email); User save(UserRegistrationDto registration); void updatePassword(String password, Long userId); }
Here is the implementation of the UserService.
package com.memorynotfound.spring.security.service; import com.memorynotfound.spring.security.model.Role; import com.memorynotfound.spring.security.model.User; import com.memorynotfound.spring.security.repository.UserRepository; import com.memorynotfound.spring.security.web.dto.UserRegistrationDto; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import java.util.Arrays; import java.util.Collection; import java.util.stream.Collectors; @Service public class UserServiceImpl implements UserService { @Autowired private UserRepository userRepository; @Autowired private BCryptPasswordEncoder passwordEncoder; public User findByEmail(String email){ return userRepository.findByEmail(email); } public User save(UserRegistrationDto registration){ User user = new User(); user.setFirstName(registration.getFirstName()); user.setLastName(registration.getLastName()); user.setEmail(registration.getEmail()); user.setPassword(passwordEncoder.encode(registration.getPassword())); user.setRoles(Arrays.asList(new Role("ROLE_USER"))); return userRepository.save(user); } @Override public void updatePassword(String password, Long userId) { userRepository.updatePassword(password, userId); } @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { User user = userRepository.findByEmail(email); if (user == null){ throw new UsernameNotFoundException("Invalid username or password."); } return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), mapRolesToAuthorities(user.getRoles())); } private Collection<? extends GrantedAuthority> mapRolesToAuthorities(Collection<Role> roles){ return roles.stream() .map(role -> new SimpleGrantedAuthority(role.getName())) .collect(Collectors.toList()); } }
The UserRepository is responsible for managing the User object database state. We created a special updatePassword method which updates the password for a particular user.
package com.memorynotfound.spring.security.repository; import com.memorynotfound.spring.security.model.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface UserRepository extends JpaRepository<User, Long> { User findByEmail(String email); @Modifying @Query("update User u set u.password = :password where u.id = :id") void updatePassword(@Param("password") String password, @Param("id") Long id); }
Spring Boot
We start the application using Spring Boot.
package com.memorynotfound.spring.security; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Run { public static void main(String[] args) { SpringApplication.run(Run.class, args); } }
Thymeleaf Templates
We used Bootstrap and JQuery to create our Thymeleaf templates. The templates are located in the src/main/resources/templates/ folder.
Forgot Password Page
The forgot-password.html page is responsible for requesting the password reset email.
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="utf-8"/> <meta http-equiv="X-UA-Compatible" content="IE=edge"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <link rel="stylesheet" type="text/css" th:href="@{/webjars/bootstrap/3.3.7/css/bootstrap.min.css}"/> <link rel="stylesheet" type="text/css" th:href="@{/css/main.css}"/> <title>Forgot Password</title> </head> <body> <div class="container"> <div class="row"> <div class="col-md-4 col-md-offset-4"> <div class="panel panel-default"> <div class="panel-body"> <div class="text-center"> <h3><i class="glyphicon glyphicon-lock" style="font-size:2em;"></i></h3> <h2 class="text-center">Forgot Password?</h2> <p>Enter your e-mail address and we'll send you a link to reset your password.</p> <div class="panel-body"> <div th:if="${param.success}"> <div class="alert alert-info"> You've successfully requested a new password reset! </div> </div> <form th:action="@{/forgot-password}" th:object="${forgotPasswordForm}" method="post"> <div class="form-group" th:classappend="${#fields.hasErrors('email')}? 'has-error':''"> <div class="input-group"> <span class="input-group-addon"> <i class="glyphicon glyphicon-envelope color-blue"></i> </span> <input id="email" class="form-control" placeholder="email address" th:field="*{email}"/> </div> <p class="error-message" th:each="error : ${#fields.errors('email')}" th:text="${error}">Validation error</p> </div> <div class="form-group"> <button type="submit" class="btn btn-success btn-block">Reset Password</button> </div> </form> </div> </div> </div> </div> <div class="row"> <div class="col-md-12"> New user? <a href="/" th:href="@{/registration}">Register</a> </div> <div class="col-md-12"> Already registered? <a href="/" th:href="@{/login}">Login</a> </div> </div> </div> </div> </div> <script type="text/javascript" th:src="@{/webjars/jquery/3.2.1/jquery.min.js/}"></script> <script type="text/javascript" th:src="@{/webjars/bootstrap/3.3.7/js/bootstrap.min.js}"></script> </body> </html>
Reset Password Page
The reset-password.html page is responsible for requesting the actual password reset.
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="utf-8"/> <meta http-equiv="X-UA-Compatible" content="IE=edge"/> <meta name="viewport" content="width=device-width, initial-scale=1"/> <link rel="stylesheet" type="text/css" th:href="@{/webjars/bootstrap/3.3.7/css/bootstrap.min.css}"/> <link rel="stylesheet" type="text/css" th:href="@{/css/main.css}"/> <title>Forgot Password</title> </head> <body> <div class="container"> <div class="row"> <div class="col-md-4 col-md-offset-4"> <div class="panel panel-default"> <div class="panel-body"> <div class="text-center"> <h3><i class="glyphicon glyphicon-lock" style="font-size:2em;"></i></h3> <h2 class="text-center">Reset password</h2> <div class="panel-body"> <div th:if="${error}"> <div class="alert alert-danger"> <span th:text="${error}"></span> </div> </div> <form th:action="@{/reset-password}" th:object="${passwordResetForm}" method="post"> <p class="error-message" th:if="${#fields.hasGlobalErrors()}" th:each="error : ${#fields.errors('global')}" th:text="${error}">Validation error</p> <input type="hidden" name="token" th:value="${token}"/> <div class="form-group" th:classappend="${#fields.hasErrors('password')}? 'has-error':''"> <div class="input-group"> <span class="input-group-addon"> <i class="glyphicon glyphicon-lock"></i> </span> <input id="password" class="form-control" placeholder="password" type="password" th:field="*{password}"/> </div> <p class="error-message" th:each="error: ${#fields.errors('password')}" th:text="${error}">Validation error</p> </div> <div class="form-group" th:classappend="${#fields.hasErrors('confirmPassword')}? 'has-error':''"> <div class="input-group"> <span class="input-group-addon"> <i class="glyphicon glyphicon-lock"></i> </span> <input id="confirmPassword" class="form-control" placeholder="Confirm password" type="password" th:field="*{confirmPassword}"/> </div> <p class="error-message" th:each="error: ${#fields.errors('confirmPassword')}" th:text="${error}">Validation error</p> </div> <div class="form-group"> <button type="submit" class="btn btn-block btn-success">Reset password</button> </div> </form> </div> </div> </div> </div> <div class="row"> <div class="col-md-12"> New user? <a href="/" th:href="@{/registration}">Register</a> </div> <div class="col-md-12"> Already registered? <a href="/" th:href="@{/login}">Login</a> </div> </div> </div> </div> </div> <script type="text/javascript" th:src="@{/webjars/jquery/3.2.1/jquery.min.js/}"></script> <script type="text/javascript" th:src="@{/webjars/bootstrap/3.3.7/js/bootstrap.min.js}"></script> </body> </html>
Demo
Access the http://localhost:8080/forgot-password and provide an invalid email.
Access the http://localhost:8080/forgot-password and provide a valid email.
This is an example email which the user receives upon requesting a password reset. Note that the email contains a unique link.
When the user clicks on the link inside the email, he is forwarded to the page where he can reset his password. When the user enters a password, but they do not match he receives the following output.
Integration Testing
Let’s write some integrations tests using H2, JUnit, spring-test, GreenMail and MockMvc.
Spring Integration Test Configuration
We configure the integration tests using the application.yml file located in the src/test/resources/ folder. This overrides the application configuration file.
# =============================== # = H2 data source # =============================== spring: datasource: url: jdbc:h2:mem:spring-security-hibernate-test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE platform: h2 username: sa password: # =============================== # = JPA configurations # =============================== jpa: show-sql: true hibernate: ddl-auto: create-drop naming-strategy: org.hibernate.cfg.ImprovedNamingStrategy database-platform: H2 # =============================== # = MAIL configurations # =============================== mail: default-encoding: UTF-8 host: localhost jndi-name: username: username password: secret port: 2525 properties: mail: debug: false smtp: debug: false auth: true starttls: true protocol: smtp test-connection: false
We can initialize the H2 In Memory Database using the data.sql file, located in the src/test/resources folder.
INSERT INTO user (id, first_name, last_name, email, password) VALUES (1, 'Memory', 'Not Found', '[email protected]', '$2a$10$RyY4bXtV3LKkDCutlUTYDOKd2AiJYZGp4Y7MPVdLzWzT1RX.JRZyG'); INSERT INTO user (id, first_name, last_name, email, password) VALUES (2, 'Memory', 'Not Found', '[email protected]', '$2a$10$RyY4bXtV3LKkDCutlUTYDOKd2AiJYZGp4Y7MPVdLzWzT1RX.JRZyG'); INSERT INTO role (id, name) VALUES (1, 'ROLE_ADMIN'); INSERT INTO role (id, name) VALUES (2, 'ROLE_MANAGER'); INSERT INTO role (id, name) VALUES (3, 'ROLE_USER'); INSERT INTO users_roles (user_id, role_id) VALUES (1, 1); INSERT INTO users_roles (user_id, role_id) VALUES (1, 2); INSERT INTO users_roles (user_id, role_id) VALUES (2, 1); INSERT INTO users_roles (user_id, role_id) VALUES (2, 2); INSERT INTO password_reset_token (id, expiry_date, token, user_id) VALUES (1, '2017-01-01 00:00:00', 'expired-token', 1); INSERT INTO password_reset_token (id, expiry_date, token, user_id) VALUES (2, '2222-01-01 00:00:00', 'valid-token', 2);
Intercept incoming emails with greenmail
We wrote a custom SmtpServerRule which uses GreenMail to intercept emails sent.
package com.memorynotfound.spring.security.test; import com.icegreen.greenmail.util.GreenMail; import com.icegreen.greenmail.util.ServerSetup; import org.junit.rules.ExternalResource; import javax.mail.internet.MimeMessage; public class SmtpServerRule extends ExternalResource { private GreenMail smtpServer; private int port; public SmtpServerRule(int port) { this.port = port; } @Override protected void before() throws Throwable { super.before(); smtpServer = new GreenMail(new ServerSetup(port, null, "smtp")); smtpServer.start(); } public MimeMessage[] getMessages() { return smtpServer.getReceivedMessages(); } @Override protected void after() { super.after(); smtpServer.stop(); } }
Password Forgot Integration Test
This integration test validates the forgot password procedures.
package com.memorynotfound.spring.security.test; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import javax.mail.internet.MimeMessage; import static org.junit.Assert.assertEquals; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest @AutoConfigureMockMvc @RunWith(SpringJUnit4ClassRunner.class) public class PasswordForgotIT { @Autowired private MockMvc mockMvc; @Rule public SmtpServerRule smtpServerRule = new SmtpServerRule(2525); @Test public void submitPasswordForgotSuccess() throws Exception { this.mockMvc .perform( post("/forgot-password") .with(csrf()) .param("email", "[email protected]") ) .andExpect(model().hasNoErrors()) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl("/forgot-password?success")); MimeMessage[] receivedMessages = smtpServerRule.getMessages(); assertEquals(1, receivedMessages.length); MimeMessage current = receivedMessages[0]; assertEquals("[email protected]", current.getAllRecipients()[0].toString()); } @Test public void submitPasswordForgotInvalidEmail() throws Exception { this.mockMvc .perform( post("/forgot-password") .with(csrf()) .param("email", "[email protected]") ) .andExpect(model().hasErrors()) .andExpect(model().attributeHasFieldErrors("forgotPasswordForm", "email")) .andExpect(status().isOk()); } }
Password Reset Integration Test
This integration test validates the password reset procedures.
package com.memorynotfound.spring.security.test; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.validation.BindingResult; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; 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.*; @SpringBootTest @AutoConfigureMockMvc @RunWith(SpringJUnit4ClassRunner.class) public class PasswordResetIT { @Autowired private MockMvc mockMvc; @Test public void accessPasswordResetWithoutToken() throws Exception { this.mockMvc .perform( get("/reset-password") ) .andExpect(model().attributeExists("error")) .andExpect(status().isOk()); } @Test public void accessPasswordResetWithInvalidToken() throws Exception { this.mockMvc .perform( get("/reset-password?token=invalid-token") ) .andExpect(model().attributeExists("error")) .andExpect(status().isOk()); } @Test public void accessPasswordResetWithExpiredToken() throws Exception { this.mockMvc .perform( get("/reset-password?token=expired-token") ) .andExpect(model().attributeExists("error")) .andExpect(status().isOk()); } @Test public void submitPasswordResetSuccess() throws Exception { this.mockMvc .perform( post("/reset-password") .with(csrf()) .param("password", "password") .param("confirmPassword", "password") .param("token", "valid-token") ) .andExpect(model().hasNoErrors()) .andExpect(redirectedUrl("/login?resetSuccess")) .andExpect(status().is3xxRedirection()); } @Test public void submitPasswordResetPasswordDoNotMatch() throws Exception { this.mockMvc .perform( post("/reset-password") .with(csrf()) .param("password", "password") .param("confirmPassword", "invalid-password") .param("token", "valid-token") ) .andExpect(flash().attributeExists(BindingResult.class.getName() + ".passwordResetForm")) .andExpect(redirectedUrl("/reset-password?token=valid-token")) .andExpect(status().is3xxRedirection()); } }
Integration Test Results
When you run the tests, you receive the following output.
Download
From:一号门
COMMENTS