Bài viết gốc bạn có thể xem ở đây.

1. Tổng quát


Đây là bài viết tiếp tục của chuỗi bài viết về đăng ký trong Spring Security, đây là phần còn thiếu trong quá trình đăng ký - kích hoạt tài khoản mới bằng email của người sử dụng.

Cơ chế xác nhận đăng ký buộc người dùng phải phản hồi một email “xác nhận đăng ký” gửi sau khi đăng ký thành công để xác thực tài khoản email đăng ký và kích hoạt tài khoản của họ. Người dùng phải kích vào đường dẫn kích hoạt duy nhất được gửi vào email của họ.

Theo logic như vậy, một tài khoản đăng ký mới sẽ không thể vào được hệ thống đến khi quá trình này hoàn thành.

2. Token xác thực

Chúng ta sẽ sử dụng một token xác thực đơn giản làm cấu phần chính để xác minh người dùng.

2.1. Entity VerificationToken

Entity VerificationToken bắt buộc phải có các tiêu chí sau:

  1. Nó phải liên kết với User (thông qua quan hệ một chiều)
  2. Nó sẽ tạo ra ngay sau khi đăng ký
  3. Nó sẽ có hạn sử dụng là 24 giờ sau khi nó được tạo ra
  4. Có một giá trị duy nhất và ngẫu nhiên được tạo ra

Yêu cầu 2 và 3 là một phần trong logic đăng ký. Hai cái còn lại được triển khai trong một entity VerificationToken đơn giản giống như trong Ví dụ 2.1.:

Ví dụ 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
}

Chú ý nullable = false trong User đảm bảo dữ liệu toàn vẹn và nhất quán trong mối quan hệ VerificationToken <-> User.

2.2. Thêm trường enabled vào User

Ban đầu, khi User được đăng ký, trường enabled sẽ được để là false. Suốt quá trình xác thực tài khoản - nếu thành công - nó sẽ trở thành true.

Nào cùng bắt đầu thêm trường này vào entity User của chúng ta:

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

Hãy chú ý cách chúng ta đặt lại giá trị mặc định của trường này thành false.

3. Trong khi đăng ký tài khoản

Cùng thêm hai phần của của logic nghiệp vụ bổ sung vào trường hợp người dùng đăng ký:

  1. Sinh ra VerificationToken cho User và lưu nó.
  2. Gửi email cho cho tài khoản xác thực - nó bao gồm đường dẫn xác thực với giá trị của VerificationToken

3.1. Sử dụng một Spring Event để tạo token và gửi cho email xác thực

Hai phần logic bổ sung này không nên được thực hiện trực tiếp bởi controller vì nó “phụ thuộc” vào nhiệm vụ của back-end.Controller sẽ công khai một ApplicationEvent Spring để kích hoạt việc thực thi tác vụ này. Nó đơn giản được inject vào ApplicationEventPublisher và sau đó công khai để hoàn thành đăng ký.
Ví dụ 3.1. sẽ show logic đơn giản này:
Ví dụ 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);
}

Một thứ thêm vào cần chú ý là khối try catch khi xử lý sự kiện. Một phần của code sẽ hiển thị một trang lỗi bất cứ khi nào có exception trong khi thực thi logic sau khi xử lý sự kiện, trong trường hợp gửi email.

3.2. Event và Listener

Bây giờ sẽ triển khai OnRegistrationCompleteEvent mới mà controller của chúng ta gửi đi, cũng như listener sẽ xử lý nó:

Ví dụ 3.2.1. - OnRegistrantionCompleteEvent

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
}

Ví dụ 3.2.2. - RegistrationListener xử lý OnRegistratrionCompleteEvent

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


Ở đây, phương thức confirmRegistration sẽ nhận được từ OnRegistrationCompleteEvent, trích xuất tất của thông tin User cần thiết từ nó, tạo token xác thực, lưu nó, và sau đó gửi nó như như một đối số trong đường dẫn “Confirm Registration”.

