1.概述
注册确认机制强制用户回应注册成功后发送的“确认注册”邮件。
根据这个逻辑,一个新注册的用户将不能登录系统,直到完成本过程。
2.验证token
我们将使用一个简单的验证token作为关键工件,通过它来验证用户。
2.1. VerificationToken 实体
VerificationToken 实体必须满足以下条件:
- 必须链接回用户(通过一个单向关系)
- 注册后被正确创建
- 24小时内过期
- 拥有唯一随机值
2、3是注册逻辑必需的部分,其他两个在简单的VerificationToken 实体中实现,如例子2.1所示
例子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字段将会设置为false。在账号认证过程-如果成功,它将会修改为true。
我们开始把字段添加到User实体中:
public class User {
...
@Column(name = "enabled")
private boolean enabled;
public User() {
super();
this.enabled=false;
}
...
}
注意我们还把字段值默认设置为false。
3.账号注册过程
让我们向用户注册用例添加另外两个业务逻辑
- 创建并持久化用户的VerificationToken
- 向外发送账号的确认邮件信息-包括带有VerificationToken值的确认链接
3.1. 使用Spring Event 创建Token并发送验证邮件
这两个额外的逻辑不应该由controller直接去执行,因为它们是“附带的”后端任务。
Controller将发布一个Spring ApplicationEvent 事件,触发这些任务的执行。这如注入ApplicationEventPublisher 那样简单,然后用它来发布注册完成。
例子 3.1. 展示了这个简单的逻辑:
例子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块环绕事件的发布。这块代码将显示一个错误页面,无论何时,当事件发布后执行的逻辑发生异常,这种情况下都会发送一封邮件。
3.2. 事件和监听器
现在,让我们看一下,我们的controller向外发送新的OnRegistrationCompleteEvent 实际的实现,还有处理它的监听器:
例子3.2.1. – The 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. –处理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信息,创建一个验证token,持久化,并且把它作为“确认注册”链接的参数发送。
像上述提到的,任何JavaMailSender 抛出的javax.mail.AuthenticationFailedException 异常,都会被controller处理
3.3. 处理验证Token参数
当用户接收到“确认注册”链接时,他们应该点击它。
一旦他们这样做,controller会从GET请求结果里,提取token属性值,并用它来启用用户。
让我们在例子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();
}
用户将会直接跳转到一个带有相应信息的错误页面,如果:
- 因为某些原因VerificationToken不存在
- VerificationToken过期
查看例子3.3.2.看下错误页面
例子3.3.2. – The 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 和过期的场景,有两个改进的机会:
- 我们可以在后台使用Cron Job检查token的有效期
- 一旦token过期,我们可以给该用户重新获取token的机会
4.在登录过程,添加账号激活检查
如果用户已经启用,我们需要添加检查代码;
让我们来看一下,例子4.1所示MyUserDetailsService的loadUserByUsername 方法。
例子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);
}
}
如我们所见,现在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.适配持久化层
现在让我们提供一个涉及验证token以及用户相关操作的实际的实现
我们将讨论:
- 一个新的VerificationTokenRepository
- 在IUserInterface 接口添加新的方法,并且实现它所需的增删改查的操作。
例子5.1-5.3展示新的接口和实现
例子5.1. – The VerificationTokenRepository
public interface VerificationTokenRepository
extends JpaRepository<VerificationToken, Long> {
VerificationToken findByToken(String token);
VerificationToken findByUser(User user);
}
例子5.2. – The IUserService Interface
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. The 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);
}
}
6. 总结
在本文中,我们扩展了注册过程,包括基于邮件的账号激活过程。
账号激活逻辑需要通过邮件,发送一个验证token给用户。因此他们可以回发给controller,核实他们的身份。