1. 概述

继续我们的 Spring Boot 用户注册系列教程,本文我们将学习如何验证用户的电子邮箱

整个业务逻辑是:当用户注册成功后,我们会向其邮箱发送一封确认邮件,用户点击激活链接之后才启用账号的使用,在验证完成之前用户无法登录系统。

2. 验证码

首先我们需要定义一个 VerificationToken 。

2.1. VerificationToken 定义

VerificationToken 实体类必须满足以下条件:

  1. 它必须指向我们的验证的 User (单向关系)
  2. 注册完成后立即创建
  3. 创建完成后 24 小时内有效
  4. token 应该 唯一,随机生成

2 和 3 在我们注册逻辑中完成,1 和 4 在 VerificationToken 实体类中实现,例如Example 2.1:

Example 2.1.

@Entity
public class VerificationToken {
    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;
   
    private Date calculateExpiryDate(int expiryTimeInMinutes) {
        Calendar cal = Calendar.getInstance();
        cal.setTime(new Timestamp(cal.getTime().getTime()));
        cal.add(Calendar.MINUTE, expiryTimeInMinutes);
        return new Date(cal.getTime().getTime());
    }
    
    // standard constructors, getters and setters
}

User 注解上的 nullable = false 保证了 VerificationToken<->User 关联中数据的完整性和一致性,

2.2. 向 User 实体类添加 enabled 字段

enabled 用于表示用户是否激活, 注册成功后 enabled 初始赋值为 false。完成邮箱验证后,设置为 true

下面我们为 User 类添加 enabled 字段:

public class User {
    ...
    @Column(name = "enabled")
    private boolean enabled;
    
    public User() {
        super();
        this.enabled=false;
    }
    ...
}

3. 完善账号注册流程

现在我们需要在用户注册逻辑中补充两个额外步骤:

  1. 为新注册的用户生成 VerificationToken 并持久化
  2. 向用户发送账号验证邮件,其中包含带有 VerificationToken 的确认链接

3.1. 使用 Spring Event 创建 Token 并发送确认邮件

这 2 个附加逻辑不应该在 controller 中直接执行,而是应该以异步任务的方式执行。

我们在 controller 中发布 Spring ApplicationEvent 事件来触发这些任务的执行。下面 Example 3.1 示例中,我们注入 ApplicationEventPublisher 实例,然后发布注册完成事件:

Example 3.1.

@Autowired
ApplicationEventPublisher eventPublisher

@PostMapping("/user/registration")
public ModelAndView registerUserAccount(
  @ModelAttribute("user") @Valid UserDto userDto, 
  HttpServletRequest request, Errors errors) { 
    
    try {
          // 注册
        User registered = userService.registerNewUserAccount(userDto);
        
        String appUrl = request.getContextPath();
        // 发布注册完成事件
        eventPublisher.publishEvent(new OnRegistrationCompleteEvent(registered, 
          request.getLocale(), appUrl));
    } catch (UserAlreadyExistException uaeEx) {
        // 用户已存在
        ModelAndView mav = new ModelAndView("registration", "user", userDto);
        mav.addObject("message", "An account for that username/email already exists.");
        return mav;
    } catch (RuntimeException ex) {
        // 邮件发送失败
        return new ModelAndView("emailError", "user", userDto);
    }

    return new ModelAndView("successRegister", "user", userDto);
}

我们使用了 try catch 代码块包裹代码,如果出现异常,则提示用户错误信息。

3.2. 注册事件和监听器

下面我们来处理 controller 发布的 OnRegistrationCompleteEvent 事件:

Example 3.2.1. – 定义注册完成事件 - OnRegistrationCompleteEvent

public class OnRegistrationCompleteEvent extends ApplicationEvent {
    private String appUrl;
    private Locale locale;
    private User user;

    public OnRegistrationCompleteEvent(
      User user, Locale locale, String appUrl) {
        super(user);
        
        this.user = user;
        this.locale = locale;
        this.appUrl = appUrl;
    }
    