Như đã đề cập bên trên, bất kì javax.mail.AuthenticationFailedException sẽ được ném ra bởi JavaMailSender sẽ được xử lý bởi controller.

3.3. Xử lý đối số token xác thực

Khi người dùng nhận được đường dẫn “Confirm Registration” học sẽ click vào nó.

Khi họ làm điều này - controller sẽ trích xuất giá trị của đối số token trong kết quả của yêu cầu GET và sẽ dùng nó để kích hoạt User.

Hãy xem quá trình này trong Ví dụ 3.3.1.:

Ví dụ 3.3.1. - Quá trình RegistrationController xác thực đăng ký 

@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(); 
}

Người dùng sẽ chuyển hướng tới trang lỗi với thông báo được trả về nếu:

  1. VerificationToken không tồn tại vì lý do nào đó hoặc
  2. VerificationToken hết hạn

Xem Ví dụ 3.2.2. để biết thêm về trang thông báo lỗi.

Ví dụ 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>

Nếu không có lỗi, người dùng sẽ được kích hoạt.

Có hai cách để tăng khả năng kiểm tra và xử lý hết hạn VerificationToken:

  1. Chúng ta có thể sử dụng Cron Job để kiểm tra hạn của token ở đằng sau
  2. Chúng ta có thể cho người sử dụng cơ hội lấy một token mới khi nó đã hết hạn

Chúng ta sẽ hoãn lại việc tạo ra một token mới trong bài viết trong tương lai và giả định rằng người dùng thực sự xác minh thành công bằng token của họ tại đây.

4. Thêm kiểm tra tài khoản đã kích hoạt trong quá trình đăng nhập

Chúng ta cần thêm code để kiểm tra nếu người dùng đã được kích hoạt:

Cùng xem Ví dụ 4.1. sẽ trình bày phương thức loadUserByUsername của MyUserDetailsService.

Ví dụ 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);
    }
}

Như chúng ta có thể thấy, bây giờ MyUserDetailsService không sử dụng để gắn cờ enabled (kích hoạt) cho người dùng - và vì vậy nó luôn luôn kích hoạt người dùng đã xác thực.

Bây giờ, chúng ta sẽ thêm AuthenticationFailureHandler để tuỳ chỉnh thông báo exception đến MyUserDetailsService. CustomAuthenticationFailureHandler sẽ được trình bày ở ví dụ 4.2.:

Ví dụ 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);
    }
}

Chúng ta cần thay đổi login.html để hiển thị thông báo lỗi.

Ví dụ 4.3. - Hiển thị thông báo lỗi tại login.html:

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

5. Điều chỉnh lớp Persistence

Bây giờ chúng ta sẽ cùng cấp cách triển khai thực tế của một số hoạt động lên quan đến token xác minh của người dùng.

 Chúng ta sẽ xử lý:

  1. VerificationTokenRepository mới
  2. Phương thức mới trong IUserInterface và triển khai CRUD cần thiết

Ví dụ 5.1 - 5.3. sẽ trình bày interface mới này và triển khai nó:

Ví dụ 5.1. - VerificationTokenRepository

public interface VerificationTokenRepository 
  extends JpaRepository<VerificationToken, Long> {

    VerificationToken findByToken(String token);

    VerificationToken findByUser(User user);
}

Ví dụ 5.2. - Interface 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);
}

Ví dụ 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.  Tổng kết

Trong bài viết này, chúng ta đã mở rộng quy trình đăng ký bao gồm quy trình kích hoạt tài khoản dựa trên email.

Logic kích hoạt tài khoản yêu cầu gửi một token kích hoạt tài khoản từ email và từ email này sẽ gửi lại thông tin cho controller để kích hoạt tài khoản của họ.

Các đoạn code trên này có thể xem ở GitHub này - đây là project ở Eclipse vì vậy nó rất dễ dàng import và chạy nó.