终于到了密码重置功能的最后一部分了。这里要做的事情,就是处理用户访问那个随机地址,然后进行重置密码的操作。
功能设计
老样子,我们要先来设计一下功能。这里提一下之前没有关注过的点,那就是需要在Spring Security
中对/reset
开头的路径都放开访问权限,否则用户就无法访问了。
这里我们要解决的逻辑相比上一部分,还是简单了不少:
- 用户访问邮件中的路径,控制器使用
@PathVariable
来获取uuid
字符串 - 控制器先删除所有过期记录,然后检查字符串是否有效
- 如果有效,返回重置密码的页面,让用户输入两个密码,同时在页面中需要埋入刚才的
path
来继续在POST
的时候进行验证 - 如果无效,提示用户无效,再去返回页面验证
下边就来逐步实现
处理用户GET
请求的控制器
这个控制器会先删除所有过期的记录,然后来验证这个字符串,如果字符串通过验证,就会返回一个供用户填写密码的页面。如果验证失败,就返回info
页面。
编写VO
对于要传入密码重置页面的数据,我们可以编写一个VO
,用来存储两个密码和这个字符串,在之后的控制器中也好处理,如下:
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.NotEmpty;
public class PasswordReset {
@NotEmpty(message = "密码不能为空")
@Length(max = 128, message = "密码最长为128位")
private String password1;
@NotEmpty(message = "确认密码不能为空")
@Length(max = 128, message = "密码最长为128位")
private String password2;
@NotEmpty(message = "PATH不能为空")
@Length(max = 32, message = "PATH最长为32个字符")
private String path;
}
GET
控制器
有了VO
,就可以用@ModelAttribute
来方便的编写控制器了。控制器验证失败的时候返回info
页面,验证成功则向密码重置页面传入带有path
的VO
:
@GetMapping("/reset/{key}")
public String validateResetKey(@ModelAttribute("resetData") PasswordReset resetData, @PathVariable("key") String path, Model model) {
//删除所有过期的path
passwordResetService.deleteAllExpiredPath();
//验证成功,给resetData设置上path,传入页面
if (passwordResetService.isValid(path)) {
resetData.setPath(path);
return "/account/resetInput";
} else {
//验证失败
model.addAttribute("customTitle", "重置链接无效");
model.addAttribute("info", "链接已失效");
return "/account/info";
}
}
输入密码页面
在控制器中我们已经命名了页面叫做resetInput.html
,这个页面依然采用居中的模式,编写如下:
<main class="form-signin">
<form action="/account/reset" th:action="|@{/account/reset/}*{path}|" method="post" th:object="${resetData}">
<img class="mb-4" src="/img/sinochem.png" th:src="@{/img/sinochem.png}" alt="中化集团">
<h1 class="h3 mb-3 fw-normal text-primary">输入新密码</h1>
<input type="hidden" name="path" id="path" th:value="*{path}">
<div th:if="${errorMessage} != null" class="alert alert-danger" role="alert" th:text="${errorMessage}">
密码不一致或链接已失效
</div>
<div class="form-floating">
<input type="password" maxlength="128" required class="form-control" th:field="*{password1}" id="password1"
placeholder="新密码">
<label for="password1">新密码</label>
<div th:if="${#fields.hasErrors('password1')}" class="alert alert-danger" role="alert"
th:errors="*{password1}"></div>
</div>
<div class="form-floating">
<input type="password" maxlength="128" required class="form-control" th:field="*{password2}" id="password2"
placeholder="重复新密码">
<label for="password2">重复新密码</label>
<div th:if="${#fields.hasErrors('password2')}" class="alert alert-danger" role="alert"
th:errors="*{password2}"></div>
</div>
<button class="w-100 btn btn-lg btn-primary mt-3" type="submit">提交</button>
<p class="mt-3 mb-3 text-muted">Copyrights ©2021
<a href="https://conyli.cc" target="_blank" style="text-decoration: none">https://conyli.cc</a>
</p>
<p class="mt-3 mb-3 text-muted">All Rights Reserved</p>
</form>
</main>
页面埋入了一个path
,然后表单的action
属性拼接了从控制器传入的地址。下一步就是在接受POST
请求的控制器中进行校验和设置密码。
POST
控制器编写
最后这个控制器的逻辑还比较复杂,详细如下:
- 验证用户输入的两个密码是否一致,不一致返回页面提示重新填写
- 通过
@PathVariable
来获取path
- 为了万无一失,将其与通过
POST
请求传入的resetData
中的path
属性进行对比,如果有错误就返回info
页面提示失效 - 删除所有过期链接
- 检验
path
是否有效,无效依然返回info
页面提示失效 - 上述检验都通过之后,根据
path
对象获取用户对象,设置上经过编码的密码,然后保存用户对象 - 保存完成之后,删除该用户所有的重置密码链接,重定向到登录页面
具体编写如下,要注意的点是完成之后要取消会话,强制用户重新登录;其他的按照上边逻辑直接编写即可:
@PostMapping("/reset/{key}")
public String processResetPassword(@Valid @ModelAttribute("resetData") PasswordReset resetData, BindingResult rs, @PathVariable("key") String path, Model model, HttpServletRequest request) {
// Bean校验
if (rs.hasErrors()) {
model.addAttribute("errorMessage", "输入信息有误,请检查");
return "/account/resetInput";
}
// 检测密码是否相同
if (!resetData.getPassword1().equals(resetData.getPassword2())) {
model.addAttribute("errorMessage", "两个密码不一致");
return "/account/resetInput";
}
// 删除所有过期链接
passwordResetService.deleteAllExpiredPath();
// 判断两个path是否相同,或者是否已经失效
if ((!path.equals(resetData.getPath())) || (!passwordResetService.isValid(path))) {
model.addAttribute("customTitle", "链接已失效");
model.addAttribute("info", "链接已失效");
return "/account/info";
}
// 至此检验全部通过,通过path对象获取User,然后设置密码并保存
User user = passwordResetService.getPathByPath(path).getUser();
user.setPassword(passwordEncoder.encode(resetData.getPassword1()));
userService.saveUser(user);
// 删除该用户对应的所有path记录
passwordResetService.deleteAllExpiredPathByUser(user.getUsername());
// 万无一失,取消session
request.getSession().invalidate();
model.addAttribute("customTitle", "密码重置成功");
model.addAttribute("info", "密码重置成功,请登录");
return "/account/info";
}
既然是叫做UserService
怎么能没有保存User
的方法呢,添加一个即可,具体代码就不放了。
这里的逻辑是即使用户登录,也可以让其重置密码,但是发送邮件成功之后,必须要强制让其退出登录,所以这里需要在上一节已经编写的/reset
的POST
请求控制器中加上一行:
// 发送邮件
if (emailSendingService.sendMimeMessage(username, email, resetPath.getPath())) {
model.addAttribute("customTitle", "返回登录");
model.addAttribute("info", "邮件已发送,请按照邮件重置密码");
// 取消用户登录
request.getSession().invalidate();
return "/account/info";
至此彻底编写好了重置密码的功能,顺便把Session
失效时间设置为一小时:
server.servlet.session.timeout=3600
其他一些收尾工作
编写好了用户功能,现在整个用户功能都是独立于其他功能模块的,也方便和其他业务内容进行整合。后边可能还需要进行一些重构,把用户模块的控制器,DAO
等再区分的详细一些。
一些地址的登录后访问跳转
现在可以回顾一下整个账户URL
:
URL | 安全 | 登录后访问 |
---|---|---|
/account/login | 完全放开 | 跳转首页 |
/account/auth | 完全放开 | 供POST 使用,GET 无法访问 |
/account/logout | 需要登录 | 正常访问 |
/account/signout | 完全放开 | 供POST 使用,GET 无法访问 |
/account/register | 完全放开 | 跳转首页 |
/account/profile | 需要登录 | 正常访问 |
/account/reset | 完全放开 | 跳转首页 |
再检查一下控制器,发现我们的/reset
路径还没有加上登录后访问自动跳转首页的功能,现在就补上:
@GetMapping("/reset")
public String passwordResetPage(HttpServletRequest request) {
if (request.getUserPrincipal() != null) {
return "redirect:/";
}
return "/account/reset";
}
另外为了统一起见,一律将所有的重定向都设置为redirect:/
即跳转根目录。
修改HTML
模板
为了让密码重置邮件看起来更加漂亮点,就找了一个模板改了改,至少比起干巴巴的样子好看不少,不过在各种客户端里还是有细微的区别,业务方法也修改一下,传入了时间和邮件地址,把代码贴在这里免得遗忘:
package cc.conyli.fms.service;
import cc.conyli.fms.config.EmailProperties;
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 javax.mail.internet.MimeMessage;
import javax.transaction.Transactional;
import java.text.SimpleDateFormat;
import java.util.Date;
@Service
public class EmailSendingService {
private final JavaMailSender emailSender;
private final EmailProperties emailProperties;
private final static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss");
@Autowired
public EmailSendingService(JavaMailSender emailSender, EmailProperties emailProperties) {
this.emailSender = emailSender;
this.emailProperties = emailProperties;
}
@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())
.replace("{{mail}}", emailProperties.getFrom())
.replace("{{time}}", simpleDateFormat.format(new Date()));
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;
}
}
private final static String template = "<!DOCTYPE html>\n" +
"<html lang=\"en\" xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:o=\"urn:schemas-microsoft-com:office:office\">\n" +
"<head>\n" +
" <meta charset=\"utf-8\"> <!-- utf-8 works for most cases -->\n" +
" <meta name=\"viewport\" content=\"width=device-width\"> <!-- Forcing initial-scale shouldn't be necessary -->\n" +
" <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"> <!-- Use the latest (edge) version of IE rendering engine -->\n" +
" <meta name=\"x-apple-disable-message-reformatting\"> <!-- Disable auto-scale in iOS 10 Mail entirely -->\n" +
" <meta name=\"format-detection\" content=\"telephone=no,address=no,email=no,date=no,url=no\"> <!-- Tell iOS not to automatically link certain text strings. -->\n" +
" <meta name=\"color-scheme\" content=\"light\">\n" +
" <meta name=\"supported-color-schemes\" content=\"light\">\n" +
" <title>密码重置 - FMS</title>\n" +
" <![endif]-->\n" +
"\n" +
"\n" +
" <style>\n" +
" * {\n" +
" font-family: sans-serif !important;\n" +
" }\n" +
" </style>\n" +
" <![endif]-->\n" +
"\n" +
"\n" +
" <style>\n" +
"\n" +
" :root {\n" +
" color-scheme: light;\n" +
" supported-color-schemes: light;\n" +
" }\n" +
"\n" +
" html,\n" +
" body {\n" +
" margin: 0 auto !important;\n" +
" padding: 0 !important;\n" +
" height: 100% !important;\n" +
" width: 100% !important;\n" +
" }\n" +
"\n" +
" * {\n" +
" -ms-text-size-adjust: 100%;\n" +
" -webkit-text-size-adjust: 100%;\n" +
" }\n" +
"\n" +
" div[style*=\"margin: 16px 0\"] {\n" +
" margin: 0 !important;\n" +
" }\n" +
"\n" +
" #MessageViewBody, #MessageWebViewDiv{\n" +
" width: 100% !important;\n" +
" }\n" +
"\n" +
" table,\n" +
" td {\n" +
" mso-table-lspace: 0pt !important;\n" +
" mso-table-rspace: 0pt !important;\n" +
" }\n" +
"\n" +
" th {\n" +
" \tfont-weight: normal;\n" +
" }\n" +
"\n" +
" table {\n" +
" border-spacing: 0 !important;\n" +
" border-collapse: collapse !important;\n" +
" table-layout: fixed !important;\n" +
" margin: 0 auto !important;\n" +
" }\n" +
"\n" +
" a {\n" +
" text-decoration: none;\n" +
" }\n" +
"\n" +
" img {\n" +
" -ms-interpolation-mode:bicubic;\n" +
" }\n" +
"\n" +
" a[x-apple-data-detectors], /* iOS */\n" +
" .unstyle-auto-detected-links a,\n" +
" .aBn {\n" +
" border-bottom: 0 !important;\n" +
" cursor: default !important;\n" +
" color: inherit !important;\n" +
" text-decoration: none !important;\n" +
" font-size: inherit !important;\n" +
" font-family: inherit !important;\n" +
" font-weight: inherit !important;\n" +
" line-height: inherit !important;\n" +
" }\n" +
"\n" +
" .im {\n" +
" color: inherit !important;\n" +
" }\n" +
"\n" +
" .a6S {\n" +
" display: none !important;\n" +
" opacity: 0.01 !important;\n" +
"\t\t}\n" +
"\t\timg.g-img + div {\n" +
"\t\t display: none !important;\n" +
"\t\t}\n" +
"\n" +
"\n" +
" @media only screen and (min-device-width: 320px) and (max-device-width: 374px) {\n" +
" u ~ div .email-container {\n" +
" min-width: 320px !important;\n" +
" }\n" +
" }\n" +
" @media only screen and (min-device-width: 375px) and (max-device-width: 413px) {\n" +
" u ~ div .email-container {\n" +
" min-width: 375px !important;\n" +
" }\n" +
" }\n" +
" @media only screen and (min-device-width: 414px) {\n" +
" u ~ div .email-container {\n" +
" min-width: 414px !important;\n" +
" }\n" +
" }\n" +
"\n" +
" </style>\n" +
"\n" +
" <style>\n" +
"\n" +
" .button-td,\n" +
" .button-a {\n" +
" transition: all 100ms ease-in;\n" +
" }\n" +
"\t .button-td-primary:hover,\n" +
"\t .button-a-primary:hover {\n" +
"\t background: #555555 !important;\n" +
"\t border-color: #555555 !important;\n" +
"\t }\n" +
"\n" +
" @media screen and (max-width: 600px) {\n" +
"\n" +
" .email-container {\n" +
" width: 100% !important;\n" +
" margin: auto !important;\n" +
" }\n" +
"\n" +
" .stack-column,\n" +
" .stack-column-center {\n" +
" display: block !important;\n" +
" width: 100% !important;\n" +
" max-width: 100% !important;\n" +
" direction: ltr !important;\n" +
" }\n" +
" .stack-column-center {\n" +
" text-align: center !important;\n" +
" }\n" +
"\n" +
" .center-on-narrow {\n" +
" text-align: center !important;\n" +
" display: block !important;\n" +
" margin-left: auto !important;\n" +
" margin-right: auto !important;\n" +
" float: none !important;\n" +
" }\n" +
" table.center-on-narrow {\n" +
" display: inline-block !important;\n" +
" }\n" +
"\n" +
" .email-container p {\n" +
" font-size: 17px !important;\n" +
" }\n" +
" }\n" +
"\n" +
" </style>\n" +
" <!-- Progressive Enhancements : END -->\n" +
"\n" +
"</head>\n" +
"\n" +
"<body width=\"100%\" style=\"margin: 0; padding: 0 !important; mso-line-height-rule: exactly; background-color: #222222;\">\n" +
" <center role=\"article\" aria-roledescription=\"email\" lang=\"en\" style=\"width: 100%; background-color: #222222;\">\n" +
" <!--[if mso | IE]>\n" +
" <table role=\"presentation\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" width=\"100%\" style=\"background-color: #222222;\">\n" +
" <tr>\n" +
" <td>\n" +
" <![endif]-->\n" +
"\n" +
" <div style=\"max-height:0; overflow:hidden; mso-hide:all;\" aria-hidden=\"true\">\n" +
" 密码重置 - {{username}}\n" +
" </div>\n" +
"\n" +
" <div style=\"display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all; font-family: sans-serif;\">\n" +
" ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ ‌ \n" +
" </div>\n" +
"\n" +
" <table align=\"center\" role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"600\" style=\"margin: auto;\" class=\"email-container\">\n" +
" <tr>\n" +
" <td style=\"padding: 30px 0;font-size: 40px; text-align: center;color: whitesmoke\">\n" +
"\t\t\t\t\tPassword Reset\n" +
" </td>\n" +
" </tr>\n" +
"\n" +
" <tr>\n" +
" <td style=\"background-color: #ffffff;\">\n" +
" <table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"100%\">\n" +
" <tr>\n" +
" <td style=\"padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;\">\n" +
" <h1 style=\"margin: 0 0 10px; font-size: 25px; line-height: 30px; color: #333333; font-weight: normal;\">亲爱的{{username}}:</h1>\n" +
" <p style=\"margin: 0 0 10px;line-height: 25px\">您收到此封邮件是因为您于{{time}}在FMS上申请了密码重置。如果密码重置不是由您发起,请忽略此封邮件。</p>\n" +
" <p style=\"margin: 0 0 10px;line-height: 25px\">请点击下方的重置链接来完成您的密码重置工作。链接将在{{interval}}小时后失效,失效后您需要重新进行密码重置。</p>\n" +
" </td>\n" +
" </tr>\n" +
" <tr>\n" +
" <td style=\"padding: 0 20px 20px;\">\n" +
" <!-- Button : BEGIN -->\n" +
" <table align=\"center\" role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" style=\"margin: auto;\">\n" +
" <tr>\n" +
" <td class=\"button-td button-td-primary\" style=\"border-radius: 4px; background: #222222;\">\n" +
"\t\t\t\t\t\t\t\t\t\t\t<a class=\"button-a button-a-primary\" href=\"{{link}}\" style=\"background: #222222; border: 1px solid #000000; font-family: sans-serif; font-size: 15px; line-height: 15px; text-decoration: none; padding: 13px 17px; color: #ffffff; display: block; border-radius: 4px;\">重置链接</a>\n" +
"\t\t\t\t\t\t\t\t\t\t</td>\n" +
" </tr>\n" +
" </table>\n" +
" <!-- Button : END -->\n" +
" </td>\n" +
" </tr>\n" +
"\t\t\t\t\t\t<tr>\n" +
"\t\t\t\t\t\t\t<td style=\"padding: 20px; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #555555;\">\n" +
"\t\t\t\t\t\t\t\t<p style=\"margin: 0 0 10px;line-height: 25px\">如果链接无法点击,请复制下列链接至浏览器地址栏打开:</p>\n" +
"\t\t\t\t\t\t\t\t<p style=\"margin: 0 0 10px;font-size: 20px;color: #0a53be\">{{link}}</p>\n" +
"\t\t\t\t\t\t\t</td>\n" +
"\t\t\t\t\t\t</tr>\n" +
" </table>\n" +
" </td>\n" +
" </tr>\n" +
"\t <tr>\n" +
"\t <td aria-hidden=\"true\" height=\"40\" style=\"font-size: 0px; line-height: 0px;\">\n" +
"\t \n" +
"\t </td>\n" +
"\t </tr>\n" +
"\n" +
"\t </table>\n" +
"\n" +
" <table align=\"center\" role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"600\" style=\"margin: auto;\" class=\"email-container\">\n" +
"\t <tr>\n" +
"\t <td style=\"padding: 20px; font-family: sans-serif; font-size: 12px; line-height: 15px; text-align: center; color: #ffffff;\">\n" +
"\t <webversion style=\"color: #ffffff; font-weight: bold;font-size: 16px\">Finance-Project Management System</webversion>\n" +
"\t <br><br>\n" +
"\t Developed by<br><a style=\"color: whitesmoke;font-size: 14px;text-decoration: underline\" target=\"_blank\" href=\"https://conyli.cc\">https://conyli.cc</a>\n" +
"\t <br><br>\n" +
"\t </td>\n" +
"\t </tr>\n" +
"\t </table>\n" +
"\n" +
"\t <table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"100%\" style=\"background-color: #709f2b;\">\n" +
"\t <tr>\n" +
"\t <td>\n" +
"\t <div align=\"center\" style=\"max-width: 600px; margin: auto;\" class=\"email-container\">\n" +
"\t <!--[if mso]>\n" +
"\t <table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"600\" align=\"center\">\n" +
"\t <tr>\n" +
"\t <td>\n" +
"\t <![endif]-->\n" +
"\t <table role=\"presentation\" cellspacing=\"0\" cellpadding=\"0\" border=\"0\" width=\"100%\">\n" +
"\t <tr>\n" +
"\t <td style=\"padding: 20px; text-align: left; font-family: sans-serif; font-size: 15px; line-height: 20px; color: #ffffff;\">\n" +
"\t <p style=\"margin: 0;text-align: center\">\n" +
"\t\t\t\t\t\t\t\t\t\t无法自助重置密码,请发送邮件至<a style=\"color: whitesmoke;text-decoration: underline\" href=\"mailto:{{mail}}\">{{mail}}</a>。\n" +
"\t\t\t\t\t\t\t\t\t</p>\n" +
"\t </td>\n" +
"\t </tr>\n" +
"\t </table>\n" +
"\t <!--[if mso]>\n" +
"\t </td>\n" +
"\t </tr>\n" +
"\t </table>\n" +
"\t <![endif]-->\n" +
"\t </div>\n" +
"\t </td>\n" +
"\t </tr>\n" +
"\t </table>\n" +
"\n" +
" <!--[if mso | IE]>\n" +
" </td>\n" +
" </tr>\n" +
" </table>\n" +
" <![endif]-->\n" +
" </center>\n" +
"</body>\n" +
"</html>\n";
}