继续我们的密码验证工作之旅。上次编写到用户名和密码验证成功之后,需要来生成让用户从外界访问的地址用于修改密码,这也是密码重置的核心功能。这次就来继续编写。
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
业务类,这个业务类需要实现如下功能:
- 取出一条
path
记录,这个是基本功能 - 判断一条
path
是否有效,有效的标准是path
存在于数据库中,以及还在有效时间内 - 删除一个用户对应的所有记录,这个功能用于用户成功修改密码
- 保存一个新
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);
}
补完控制器与发送邮件功能
现在我们编写好了完整的业务层的支持,就可以继续来编写控制器。
在用户的用户名和电子邮件验证相符后,我们的逻辑是
- 删除所有的过期记录
- 为当前用户生成一条记录插入数据库
- 发送电子邮件
下边来一点一点补完
删除过期记录与保存用户记录
根据上边的分析,编写如下:
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>";
这其中我打算把username
,link
,interval
替换成方法中的参数。
发送电子邮件 - 功能
发送电子邮件就是先把上边的模板中的字符串都替换掉,再发送即可:
@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";
}
到这里用户重置密码功能编写了三分之二了,还差最后一块功能,就是用户访问密码重置链接的逻辑。