註冊 – 通過電子郵件激活新賬户

Spring Security
Remote
0
09:19 PM · Nov 29 ,2025

1. 概述

本文檔繼續介紹 使用 Spring Security 註冊 系列,其中缺少註冊過程中的一個關鍵環節——驗證用户郵箱以確認其賬户

註冊確認機制要求用户在成功註冊後,通過回覆 “確認註冊” 郵件來驗證其郵箱地址並激活其賬户。用户通過點擊郵件中發送的唯一激活鏈接來完成此操作。

遵循以上邏輯,新註冊的用户在完成此過程之前無法登錄系統。

2. 驗證令牌

我們將使用一個簡單的驗證令牌作為關鍵工件,用於驗證用户。

2.1. VerificationToken 實體

VerificationToken 實體必須滿足以下標準:

  1. 它必須鏈接回 User(通過單向關係)
  2. 它將在註冊後創建
  3. 它將在創建後到期,最多24小時
  4. 具有一個唯一且隨機生成的

第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
}

注意nullable = falseUser 上,以確保VerificationTokenUser 之間的關聯中的數據完整性和一致性。

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. During Account Registration

讓我們添加兩個額外的業務邏輯到用户註冊用例中:

  1. 生成VerificationToken用於用户,並將其持久化
  2. 發送賬户確認電子郵件——其中包含帶有VerificationToken’s值的確認鏈接

3.1. Using a Spring Event to Create the Token and Send the Verification Email

這兩個額外的邏輯不應由控制器直接執行,因為它們是“附帶”的後端任務。

控制器將發佈一個 Spring ApplicationEvent來觸發這些任務的執行。這就像注入 ApplicationEventPublisher然後使用它來發布註冊完成一樣。

示例 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. The Event and the Listener

現在讓我們看看控制器的發送的這個新的 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.The 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 信息,創建驗證令牌,並將其發送為“Confirm Registration” 鏈接中的參數。

如上所述,由 JavaMailSender 拋出的任何 AuthenticationFailedException 將由控制器處理。

3.3. Processing the Verification Token Parameter

當用户收到“Confirm Registration” 鏈接時,他們應該點擊它。

一旦他們點擊了它——控制器將提取 GET 請求中 token 參數的值,並使用它來啓用 User

讓我們看看這個過程在示例 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(); 
}

用户將在以下情況下重定向到錯誤頁面:

  1. VerificationToken 不存在,或者
  2. VerificationToken 已過期

查看示例 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 檢查和過期場景方面,存在兩個改進機會:

  1. 我們可以使用 Cron Job 在後台檢查令牌的過期情況
  2. 我們可以給用户提供在令牌過期後獲取新令牌的機會

我們將推遲為新令牌生成的時間,並假設用户確實成功驗證了其令牌。

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. 調整持久化層

現在,我們將提供涉及驗證令牌和用户的一些操作的實際實現。

我們將涵蓋:

  1. 一個新的 VerificationTokenRepository
  2. 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. 結論

在本文中,我們已將註冊流程擴展到包括 基於電子郵件的賬户激活程序

賬户激活邏輯需要通過電子郵件將驗證令牌發送給用户,以便他們將其返回到控制器以驗證其身份。

下一條 »
Spring Security 註冊 – 發送驗證郵件
«
之前的
Spring Security 註冊流程
user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.