Fms-Java版开发实录:12 密码重置功能 - 随机地址生成与发送邮件

Fms-Java版开发实录:12 密码重置功能 - 随机地址生成与发送邮件

密码重置功能的核心工作,生成唯一路径然后发送邮件

继续我们的密码验证工作之旅。上次编写到用户名和密码验证成功之后,需要来生成让用户从外界访问的地址用于修改密码,这也是密码重置的核心功能。这次就来继续编写。

PO类编写

很显然,我们的逻辑需要首先生成一段字符串,这个就使用UUID来生成,去掉其中的-符号,就是长串字符串。之后需要把这个字符串,对应的用户,以及生成的时间写入到数据库中,以备将来使用。所以需要先编写一个PO类:

package cc.conyli.fms.dataobject.entity;

import org.hibernate.validator.constraints.Length;

import javax.persistence.*;
import javax.validation.constraints.NotEmpty;
import java.util.Date;
import java.util.Objects;

@Entity
@Table(name = "password_reset_path")
public class PasswordResetPath {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "id")
    private Integer id;

    @NotEmpty(message = "PATH不能为空")
    @Length(max = 32, message = "PATH最长为32个字符")
    @Column(name = "path", nullable = false, unique = true, length = 32)
    private String path;

    @Temporal(TemporalType.TIMESTAMP)
    @Column(name = "expiration", nullable = false, insertable = false, updatable = false)
    @org.hibernate.annotations.Generated(GenerationTime.INSERT)
    private Date expiration;

    @ManyToOne(optional = false, fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    public String getPath() {
        return path;
    }

    public Date getExpiration() {
        return expiration;
    }

    public String getUsername() {
        return this.user.getUsername();
    }

    ......
}

这个类的核心是三个属性,一个是32个字符长的特殊地址,将来使用UUID生成,一个是过期时间,这个时间由于就是用户选择重置密码的时间,因此就让Hibernate在每次插入新数据的时候自动写入,还有一个是关联到用户的外键,用于获取是哪个用户来修改密码,这都是非常关键的数据。

PO类编写好之后,需要编写业务类。

业务类和DAO类编写

业务类是非常关键的,我这里就逐渐要把重型操作都要移动到业务层来,控制器只负责传递尽量简单的经过验证的数据。

DAO类编写

DAO类的关键是要通过PATH来判断是否存在这个路径,所以在之前的PO中给这列加上了unique,所以就会创建一个索引,编写如下:

package cc.conyli.fms.dao;

import cc.conyli.fms.dataobject.entity.PasswordResetPath;
import org.springframework.data.repository.CrudRepository;

public interface PasswordResetDao extends CrudRepository<PasswordResetPath, Integer> {
    PasswordResetPath findByPath(String path);

    boolean existsByPath(String path);
}

业务类PasswordResetService的功能设计

至于业务类,因为和用户业务类关系不是很大,所以我们来单独编写一个PasswordResetService业务类,这个业务类需要实现如下功能:

  1. 取出一条path记录,这个是基本功能
  2. 判断一条path是否有效,有效的标准是path存在于数据库中,以及还在有效时间内
  3. 删除一个用户对应的所有记录,这个功能用于用户成功修改密码
  4. 保存一个新path对象,这其中要自动生成过期时间

这个业务类很关键,一步一步来编写

取出path对象

这个业务类需要注入不少东西,取出记录的编写比较简单,先编写如下:

@Service
public class PasswordResetService {

    private final PasswordResetDao passwordResetDao;

    private final UserDao userDao;

    private final EmailProperties emailProperties;

    @Autowired
    public PasswordResetService(PasswordResetDao passwordResetDao, UserDao userDao, EmailProperties emailProperties) {
        this.passwordResetDao = passwordResetDao;
        this.userDao = userDao;
        this.emailProperties = emailProperties;
    }

    @Transactional
    public PasswordResetPath getPathByPath(String path) {
        return passwordResetDao.findByPath(path);
    }

    public static String generatePath() {
        String uuidString = UUID.randomUUID().toString();
        String temp = uuidString.substring(0, 8) + uuidString.substring(9, 13) + uuidString.substring(14, 18) + uuidString.substring(19, 23) + uuidString.substring(24);
        System.out.println("UUID is: " + temp);
        return temp;
    }

}

这个方法注入了EmailProperties用于获取配置,很显然在发送邮件的那个业务层中也需要注入一个。之后编写了一个静态方法用于生成路径。

判断path是否有效

这里很显然要先获取,如果不存在记录,肯定无效。如果成功取出之后,还要判断有效时间是否大于当前的时间,否则依然无效:

    @Transactional
    public boolean isValid(String path) {
        return (passwordResetDao.existsByPath(path)) 
                && 
                (passwordResetDao.findByPath(path).getExpiration().after(new Date()));
    }

删除过期记录的方法

删除过期记录这个功能实际上有两种,一种是删除数据库中所有过期的记录,另一个是在某个用户重置密码之后,需要删除这个用户对应的全部path记录。需要在PasswordResetDao接口中新添加两个方法,一个用来查找所有过期记录,一个用来查找某个用户的所有记录:

    List<PasswordResetPath> findAllByExpirationBefore(Date date);

    List<PasswordResetPath> findAllByUser(User user);

