1. 概述

在本教程中,我们将继续《使用 Spring Security 实现注册》系列文章,重点讨论如何在用户注册链接过期后重新发送验证邮件。

这是个很常见的需求:用户没有及时激活账号,验证链接已失效,此时用户需要一个新的验证链接。我们可以通过重新生成 token 并发送新邮件来实现。

2. 重新发送验证链接

当用户请求一个新的验证链接时,我们需要做两件事:

✅ 重置当前 token 的过期时间
✅ 重新发送验证邮件

下面是具体的实现代码:

@GetMapping("/user/resendRegistrationToken")
public GenericResponse resendRegistrationToken(
  HttpServletRequest request, @RequestParam("token") String existingToken) {
    VerificationToken newToken = userService.generateNewVerificationToken(existingToken);
    
    User user = userService.getUser(newToken.getToken());
    String appUrl = 
      "http://" + request.getServerName() + 
      ":" + request.getServerPort() + 
      request.getContextPath();
    SimpleMailMessage email = 
      constructResendVerificationTokenEmail(appUrl, request.getLocale(), newToken, user);
    mailSender.send(email);

    return new GenericResponse(
      messages.getMessage("message.resendToken", null, request.getLocale()));
}

构造邮件内容的方法如下:

private SimpleMailMessage constructResendVerificationTokenEmail
  (String contextPath, Locale locale, VerificationToken newToken, User user) {
    String confirmationUrl = 
      contextPath + "/registrationConfirm.html?token=" + newToken.getToken();
    String message = messages.getMessage("message.resendToken", null, locale);
    SimpleMailMessage email = new SimpleMailMessage();
    email.setSubject("重新发送注册验证链接");
    email.setText(message + "\n" + confirmationUrl);
    email.setFrom(env.getProperty("support.email"));
    email.setTo(user.getEmail());
    return email;
}

⚠️ 注意:拼接 URL 时建议使用 UriComponentsBuilder 更安全,但为了保持示例简单,这里直接拼接。

另外,我们需要在验证 token 时,判断 token 是否已过期,并在页面上提示用户可以重新发送验证链接:

@GetMapping("/registrationConfirm")
public String confirmRegistration(
  Locale locale, Model model, @RequestParam("token") String token) {
    VerificationToken verificationToken = userService.getVerificationToken(token);
    if (verificationToken == null) {
        String message = messages.getMessage("auth.message.invalidToken", null, locale);
        model.addAttribute("message", message);
        return "redirect:/badUser.html?lang=" + locale.getLanguage();
    }

    User user = verificationToken.getUser();
    Calendar cal = Calendar.getInstance();
    if ((verificationToken.getExpiryDate().getTime() - cal.getTime().getTime()) <= 0) {
        model.addAttribute("message", messages.getMessage("auth.message.expired", null, locale));
        model.addAttribute("expired", true);
        model.addAttribute("token", token);
        return "redirect:/badUser.html?lang=" + locale.getLanguage();
    }

    user.setEnabled(true);
    userService.saveRegisteredUser(user);
    model.addAttribute("message", messages.getMessage("message.accountVerified", null, locale));
    return "redirect:/login.html?lang=" + locale.getLanguage();
}

3. 异常处理

上述功能在执行过程中可能抛出异常,比如用户不存在、邮件配置错误等,因此我们需要一个统一的异常处理器。

我们使用 @ControllerAdvice 来处理全局异常,这样可以统一返回格式:

@ControllerAdvice
public class RestResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {

    @Autowired
    private MessageSource messages;

    @ExceptionHandler({ UserNotFoundException.class })
    public ResponseEntity<Object> handleUserNotFound(RuntimeException ex, WebRequest request) {
        logger.error("404 Status Code", ex);
        GenericResponse bodyOfResponse = new GenericResponse(
          messages.getMessage("message.userNotFound", null, request.getLocale()), "UserNotFound");
        
        return handleExceptionInternal(
          ex, bodyOfResponse, new HttpHeaders(), HttpStatus.NOT_FOUND, request);
    }

    @ExceptionHandler({ MailAuthenticationException.class })
    public ResponseEntity<Object> handleMail(RuntimeException ex, WebRequest request) {
        logger.error("500 Status Code", ex);
        GenericResponse bodyOfResponse = new GenericResponse(
          messages.getMessage(
            "message.email.config.error", null, request.getLocale()), "MailError");
        
        return handleExceptionInternal(
          ex, bodyOfResponse, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request);
    }

    @ExceptionHandler({ Exception.class })
    public ResponseEntity<Object> handleInternal(RuntimeException ex, WebRequest request) {
        logger.error("500 Status Code", ex);
        GenericResponse bodyOfResponse = new GenericResponse(
          messages.getMessage(
            "message.error", null, request.getLocale()), "InternalError");
        
        return handleExceptionInternal(
          ex, bodyOfResponse, new HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request);
    }
}

其中,GenericResponse 是一个通用的响应对象:

public class GenericResponse {
    private String message;
    private String error;

    public GenericResponse(String message) {
        this.message = message;
    }

    public GenericResponse(String message, String error) {
        this.message = message;
        this.error = error;
    }
}

4. 修改 badUser.html 页面

当 token 过期时,我们希望用户看到一个重新发送验证链接的按钮。因此,我们需要修改 badUser.html 页面:

<html>
<head>
<title th:text="#{label.badUser.title}">bad user</title>
</head>
<body>
<h1 th:text="${param.message[0]}">error</h1>
<br>
<a th:href="@{/user/registration}" th:text="#{label.form.loginSignUp}">
  注册新账号
</a>

<div th:if="${param.expired[0]}">
<h1 th:text="#{label.form.resendRegistrationToken}">重新发送验证链接</h1>
<button onclick="resendToken()" 
  th:text="#{label.form.resendRegistrationToken}">发送</button>
 
<script src="jquery.min.js"></script>
<script type="text/javascript">

var serverContext = [[@{/}]];

function resendToken(){
    var token = [[${param.token[0]}]];
    $.get(serverContext + "user/resendRegistrationToken?token=" + token, 
      function(data){
            window.location.href = 
              serverContext + "login.html?message=" + encodeURIComponent(data.message);
    })
    .fail(function(data) {
        if(data.responseJSON.error.indexOf("MailError") > -1) {
            window.location.href = serverContext + "emailError.html";
        }
        else {
            window.location.href = 
              serverContext + "login.html?message=" + encodeURIComponent(data.responseJSON.message);
        }
    });
}
</script>
</div>
</body>
</html>

⚠️ 注意:这里使用了 jQuery 发送 GET 请求,并根据响应结果跳转页面。实际项目中建议使用 POST 方法,并考虑 CSRF 保护。

5. 总结

在本教程中,我们实现了用户请求新的验证链接的功能。这在实际项目中非常实用,尤其是在用户忘记及时激活账号的情况下。

完整代码可以在 GitHub 上找到:spring-security-registration(这是一个基于 Eclipse 的项目,可直接导入运行)

如果你正在构建一个完整的注册系统,这个功能是必不可少的。记得处理好异常、邮件配置、以及 token 的生命周期管理。


原始标题:Spring Security – Resend The Verification Email | Baeldung