本文续之前的 Spring Security 系列之注册流程 中缺失的部分 — 验证用户的电子邮件以确认帐户 。
注册确认机制强制用户在成功注册后执行 确认注册 电子邮件中的操作,以验证其电子邮件地址并激活帐户。用户通过单击电子邮件中的唯一激活链接来完成激活操作。
根据此逻辑,新注册的用户无法登录到系统,除非完成了该流程。
我们将使用一个简单的验证令牌作为验证用户的凭据。
VerificationToken 实体必须符合以下标准:
第 2 和 3 点是注册逻辑的一部分。其余的两个实现位于简单的 VerificationToken 实体中,如示例 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 关联中数据的完整性和一致性。
当用户注册时,此 enabled 字段将被设置为 false。在帐户验证过程中,如果通过,则置为 true。
添加字段到 User 实体中:
public class User { ... @Column(name = "enabled") private boolean enabled; public User() { super(); this.enabled=false; } ... }
请注意,我们也将该字段的默认值设置为 false。
让我们添加两个额外的业务逻辑到用户注册用例中:
这两个额外的逻辑不应该由控制器直接执行,因为它们是并行的后台任务。
控制器将发布一个 Spring ApplicationEvent 来触发这些任务的执行。这和注入 ApplicationEventPublisher 使用它来发布注册一样简单。
示例 3.1 展示了这个简单的逻辑:
@Autowired ApplicationEventPublisher eventPublisher @RequestMapping(value = "/user/registration", method = RequestMethod.POST) public ModelAndView registerUserAccount( @ModelAttribute("user") @Valid UserDto accountDto, BindingResult result, WebRequest request, Errors errors) { if (result.hasErrors()) { return new ModelAndView("registration", "user", accountDto); } User registered = createUserAccount(accountDto); if (registered == null) { result.rejectValue("email", "message.regError"); } try { String appUrl = request.getContextPath(); eventPublisher.publishEvent(new OnRegistrationCompleteEvent (registered, request.getLocale(), appUrl)); } catch (Exception me) { return new ModelAndView("emailError", "user", accountDto); } return new ModelAndView("successRegister", "user", accountDto); }
另外需要注意的是包围事件发布的 try catch 块。这段代码代表了只要在发布事件后执行的逻辑中存在异常就展示一个错误页面。此处的逻辑就是发送电子邮件。
现在让我们看看控制器发出的这个新的 OnRegistrationCompleteEvent 的实际实现,以及要处理它的监听器:
例 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 }
例 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 + " rn" + "http://localhost:8080" + confirmationUrl); mailSender.send(email); } }
在此处, confirmRegistration 方法将接收 OnRegistrationCompleteEvent ,从中提取所有必要的 User 信息,创建验证令牌,将其保存,然后在 确认注册 链接中将其作为参数发送。
如上所述, JavaMailSender 引发的任何 javax.mail.AuthenticationFailedException 都将由控制器处理。
当用户收到 确认注册 链接时点击它。
一旦点击,控制器将提取 GET 请求中的令牌参数的值,并将使用它来启用 User。
我们来示例 3.3.1 中的这个流程:
例 3.3.1— RegistrationController 处理注册确认
@Autowired private IUserService service; @RequestMapping(value = "/regitrationConfirm", method = RequestMethod.GET) public String confirmRegistration (WebRequest request, Model model, @RequestParam("token") String token) { Locale locale = request.getLocale(); VerificationToken verificationToken = service.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) { 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(); }
如果出现以下情况,用户将被重定向到错误页面并显示相应的信息:
见示例 3.3.2 的错误页面。
例 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>
如果没有发现错误,则启用用户。
在处理 VerificationToken 检查和过期流程中有两个地方可以改进:
我们将生成新令牌流程推迟到后面的文章再讲,现在假设用户确实在这里成功验证了令牌。
我们需要添加检查用户是否启用的代码:
我们来看示例 4.1。其展示了 MyUserDetailsService 的 loadUserByUsername 方法。
@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); } }
正如我们所看到的,现在 MyUserDetailsService 不使用 User 的 enabled 标志。
现在,我们将添加一个 AuthenticationFailureHandler 来自定义来自 MyUserDetailsService 的异常消息。我们的 CustomAuthenticationFailureHandler 如示例 4.2 所示:
例 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 以显示错误消息。
示例 4.3— 在 login.html 处显示错误消息:
<div th:if="${param.error != null}" th:text="${session[SPRING_SECURITY_LAST_EXCEPTION]}">error</div>
现在让我们来看看一些涉及到验证令牌和用户操作的实际实现。
将涵盖以下内容:
示例 5.1 — 5.3 展示新的接口和实现:
示例 5.1— VerificationTokenRepository
public interface VerificationTokenRepository extends JpaRepository<VerificationToken, Long> { VerificationToken findByToken(String token); VerificationToken findByUser(User user); }
示例5.2— IUserService 接口
public interface IUserService { User registerNewUserAccount(UserDto accountDto) throws EmailExistsException; User getUser(String verificationToken); void saveRegisteredUser(User user); void createVerificationToken(User user, String token); VerificationToken getVerificationToken(String VerificationToken); }
示例 5.3— UserService
@Service @Transactional public class UserService implements IUserService { @Autowired private UserRepository repository; @Autowired private VerificationTokenRepository tokenRepository; @Override public User registerNewUserAccount(UserDto accountDto) throws EmailExistsException { if (emailExist(accountDto.getEmail())) { throw new EmailExistsException( "There is an account with that email adress: " + accountDto.getEmail()); } User user = new User(); user.setFirstName(accountDto.getFirstName()); user.setLastName(accountDto.getLastName()); user.setPassword(accountDto.getPassword()); user.setEmail(accountDto.getEmail()); user.setRole(new Role(Integer.valueOf(1), user)); return repository.save(user); } private boolean emailExist(String email) { User user = repository.findByEmail(email); if (user != null) { return true; } return false; } @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); } }
在本文中,我们已经介绍了注册流程,包括一个基于电子邮件的帐户激活流程。
帐户激活逻辑需要通过电子邮件向用户发送验证令牌,以便他们可以将信息发送回控制器以验证身份。
这个注册与 Spring Security 教程的实现可以在 GitHub 项目中找到 — 这是一个基于 Eclipse 的项目,因此应该很容易导入运行。