这两个方法也是用JPA生成的,非常方便。
查找全部的过期记录或者某个用户的记录之后,再调用接口提供的deleteAll方法删除所有内容。

    @Transactional
    public void deleteAllExpiredPath() {
        passwordResetDao.deleteAll(
                passwordResetDao.findAllByExpirationBefore(new Date()));
    }

    @Transactional
    public void deleteAllExpiredPathByUser(String username) {
        passwordResetDao.deleteAll(
                passwordResetDao.findAllByUser(
                        userDao.findByUsername(username))
        );
    }

保存path的方法

最后是保存path的方法,这个方法中需要自动生成从配置属性中读入的间隔,然后写入数据库:

    @Transactional
    public PasswordResetPath savePath(String username) {
        //获取用户名,因为控制器端已经校验过用户,肯定存在
        User user = userDao.findByUsername(username);
        //获取过期小时数,然后组装6个小时之后的包含时区的信息
        ZoneId zoneId = ZoneId.systemDefault();
        ZonedDateTime expireTime = LocalDateTime
                .now()
                .plusHours(Long.parseLong(emailProperties.getInvalidInterval()))
                .atZone(zoneId);
        //最后转换成Date对象保存至数据库
        PasswordResetPath resetPath = new PasswordResetPath(generatePath(), user, Date.from(expireTime.toInstant()));
        return passwordResetDao.save(resetPath);
    }

补完控制器与发送邮件功能

现在我们编写好了完整的业务层的支持,就可以继续来编写控制器。
在用户的用户名和电子邮件验证相符后,我们的逻辑是

  1. 删除所有的过期记录
  2. 为当前用户生成一条记录插入数据库
  3. 发送电子邮件

下边来一点一点补完

删除过期记录与保存用户记录

根据上边的分析,编写如下:

if (userService.validateUsernameAndEmail(username, email)) {

    //删除所有过期记录
    passwordResetService.deleteAllExpiredPath();

    // 为当前用户生成一条path保存进数据库
    PasswordResetPath resetPath = passwordResetService.savePath(username);
    
    // 发送邮件的功能

发送电子邮件 - 模板

刚才编写的邮件功能是非常基本的,现在我们要重新改造一下EmailSendingService类。首先删除不需要的sendSimpleMessage方法,注入EmailProperties。最关键的是要准备一个HTML模板,就得了解一下HTML电子邮件的规矩:

这里我找到了阮一峰HTML Email 编写指南的内容,发现原来电子邮件的HTML格式给人比较原始的感觉。我自己先在类中写了一个简单的模板:

private static final String template = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\"\n" +
            "        \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n" +
            "<html lang=\"zh\" xmlns=\"http://www.w3.org/1999/xhtml\">\n" +
            "<head>\n" +
            "    <meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\"/>\n" +
            "      <title>密码重置 - FMS</title>\n" +
            "    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>\n" +
            "</head>\n" +
            "\n" +
            "<body style=\"margin: 0; padding: 0;\">\n" +
            "<p style=\"font-size: 2rem;padding-left: 10px\">亲爱的 {{username}}:</p>\n" +
            "<p style=\"padding-left: 10px\">你正在FMS上申请密码重置。如果密码重置不是由您发起,请忽略此封邮件。</p>\n" +
            "<p style=\"padding-left: 10px\">请点击<a href=\"{{link}}\">密码重置链接</a>来完成您的密码重置工作。</p>\n" +
            "<p style=\"padding-left: 10px\">链接将在{{interval}}小时后失效,失效后您需要重新进行密码重置。</p>\n" +
            "<p style=\"padding-left: 10px\">如果无法点击链接,请将以下链接复制到浏览器地址栏打开:</p>\n" +
            "<p style=\"padding-left: 10px;font-size: 1.5rem;color: #1EAEDB\">{{link}}</p>\n" +
            "</body>\n" +
            "</html>";

这其中我打算把usernamelinkinterval替换成方法中的参数。

发送电子邮件 - 功能

发送电子邮件就是先把上边的模板中的字符串都替换掉,再发送即可:

@Transactional
public boolean sendMimeMessage(String username, String email, String path) {
    MimeMessage mimeMessage = emailSender.createMimeMessage();
    String message = template
            .replace("{{username}}", username)
            .replace("{{link}}", emailProperties.getPath() + path)
            .replace("{{interval}}", emailProperties.getInvalidInterval());
    try {
        MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true);
        helper.setTo(email);
        helper.setFrom(emailProperties.getFrom());
        helper.setSubject("用户密码重置");
        helper.setText(message, true);
        emailSender.send(mimeMessage);
        return true;
    } catch (Exception e) {
        return false;
    }
}

补完控制器 - 发送邮件功能

终于到了这篇文章的结束了,在控制器中根据用户名,电子邮件地址以及经过保存后生成的路径,传入业务层来进行发送邮件,根据发送的结果渲染不同的页面:

if (userService.validateUsernameAndEmail(username, email)) {

    //删除所有过期记录
    passwordResetService.deleteAllExpiredPath();

    // 为当前用户生成一条path保存进数据库
    PasswordResetPath resetPath = passwordResetService.savePath(username);

    // 发送邮件
    if (emailSendingService.sendMimeMessage(username, email, resetPath.getPath())) {
        model.addAttribute("customTitle", "返回登录");
        model.addAttribute("info", "邮件已发送,请按照邮件重置密码");
        return "/account/info";
    } else {
        model.addAttribute("emailError","emailError");
        return "/account/reset";
    }
} else {
    model.addAttribute("failed", "failed");
    return "/account/reset";
}

到这里用户重置密码功能编写了三分之二了,还差最后一块功能,就是用户访问密码重置链接的逻辑。

LICENSED UNDER CC BY-NC-SA 4.0
Comment