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.

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

上一篇: Custom Password Constraint Validator Annotation Example
下一篇: Spring Security User Registration with Hibernate and Thymeleaf
 评论 ( What Do You Think )
名称
邮箱
网址
评论
验证
   
 

 


  • 微信公众号

  • 我的微信

站点声明:

1、一号门博客CMS,由Python, MySQL, Nginx, Wsgi 强力驱动

2、部分文章或者资源来源于互联网, 有时候很难判断是否侵权, 若有侵权, 请联系邮箱:summer@yihaomen.com, 同时欢迎大家注册用户,主动发布无版权争议的 文章/资源.

3、鄂ICP备14001754号-3, 鄂公网安备 42280202422812号