1. Обзор
Эта статья продолжает текущую серию « Регистрация в Spring Security
» с одним из недостающих элементов процесса регистрации — проверкой электронной почты пользователя для подтверждения его учетной записи .
Механизм подтверждения регистрации заставляет пользователя ответить на электронное письмо « Подтвердить регистрацию
», отправленное после успешной регистрации, чтобы подтвердить свой адрес электронной почты и активировать свою учетную запись. Пользователь делает это, щелкая уникальную ссылку активации, отправленную ему по электронной почте.
Следуя этой логике, вновь зарегистрированный пользователь не сможет войти в систему, пока этот процесс не будет завершен.
2. Токен подтверждения
Мы будем использовать простой токен проверки в качестве ключевого артефакта, с помощью которого проверяется пользователь.
2.1. Объект VerificationToken
_
Сущность VerificationToken
должна соответствовать следующим критериям:
- Он должен ссылаться на
пользователя
(через однонаправленное отношение) - Он будет создан сразу после регистрации
- Срок действия истекает в течение 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
}
Обратите внимание на значение nullable = false
для пользователя, чтобы обеспечить целостность и согласованность данных в ассоциации VerificationToken<
-> User .
2.2. Добавить включенное
поле для пользователя
Первоначально, когда Пользователь
зарегистрирован, для этого включенного
поля будет установлено значение false
. В процессе проверки учетной записи — в случае успеха — станет правдой
.
Давайте начнем с добавления поля в нашу сущность User :
public class User {
...
@Column(name = "enabled")
private boolean enabled;
public User() {
super();
this.enabled=false;
}
...
}
Обратите внимание, как мы также установили значение по умолчанию для этого поля равным false
.
3. Во время регистрации аккаунта
Давайте добавим две дополнительные части бизнес-логики к варианту использования регистрации пользователя:
- Создайте
VerificationToken
для пользователя и сохраните его - Отправьте сообщение электронной почты для подтверждения учетной записи, которое включает ссылку подтверждения со значением VerificationToken
.
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?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();
}
Пользователь будет перенаправлен на страницу ошибки с соответствующим сообщением, если:
VerificationToken
не существует по какой - то причине или- Срок действия
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 :
- Мы можем использовать задание Cron для проверки истечения срока действия токена в фоновом режиме.
- Мы можем дать пользователю возможность получить новый токен после истечения срока его действия.
Мы отложим создание нового токена до следующей статьи и предположим, что пользователь действительно успешно подтвердил свой токен здесь.
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. Адаптация уровня сохраняемости
Давайте теперь обеспечим фактическую реализацию некоторых из этих операций с использованием токена проверки, а также пользователей.
Мы рассмотрим:
- Новый
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. Пользовательская служба
@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, поэтому его легко импортировать и запускать как есть.