«1. Обзор

Эта статья продолжает текущую серию «Регистрация в Spring Security» с одной из недостающих частей процесса регистрации — подтверждением электронной почты пользователя для подтверждения его учетной записи.

Механизм подтверждения регистрации заставляет пользователя ответить на электронное письмо «Подтвердить регистрацию», отправленное после успешной регистрации, чтобы подтвердить свой адрес электронной почты и активировать свою учетную запись. Пользователь делает это, щелкая уникальную ссылку активации, отправленную ему по электронной почте.

Следуя этой логике, вновь зарегистрированный пользователь не сможет войти в систему, пока этот процесс не завершится.

2. Токен проверки

Мы будем использовать простой токен проверки в качестве ключевого артефакта, с помощью которого проверяется пользователь.

2.1. Объект VerificationToken

Объект VerificationToken должен соответствовать следующим критериям:

  1. It must link back to the User (via a unidirectional relation)
  2. It will be created right after registration
  3. It will expire within 24 hours following its creation
  4. Has a unique, randomly generated value

Требования 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\u003c-\u003eUser.

2.2. Добавить разрешенное поле к пользователю

Изначально, когда пользователь зарегистрирован, для этого включенного поля будет установлено значение false. Во время процесса проверки учетной записи — в случае успеха — это станет правдой.

Давайте начнем с добавления поля в нашу сущность User:

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

Обратите внимание, что мы также установили значение по умолчанию для этого поля равным false.

3. Во время регистрации учетной записи

Давайте добавим две дополнительные части бизнес-логики к варианту использования регистрации пользователя:

  1. Generate the VerificationToken for the User and persist it
  2. Send out the email message for account confirmation – which includes a confirmation link with the VerificationToken’s value

3.1. Использование события Spring для создания токена и отправки письма с подтверждением

Эти две дополнительные части логики не должны выполняться контроллером напрямую, поскольку они являются «побочными» внутренними задачами.

Контроллер опубликует 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. Событие и слушатель

Давайте теперь посмотрим на реальную реализацию этого нового события 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.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);
    }
}

Здесь метод confirmRegistration получает событие OnRegistrationCompleteEvent, извлекает из него всю необходимую информацию о пользователе, создает токен проверки, сохраняет его и затем отправляет в качестве параметра в Ссылка «Подтвердить регистрацию».

Как упоминалось выше, любое исключение javax.mail.AuthenticationFailedException, сгенерированное JavaMailSender, будет обрабатываться контроллером.

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

Пользователь будет перенаправлен на страницу ошибки с соответствующим сообщением, если:

  1. The VerificationToken does not exist, for some reason or
  2. The VerificationToken has expired

См. пример 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. We can use a Cron Job to check for token expiration in the background
  2. We can give the user the opportunity to get a new token once it has expired

«Мы отложим создание нового токена до следующей статьи и предположим, что пользователь действительно успешно подтвердил свой токен здесь.

4. Добавление проверки активации учетной записи к процессу входа в систему

Нам нужно добавить код, который будет проверять, включен ли пользователь:

Давайте посмотрим на это в примере 4.1. который показывает метод loadUserByUsername MyUserDetailsService.

Пример 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. – 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. A new VerificationTokenRepository
  2. New methods in the IUserInterface and its implementation for new CRUD operations needed

Примеры 5.1 – 5.3. показать новые интерфейсы и реализацию:

Пример 5.1. — The 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 можно найти в проекте GitHub — это проект на основе Eclipse, поэтому его должно быть легко импортировать и запускать как есть.

Next »

Spring Security Registration – Resend Verification Email

« Previous

The Registration Process With Spring Security