1. 概述

在本教程中,我们将继续《使用 Spring Security 进行注册》系列文章,重点实现一个非常实用的功能 —— “忘记密码”流程。这个功能允许用户在忘记密码时,安全地重置自己的密码。

2. 触发密码重置请求

密码重置流程通常从用户点击登录页上的“重置密码”按钮开始。接着,我们需要用户提供注册时使用的邮箱地址或其他身份识别信息。确认无误后,系统将生成一个一次性令牌(Token),并通过邮件发送给用户。

下图展示了我们将实现的完整流程:

Request password reset e-mail

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;
}

当用户触发密码重置请求后,系统会生成一个 Token,并通过邮件发送包含该 Token 的重置链接。

该 Token 及链接只在指定时间内有效(本例中为 24 小时)。

4. 忘记密码页面 (forgotPassword.html)

这是整个流程的第一个页面,用于提示用户输入注册时使用的邮箱地址,从而触发密码重置流程。

以下是一个简单的 forgotPassword.html 页面:

<html>
<body>
    <h1 th:text="#{message.resetPassword}">reset</h1>

    <label th:text="#{label.user.email}">email</label>
    <input id="email" name="email" type="email" value="" />
    <button type="submit" onclick="resetPass()" 
      th:text="#{message.resetPassword}">reset</button>

<a th:href="@{/registration.html}" th:text="#{label.form.loginSignUp}">
    registration
</a>
<a th:href="@{/login}" th:text="#{label.form.loginLink}">login</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>

5. 邮件发送逻辑

当用户提交邮箱后,后端会生成 Token,并调用邮件服务将重置链接发送给用户。示例代码如下:

@PostMapping("/user/resetPassword")
public ResponseEntity<?> resetPassword(HttpServletRequest request, 
  @RequestParam("email") String email) {
    User user = userService.findUserByEmail(email);
    if (user == null) {
        throw new MailSendException("User not found");
    }

    String token = UUID.randomUUID().toString();
    securityService.createPasswordResetTokenForUser(user, token);

    String appUrl = request.getScheme() + "://" + request.getServerName();
    String message = messages.getMessage("message.resetPassword", null, Locale.getDefault());
    SimpleMailMessage emailTemplate = mailConstructor.constructEmail(
      "Reset Password", message + " \r\n" + appUrl + "/user/changePassword?token=" + token, user.getEmail());
    mailSender.send(emailTemplate);

    return ResponseEntity.ok().build();
}

⚠️ 注意:邮件发送失败时,需要妥善处理异常,避免暴露用户信息。

6. 处理重置链接

当用户点击邮件中的链接后,会跳转到 /user/changePassword 接口进行 Token 验证,并跳转到设置新密码的页面。

@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());
}

7. 更新密码

用户进入密码更新页面后,输入新密码并提交。后端验证 Token 并更新密码:

@PostMapping("/user/savePassword")
public String savePassword(Locale locale, @RequestParam("token") String token, 
  @RequestBody PasswordDto passwordDto) {
    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;
    }

    User user = passwordTokenRepository.findByToken(token).getUser();
    securityService.changeUserPassword(user, passwordDto.getNewPassword());

    String message = messages.getMessage("message.resetPasswordSuccess", null, locale);
    return "redirect:/login.html?lang=" + locale.getLanguage() + "&message=" + message;
}

下面是 changeUserPassword() 方法:

public void changeUserPassword(User user, String password) {
    user.setPassword(passwordEncoder.encode(password));
    repository.save(user);
}

同时我们定义一个 PasswordDto 来接收用户输入:

public class PasswordDto {

    private String oldPassword;

    private  String token;

    @ValidPassword
    private String newPassword;
}

8. 总结

通过本教程,我们实现了一个成熟认证系统中常见的功能 —— 用户密码重置。虽然流程看似简单,但其中涉及到 Token 管理、邮件发送、安全验证等多个关键环节,每一步都需要仔细处理,确保安全性和用户体验。

完整的实现代码可在 GitHub 上查看:完整项目地址

如果你正在构建一个基于 Spring Security 的认证系统,建议将该流程作为标准模块进行封装,方便复用和维护。


原始标题:Spring Security – Reset Your Password

« 上一篇: Baeldung周报6号
» 下一篇: Baeldung周报7