這篇文章是系列的一部分
• Spring Security 註冊系列
• 使用 Spring Security 的註冊過程
• 激活新賬户通過電子郵件
• Spring Security 註冊 – 發送驗證電子郵件
• Spring Security 註冊 – 密碼編碼
• 註冊 API 變為 RESTful
• Spring Security – 重置您的密碼 (當前文章)
• 註冊 – 密碼強度和規則
• 更新您的密碼
• 通知用户從新設備或位置登錄
1. 概述
在本教程中,我們將繼續介紹Registration with Spring Security系列,重點介紹“忘記密碼”基本功能,以便用户在需要時安全地重置自己的密碼。
2. 請求重置您的密碼
密碼重置流程通常在用户在登錄頁面的某個“重置”按鈕上進行點擊開始。然後,我們可以要求用户提供他們的電子郵件地址或其他身份信息。確認後,我們可以生成一個令牌並向用户發送電子郵件。
以下圖表可視化我們將在本文中實施流程:
3. 用户密碼重置令牌
首先,我們創建一個 PasswordResetToken 實體,用於重置用户的密碼:
@Entity
public class PasswordResetToken {
private static final int EXPIRATION = 60 * 24;
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String token;
@OneToOne(targetEntity = User.class, fetch = FetchType.EAGER)
@JoinColumn(nullable = false, name = "user_id")
private User user;
private Date expiryDate;
}
當密碼重置觸發時,將會創建一個令牌,並向用户發送包含該令牌的特殊鏈接。
令牌和鏈接僅在一定時間範圍內有效(在本示例中為 24 小時)。
4. forgotPassword.html
第一個流程頁面是 “我忘記了密碼” 頁面 – 用户在此處會被提示其電子郵件地址,以便開始實際重置流程。
因此,讓我們創建一個簡單的 forgotPassword.html,要求用户提供電子郵件地址:
<html>
<body>
<h1 th:text="#{message.resetPassword}">重置密碼</h1>
<label th:text="#{label.user.email}">郵箱</label>
<input id="email" name="email" type="email" value="" />
<button type="submit" onclick="resetPass()"
th:text="#{message.resetPassword}">重置</button>
<a th:href="@{/registration.html}" th:text="#{label.form.loginSignUp}">
註冊
</a>
<a th:href="@{/login}" th:text="#{label.form.loginLink}">
登錄
</a>
<script src="jquery.min.js"></script>
<script th:inline="javascript">
var serverContext = [[@{/}]];
function resetPass(){
var email = $("#email").val();
$.post(serverContext + "user/resetPassword",{email: email} ,
function(data){
window.location.href =
serverContext + "login?message=" + data.message;
})
.fail(function(data) {
if(data.responseJSON.error.indexOf("MailError") > -1)
window.location.href = serverContext + "emailError.html";
else
window.location.href =
serverContext + "login?message=" + data.responseJSON.message;
});
}
</script>
</body>
</html>
現在我們需要從登錄頁面鏈接到這個新的 “重置密碼” 頁面:
<a th:href="@{/forgetPassword.html}"
th:text="#{message.resetPassword}">重置</a>
5. 創建 PasswordResetToken
我們首先創建新的 PasswordResetToken 並通過電子郵件發送給用户:
@PostMapping("/user/resetPassword")
public GenericResponse resetPassword(HttpServletRequest request,
@RequestParam("email") String userEmail) {
User user = userService.findUserByEmail(userEmail);
if (user == null) {
throw new UserNotFoundException();
}
String token = UUID.randomUUID().toString();
userService.createPasswordResetTokenForUser(user, token);
mailSender.send(constructResetTokenEmail(getAppUrl(request),
request.getLocale(), token, user));
return new GenericResponse(
messages.getMessage("message.resetPasswordEmail", null,
request.getLocale()));
}
以下是 createPasswordResetTokenForUser() 方法:
public void createPasswordResetTokenForUser(User user, String token) {
PasswordResetToken myToken = new PasswordResetToken(token, user);
passwordTokenRepository.save(myToken);
}
以下是方法 constructResetTokenEmail() – 用於發送包含重置令牌的電子郵件:
private SimpleMailMessage constructResetTokenEmail(
String contextPath, Locale locale, String token, User user) {
String url = contextPath + "/user/changePassword?token=" + token;
String message = messages.getMessage("message.resetPassword",
null, locale);
return constructEmail("Reset Password", message + " \r\n" + url, user);
}
private SimpleMailMessage constructEmail(String subject, String body,
User user) {
SimpleMailMessage email = new SimpleMailMessage();
email.setSubject(subject);
email.setText(body);
email.setTo(user.getEmail());
email.setFrom(env.getProperty("support.email"));
return email;
}
注意我們使用簡單的對象 GenericResponse 來表示我們對客户端的響應:
public class GenericResponse {
private String message;
private String error;
public GenericResponse(String message) {
super();
this.message = message;
}
public GenericResponse(String message, String error) {
super();
this.message = message;
this.error = error;
}
}
6. 檢查 PasswordResetToken
用户點擊郵件中的鏈接後,user/changePassword 端點:
- 驗證 token 是否有效,並
- 向用户展示 updatePassword 頁面,其中他可以輸入新的密碼
新的密碼和 token 隨後傳遞到 user/savePassword 端點:
用户收到包含唯一重置密碼鏈接的郵件,並點擊該鏈接:
@GetMapping("/user/changePassword")
public String showChangePasswordPage(Locale locale, Model model,
@RequestParam("token") String token) {
String result = securityService.validatePasswordResetToken(token);
if(result != null) {
String message = messages.getMessage("auth.message." + result, null, locale);
return "redirect:/login.html?lang="
+ locale.getLanguage() + "&message=" + message;
} else {
model.addAttribute("token", token);
return "redirect:/updatePassword.html?lang=" + locale.getLanguage();
}
}
以下是 validatePasswordResetToken() 方法:
public String validatePasswordResetToken(String token) {
final PasswordResetToken passToken = passwordTokenRepository.findByToken(token);
return !isTokenFound(passToken) ? "invalidToken"
: isTokenExpired(passToken) ? "expired"
: null;
}
private boolean isTokenFound(PasswordResetToken passToken) {
return passToken != null;
}
private boolean isTokenExpired(PasswordResetToken passToken) {
final Calendar cal = Calendar.getInstance();
return passToken.getExpiryDate().before(cal.getTime());
}
At this point, the user sees the simple page – where the only possible option is to :
<html>
<body>
<div sec:authorize="hasAuthority('CHANGE_PASSWORD_PRIVILEGE')">
<h1 th:text="#{message.resetYourPassword}">reset</h1>
<form>
<label th:text="#{label.user.password}">password</label>
<input id="password" name="newPassword" type="password" value="" />
<label th:text="#{label.user.confirmPass}">confirm</label>
<input id="matchPassword" type="password" value="" />
<label th:text="#{token.message}">token</label>
<input id="token" name="token" value="" />
<div id="globalError" style="display:none"
th:text="#{PasswordMatches.user}">error</div>
<button type="submit" onclick="savePass()"
th:text="#{message.updatePassword}">submit</button>
</form>
<script th:inline="javascript">
var serverContext = [[@{/}]];
$(document).ready(function () {
$('form').submit(function(event) {
savePass(event);
});
$(":password").keyup(function(){
if($("#password").val() != $("#matchPassword").val()){
$("#globalError").show().html(/*[[#{PasswordMatches.user}]]*/);
}else{
$("#globalError").html("").hide();
}
});
});
function savePass(event){
event.preventDefault();
if($("#password").val() != $("#matchPassword").val()){
$("#globalError").show().html(/*[[#{PasswordMatches.user}]]*/);
return;
}
var formData= $('form').serialize();
$.post(serverContext + "user/savePassword",formData ,function(data){
window.location.href = serverContext + "login?message="+data.message;
})
.fail(function(data) {
if(data.responseJSON.error.indexOf("InternalError") > -1){
window.location.href = serverContext + "login?message" + data.responseJSON.message;
}
else{
var errors = $.parseJSON(data.responseJSON.message);
$.each( errors, function( index,item ){
$("#globalError").show().html(item.defaultMessage);
});
errors = $.parseJSON(data.responseJSON.error);
$.each( errors, function( index,item ){
$("#globalError").show().append(item.defaultMessage+"<br/>");
});
}
});
}
</script>
</div>
</body>
</html>
Note that we show the reset token and pass it as a POST parameter in the following call to save the password.
Finally, when the previous post request is submitted – the new user password is saved:
@PostMapping("/user/savePassword")
public GenericResponse savePassword(final Locale locale, @Valid PasswordDto passwordDto) {
String result = securityUserService.validatePasswordResetToken(passwordDto.getToken());
if(result != null) {
return new GenericResponse(messages.getMessage(
"auth.message." + result, null, locale));
}
Optional user = userService.getUserByPasswordResetToken(passwordDto.getToken());
if(user.isPresent()) {
userService.changeUserPassword(user.get(), passwordDto.getNewPassword());
return new GenericResponse(messages.getMessage(
"message.resetPasswordSuc", null, locale));
} else {
return new GenericResponse(messages.getMessage(
"auth.message.invalid", null, locale));
}
}