学习 Spring Security(四):使用邮箱激活新账户

http://www.baeldung.com/registration-verify-user-by-email
作者:Eugen Paraschiv
译者:oopsguy.com

1、概述

本文续之前的 Spring Security 系列之注册流程中缺失的部分 — 验证用户的电子邮件以确认帐户

注册确认机制强制用户在成功注册后执行确认注册电子邮件中的操作,以验证其电子邮件地址并激活帐户。用户通过单击电子邮件中的唯一激活链接来完成激活操作。

根据此逻辑,新注册的用户无法登录到系统,除非完成了该流程。

2、验证 Token

我们将使用一个简单的验证令牌作为验证用户的凭据。

2.1、VerificationToken 实体

VerificationToken 实体必须符合以下标准:

  1. 它必须指向 User(通过一个单向关系)
  2. 它将在注册后立即创建
  3. 它将在创建后 24 小时内过期
  4. 有一个唯一、随机生成的值

第 2 和 3 点是注册逻辑的一部分。其余的两个实现位于简单的 VerificationToken 实体中,如示例 2.1。

示例 2.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@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、将 enabled 字段添加到 User 中

当用户注册时,此 enabled 字段将被设置为 false。在帐户验证过程中,如果通过,则置为 true。

添加字段到 User 实体中:

1
2
3
4
5
6
7
8
9
10
11
public class User {
...
@Column(name = "enabled")
private boolean enabled;

public User() {
super();
this.enabled=false;
}
...
}

请注意,我们也将该字段的默认值设置为 false。

3、帐户注册期间

让我们添加两个额外的业务逻辑到用户注册用例中:

  1. User 生成 VerificationToken 并保存
  2. 发送电子邮件进行帐户确认 — 其中包含带有 VerificationToken 值的确认链接

3.1、使用 Spring Event 创建令牌并发送验证邮件

这两个额外的逻辑不应该由控制器直接执行,因为它们是并行的后台任务。

控制器将发布一个 Spring ApplicationEvent 来触发这些任务的执行。这和注入 ApplicationEventPublisher 使用它来发布注册一样简单。

示例 3.1 展示了这个简单的逻辑:

示例 3.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@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、Event 与 Listener

现在让我们看看控制器发出的这个新的 OnRegistrationCompleteEvent 的实际实现,以及要处理它的监听器:

例 3.2.1 — OnRegistrationCompleteEvent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@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 都将由控制器处理。

3.3、处理验证令牌参数

当用户收到确认注册链接时点击它。

一旦点击,控制器将提取 GET 请求中的令牌参数的值,并将使用它来启用 User。

我们来示例 3.3.1 中的这个流程:

例 3.3.1 — RegistrationController 处理注册确认

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@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();
}

如果出现以下情况,用户将被重定向到错误页面并显示相应的信息:

  1. 由于某种原因 VerificationToken 不存在
  2. VerificationToken 已过期

见示例 3.3.2 的错误页面。

例 3.3.2 — badUser.html

1
2
3
4
5
6
7
<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 检查和过期流程中有两个地方可以改进:

  1. 我们可以使用 Cron 作业在后台检查令牌是否过期
  2. 一旦过期,我们可以让用户有机会获得新的令牌

我们将生成新令牌流程推迟到后面的文章再讲,现在假设用户确实在这里成功验证了令牌。

4、将帐户激活检查添加到登录流程

我们需要添加检查用户是否启用的代码:

我们来看示例 4.1。其展示了 MyUserDetailsServiceloadUserByUsername方法。

例 4.1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@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 处显示错误消息:

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

5、适配持久层

现在让我们来看看一些涉及到验证令牌和用户操作的实际实现。

将涵盖以下内容:

  1. 一个新的 VerificationTokenRepository
  2. IUserInterface 中的新方法及其对新 CRUD 操作的实现需求

示例 5.1 — 5.3 展示新的接口和实现:

示例 5.1 — VerificationTokenRepository

1
2
3
4
5
6
7
public interface VerificationTokenRepository 
extends JpaRepository<VerificationToken, Long> {

VerificationToken findByToken(String token);

VerificationToken findByUser(User user);
}

示例5.2 — IUserService 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@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、总结

在本文中,我们已经介绍了注册流程,包括一个基于电子邮件的帐户激活流程。

帐户激活逻辑需要通过电子邮件向用户发送验证令牌,以便他们可以将信息发送回控制器以验证身份。

这个注册与 Spring Security 教程的实现可以在 GitHub 项目中找到 — 这是一个基于 Eclipse 的项目,因此应该很容易导入运行。

原文项目源码