天天看點

Spring Security – 通過電子郵件激活新帳戶

作者:農非農

1. 概述

本文繼續正在進行的 Spring 安全注冊系列,其中包含注冊過程中缺失的部分之一 - 驗證使用者的電子郵件以确認其帳戶。

注冊确認機制強制使用者回複成功注冊後發送的“确認注冊”電子郵件,以驗證其電子郵件位址并激活其帳戶。使用者通過單擊通過電子郵件發送給他們的唯一激活連結來執行此操作。

按照此邏輯,新注冊的使用者将無法登入系統,直到此過程完成。

2. 驗證令牌

我們将使用簡單的驗證令牌作為驗證使用者的關鍵工件。

2.1.驗證令牌實體

驗證令牌實體必須滿足以下條件:

  1. 它必須連結回使用者(通過單向關系)
  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 = false,以確定VerificationToken<->使用者關聯中的資料完整性和一緻性。

2.2. 将啟用的字段添加到使用者

最初,注冊使用者時,此啟用字段将設定為 false。在帳戶驗證過程中 - 如果成功 - 它将變為true。

讓我們首先将字段添加到我們的使用者實體:

public class User {
    ...
    @Column(name = "enabled")
    private boolean enabled;
    
    public User() {
        super();
        this.enabled=false;
    }
    ...
}           

3. 注冊賬戶時

讓我們向使用者注冊用例添加兩個額外的業務邏輯:

  1. 為使用者生成驗證令牌并持久化它
  2. 發送電子郵件以進行帳戶确認 - 其中包括帶有驗證令牌值的确認連結

3.1. 使用 Spring 事件建立令牌并發送驗證電子郵件

這兩個附加邏輯不應由控制器直接執行,因為它們是“附屬”後端任務。控制器将釋出一個 Spring 應用程式事件來觸發這些任務的執行。這就像注入應用程式事件釋出者,然後使用它來釋出注冊完成一樣簡單。

例 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.– 注冊完成事件

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" + "http://localhost:8080" + confirmationUrl);
        mailSender.send(email);
    }
}           

在這裡,确認注冊方法将接收OnRegistrationCompleteEvent,從中提取所有必要的使用者資訊,建立驗證令牌,保留它,然後将其作為“确認注冊”連結中的參數發送。

如上所述,JavaMailSender 抛出的任何 javax.mail.AuthenticationFailedException 都将由控制器處理。

3.3. 處理驗證令牌參數

當使用者收到“确認注冊”連結時,他們應該點選它。一旦他們這樣做 - 控制器将在生成的GET請求中提取令牌參數的值,并使用它來啟用使用者。讓我們在例 3.3.1 中看看這個過程:

例 3.3.1.– 注冊控制器處理注冊确認

@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. 驗證令牌不存在,由于某種原因
  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>           

如果未發現錯誤,則啟用該使用者。在處理驗證令牌檢查和過期方案時,有兩個改進機會:

  1. 我們可以使用 Cron 作業在背景檢查令牌過期
  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不使用使用者的啟用标志 - 是以它隻允許啟用的使用者進行身份驗證。

現在,我們将添加一個 AuthenticationFailureHandler 來自定義來自 MyUserDetailsService 的異常消息。我們的 CustomAuthenticationFailureHandler 如例 4.2 所示。:

例 4.2.– 自定義身份驗證失敗處理程式:

@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. 一個新的驗證令牌存儲庫
  2. IUser接口中的新方法及其對新 CRUD 操作的實作需要

例 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. 結論

在本文中,我們擴充了注冊過程,以包括基于電子郵件的帳戶激活過程。