• 使用 Spring Security 的註冊流程
• 使用 Spring Security 註冊 – 密碼編碼
• 註冊 API 變為 RESTful
• Spring Security – 重置您的密碼
• 註冊 – 密碼強度和規則
• 更新您的密碼
• 通知用户從新設備或位置登錄
1. 概述
本文檔延續了《註冊 Spring Security》系列文章,其中缺失的一環是驗證用户郵箱以確認其賬户的過程。
註冊確認機制要求用户在成功註冊後,對“確認註冊”郵件作出響應,以驗證其郵箱地址並激活其賬户。用户通過點擊發送給他們的唯一激活鏈接來完成此操作。
遵循這一邏輯,新註冊的用户在完成此過程之前無法登錄系統。
2. 驗證令牌
我們將使用一個簡單的驗證令牌作為關鍵工件,通過它來驗證用户身份。
2.1. VerificationToken 實體
VerificationToken 實體必須滿足以下標準:
- 它必須與 User (通過單向關係) 關聯
- 它將在註冊後立即創建
- 它的有效期將在創建後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 字段
在 User 註冊時,enabled 字段最初將被設置為 false。 在賬户驗證過程中——如果成功——它將變為 true。
讓我們首先在 User 實體中添加該字段:
public class User {
...
@Column(name = "enabled")
private boolean enabled;
public User() {
super();
this.enabled=false;
}
...
}請注意,我們還將此字段的默認值設置為 false。
3. 賬户註冊期間
以下將兩個額外的業務邏輯添加到用户註冊用例中:
- 生成用户的 驗證令牌,並將其持久化。
- 發送賬户確認電子郵件 – 其中包含帶有 驗證令牌 值的確認鏈接。
3.1. 使用 Spring 事件創建 Token 併發送驗證郵件
這兩段邏輯不應由控制器直接執行,因為它們是“輔助”後端任務。
控制器將發佈一個 Spring <em >ApplicationEvent</em> 以觸發這些任務的執行。這就像注入 <i >ApplicationEventPublisher</i> 並使用它來發布註冊完成一樣簡單。
示例 3.1 展示了這種簡單邏輯:
示例 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. 事件與監聽器
現在,讓我們查看控制器的實際實現,即發送的全新 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?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" + confirmationUrl);
mailSender.send(email);
}
}在此,confirmRegistration 方法將接收 OnRegistrationCompleteEvent,從中提取所有必要的 User 信息,創建驗證令牌,持久化存儲,並將其作為參數發送到 “確認註冊” 鏈接。
正如上面所提到的,JavaMailSender 拋出的任何 jakarta.mail.AuthenticationFailedException 都將被控制器處理。
3.3. 處理驗證令牌參數
當用户收到“確認註冊”鏈接時,應點擊該鏈接。
一旦他們點擊後,控制器將從生成的 GET 請求中提取令牌參數的值,並將其用於啓用用户。
讓我們在示例 3.3.1 中查看此過程:
示例 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);
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 的驗證和過期場景方面,存在兩個改進機會:
- 我們可以使用 Cron Job 在後台檢查令牌的過期情況
- 我們可以 在令牌過期後,給予用户獲取新令牌的機會
我們將延遲生成新令牌,並假設用户在此處確實成功驗證了其令牌。
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 不再使用用户指定的 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. 調整持久化層
現在,我們將提供涉及驗證令牌和用户的實際實現。
我們將涵蓋:
- 一個新的 VerificationTokenRepository
- 在 IUserInterface 中新增的方法,以及用於新 CRUD 操作的實現
示例 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 userDto)
throws UserAlreadyExistException;
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 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. 結論
在本文中,我們已將註冊流程擴展到包含基於電子郵件的賬户激活程序。
賬户激活邏輯需要通過電子郵件將驗證令牌發送給用户,以便他們將其返回到控制器進行身份驗證。