    // standard getters and setters
}

Example 3.2.2. – 定义 RegistrationListener 监听器来处理 OnRegistrationCompleteEvent

@Component
public class RegistrationListener implements 
  ApplicationListener<OnRegistrationCompleteEvent> {
 
    @Autowired
    private IUserService service;
 
    @Autowired
    private MessageSource messages;
 
    @Autowired
    private JavaMailSender mailSender;

    @Override
    public void onApplicationEvent(OnRegistrationCompleteEvent event) {
        this.confirmRegistration(event);
    }

    private void confirmRegistration(OnRegistrationCompleteEvent event) {
        User user = event.getUser();
        String token = UUID.randomUUID().toString();
        service.createVerificationToken(user, token);
        
        String recipientAddress = user.getEmail();
        String subject = "Registration Confirmation";
        String confirmationUrl 
          = event.getAppUrl() + "/regitrationConfirm.html?token=" + token;
        String message = messages.getMessage("message.regSucc", null, event.getLocale());
        
        SimpleMailMessage email = new SimpleMailMessage();
        email.setTo(recipientAddress);
        email.setSubject(subject);
        email.setText(message + "\r\n" + "http://localhost:8080" + confirmationUrl);
        mailSender.send(email);
    }
}

confirmRegistration 方法接收 OnRegistrationCompleteEvent 事件,获取用户信息,创建验证码并持久化,然后将其作为参数发送到“确认注册”链接中。

3.3. 处理验证码参数

用户收到 “确认注册” 链接并点击后,我们在 controller 中获取到 token 参数,并完成验证逻辑:

Example 3.3.1. – RegistrationController 处理验证流程

@Autowired
private IUserService service;

@GetMapping("/regitrationConfirm")
public String confirmRegistration
  (WebRequest request, Model model, @RequestParam("token") String token) {
 
    Locale locale = request.getLocale();
    
    VerificationToken verificationToken = service.getVerificationToken(token);
    // 如果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();
    // 如果token过期
    if ((verificationToken.getExpiryDate().getTime() - cal.getTime().getTime()) <= 0) {
        String messageValue = messages.getMessage("auth.message.expired", null, locale)
        model.addAttribute("message", messageValue);
        return "redirect:/badUser.html?lang=" + locale.getLanguage();
    } 
    
    // 验证成功
    user.setEnabled(true); 
    service.saveRegisteredUser(user); 
    return "redirect:/login.html?lang=" + request.getLocale().getLanguage(); 
}

出现以下情况会导致验证失败,并将用户重定向到错误页面:

  1. VerificationToken 不存在
  2. VerificationToken 存在但已过期

Example 3.3.2. – 错误页面 - badUser.html

<html>
<body>
    <h1 th:text="${param.message[0]}>Error Message</h1>
    <a th:href="@{/registration.html}" 
      th:text="#{label.form.loginSignUp}">signup</a>
</body>
</html>

如果没有错误,则用户激活成功。

当然在检查token和过期,有两点我们是可以改进的:

  1. 我们可以在后台使用定时任务检查token是否过期
  2. token 一旦过期 我们应该允许用户再次获取新的token

我们将在后续的文章中讲解如何生成新的token,这里我们假设用户成功验证了token。

4. 登录新增账号状态检查

登录流程中,我们需要检查用户账号是否已激活:

具体实现在 MyUserDetailsService -> loadUserByUsername 方法中完成。

Example 4.1.

@Autowired
UserRepository userRepository;

