Перейти к содержанию

Смена email и слияние аккаунтов

Логика переноса аккаунта при смене email в modulauth.


Обзор флоу

При смене email система определяет два пути в зависимости от того, занят ли новый адрес активным аккаунтом.

Пользователь вводит новый email
        ↓
findActiveByEmail(newEmail)  — только не-удалённые аккаунты
        ↓
   Занят?  ──Да──→ Слияние (mergeIntent=true)
       │
      Нет
       ↓
   OTP → текущий email (владелец изменяемого аккаунта подтверждает)
       ↓
   Обычная смена: updateEmail(currentEmail, newEmail)
       ↓
   Письма на оба адреса

Принцип безопасности

OTP всегда отправляется на текущий email — тот, которым вошёл пользователь. Только владелец изменяемого аккаунта может подтвердить смену. Контроль над новым адресом не даёт права на подтверждение.


Обычная смена email (новый адрес свободен)

Условие: findActiveByEmail(newEmail) возвращает пусто.

  1. Генерируется 6-значный OTP, действителен 15 минут
  2. sendChangeEmailOtp(currentEmail, newEmail, otp) — письмо на старый адрес
  3. Пользователь открывает /auth/protected?emailStep=confirm#account и вводит код
  4. updateEmail(currentEmail, newEmail) в таблице users
  5. finalizeEmailChange — обновляет сессию, отправляет уведомления на оба адреса

Уведомления после смены

Получатель Содержание
Старый email «Ваш email old@… отвязан. Теперь привязан new@…. Если не вы — пишите в поддержку.»
Новый email «Email new@… теперь привязан к аккаунту dix.su вместо old@…. Войти в кабинет →»

Слияние аккаунтов (новый адрес занят)

Условие: findActiveByEmail(newEmail) находит активный аккаунт.

  1. OTP на текущий email, в сессию кладётся mergeIntent=true
  2. После подтверждения: mergeAccounts(currentUser, targetUser, chosenSlug)
  3. Победитель — аккаунт с меньшим id (зарегистрированный раньше)
  4. Данные loser-а переносятся к winner-у
  5. Loser немедленно физически удаляется (hardDeleteLoser)
  6. updateEmail(winner.getEmail(), newEmail) — email winner-а меняется на новый
  7. Уведомления на оба адреса

Что переносится от loser к winner

Данные Действие
Поля профиля (имя, биография, аватар) mergeProfileFields — winner приоритетнее
Ссылки в визитке profileLinkRepository.moveToUser
Короткие ссылки shortLinkRepository.moveToUser
Соцсети (verified) Дубли удаляются, остальные переносятся
Верифицированные сайты Дубли удаляются, остальные переносятся
Верифицированные email-адреса Переносятся
OAuth-провайдеры authProviderRepository.moveProvidersToUser
Туннель loser-а Отключается (disconnectTunnel)
Slug loser-а Опционально передаётся winner-у (если chosenSlug совпадает)

Слияние необратимо

Победитель определяется автоматически по наименьшему id. При конфликте полей профиля данные winner-а приоритетнее.


Поведение soft-deleted аккаунтов

Soft-delete — аккаунт помечен как удалённый (deleted_at IS NOT NULL) и ожидает физического удаления через 30 дней.

При запросе смены email

Статус нового email Поведение
Активный аккаунт Предлагается слияние
Soft-deleted аккаунт findActiveByEmail возвращает пусто → обычная смена
Не существует Обычная смена

Если winner при слиянии был soft-deleted

В конце mergeAccounts вызывается userRepository.restoreAccount(winner.getId()) — флаг удаления снимается, иначе планировщик purgeExpiredAccounts (03:00 каждую ночь) удалил бы winner-а вместе со всеми слитыми данными.

Что происходит с loser-ом

Все данные уже перенесены. Loser немедленно физически удаляется через hardDeleteLoser (таблицы: email_verification_tokens, github_verify_tokens, youtube_verify_tokens, rutube_verify_tokens, catalog_entries, deleted_short_links, users).

Зомби-строки устранены

Ранее loser оставался в базе с email merged_<id>@noemail.dix.su без deleted_at — никогда не чистился. Теперь удаляется сразу после переноса данных.


Уведомления

Событие Письмо Получатель Метод
Запрос OTP «Подтверждение смены email» Текущий email sendChangeEmailOtp(currentEmail, newEmail, otp)
Смена подтверждена — старый адрес «Email аккаунта изменён» Старый email sendEmailChangedOldNotification
Смена подтверждена — новый адрес «Email аккаунта изменён» Новый email sendEmailChangedNewNotification

Отправка писем выполняется асинхронно через .subscribe() в finalizeEmailChange — ошибка отправки не блокирует редирект пользователя.


Исправленные баги (2026-09-29)

Баг Симптом Исправление
OTP на новый email Любой, кто владеет целевым ящиком, мог подтвердить слияние без согласия текущего владельца OTP идёт на currentEmail
Soft-deleted считался «занятым» findByEmail возвращал удалённые аккаунты → лишний мёрж-флоу Новый метод findActiveByEmail с AND deleted_at IS NULL
Зомби-строка loser-а После мержа loser оставался в БД, никогда не чистился hardDeleteLoser сразу после переноса данных
Winner с soft-delete Планировщик удалял winner-а и все слитые данные в 03:00 restoreAccount(winner.getId()) в конце mergeAccounts
Страница слетала с #account Редиректы после OTP-запроса не содержали #account Все redirectTo добавляют #account; JS auto-scroll при наличии ?emailStep

Файлы реализации

Файл Изменения
controller/AuthController.java OTP на currentEmail; #account в редиректах; новый POST /auth/email-change-admin-request; finalizeEmailChange(session, exchange, oldEmail, newEmail)
service/UnifiedAuthService.java mergeAccounts: hardDeleteLoser + restore winner если soft-deleted
service/EmailService.java 3-arg sendChangeEmailOtp; sendEmailChangedOldNotification; sendEmailChangedNewNotification; шаблоны emailChangedOldHtml, emailChangedNewHtml
repository/UserRepository.java Новый запрос findActiveByEmail (AND deleted_at IS NULL)
service/UserService.java Публичный метод findActiveByEmail
templates/protected-page.html OTP step2 поясняет что код идёт на текущий email; JS auto-scroll в #account; форма «Нет доступа к старому email»