public UserDetails loadUserByUsername(String email) 
  throws UsernameNotFoundException {
 
    boolean enabled = true;
    boolean accountNonExpired = true;
    boolean credentialsNonExpired = true;
    boolean accountNonLocked = true;
    try {
        User user = userRepository.findByEmail(email);
        if (user == null) {
            throw new UsernameNotFoundException(
              "No user found with username: " + email);
        }
        
        return new org.springframework.security.core.userdetails.User(
          user.getEmail(), 
          user.getPassword().toLowerCase(), 
          user.isEnabled(), 
          accountNonExpired, 
          credentialsNonExpired, 
          accountNonLocked, 
          getAuthorities(user.getRole()));
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

我们需要添加一个 AuthenticationFailureHandler 以定制 MyUserDetailsService 抛出的异常消息。

Example 4.2. – CustomAuthenticationFailureHandler:

@Component
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {

    @Autowired
    private MessageSource messages;

    @Autowired
    private LocaleResolver localeResolver;

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, 
      HttpServletResponse response, AuthenticationException exception)
      throws IOException, ServletException {
        setDefaultFailureUrl("/login.html?error=true");

        super.onAuthenticationFailure(request, response, exception);

        Locale locale = localeResolver.resolveLocale(request);

        String errorMessage = messages.getMessage("message.badCredentials", null, locale);

        if (exception.getMessage().equalsIgnoreCase("User is disabled")) {
            errorMessage = messages.getMessage("auth.message.disabled", null, locale);
        } else if (exception.getMessage().equalsIgnoreCase("User account has expired")) {
            errorMessage = messages.getMessage("auth.message.expired", null, locale);
        }

        request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, errorMessage);
    }
}

我们需要修改 login.html,显示错误信息。

Example 4.3. – login.html 显示错误信息:

<div th:if="${param.error != null}" 
  th:text="${session[SPRING_SECURITY_LAST_EXCEPTION]}">error</div>

5. 适配持久层

下面是数据持久层的实现。

包括:

  1. 新增 VerificationTokenRepository
  2. IUserInterface 新增方法并实现CRUD操作

Examples 5.1 – 5.3. 展示了我们新增的接口和其实现:

Example 5.1.VerificationTokenRepository

public interface VerificationTokenRepository 
  extends JpaRepository<VerificationToken, Long> {

    VerificationToken findByToken(String token);

    VerificationToken findByUser(User user);
}

Example 5.2.IUserService 接口

public interface IUserService {
    
    User registerNewUserAccount(UserDto userDto) 
      throws UserAlreadyExistException;

    User getUser(String verificationToken);

    void saveRegisteredUser(User user);

    void createVerificationToken(User user, String token);

    VerificationToken getVerificationToken(String VerificationToken);
}

Example 5.3. UserService 接口

@Service
@Transactional
public class UserService implements IUserService {
    @Autowired
    private UserRepository repository;

    @Autowired
    private VerificationTokenRepository tokenRepository;

    @Override
    public User registerNewUserAccount(UserDto userDto) 
      throws UserAlreadyExistException {
        
        if (emailExist(userDto.getEmail())) {
            throw new UserAlreadyExistException(
              "There is an account with that email adress: " 
              + userDto.getEmail());
        }
        
        User user = new User();
        user.setFirstName(userDto.getFirstName());
        user.setLastName(userDto.getLastName());
        user.setPassword(userDto.getPassword());
        user.setEmail(userDto.getEmail());
        user.setRole(new Role(Integer.valueOf(1), user));
        return repository.save(user);
    }

    private boolean emailExist(String email) {
        return userRepository.findByEmail(email) != null;
    }
    
    @Override
    public User getUser(String verificationToken) {
        User user = tokenRepository.findByToken(verificationToken).getUser();
        return user;
    }
    
    @Override
    public VerificationToken getVerificationToken(String VerificationToken) {
        return tokenRepository.findByToken(VerificationToken);
    }
    
    @Override
    public void saveRegisteredUser(User user) {
        repository.save(user);
    }
    
    @Override
    public void createVerificationToken(User user, String token) {
        VerificationToken myToken = new VerificationToken(token, user);
        tokenRepository.save(myToken);
    }
}

6. 总结

本文我们对注册流程进行了完善,新增了基于邮箱的账号验证机制

帐户激活逻辑需要通过电子邮件向用户发送验证token, contoller 中会接收返回的token以识别用户身份。

本教程完整源码托管在 GitHub 上。