Смена email и слияние аккаунтов¶
Логика переноса аккаунта при смене email в modulauth.
Обзор флоу¶
При смене email система определяет два пути в зависимости от того, занят ли новый адрес активным аккаунтом.
Пользователь вводит новый email
↓
findActiveByEmail(newEmail) — только не-удалённые аккаунты
↓
Занят? ──Да──→ Слияние (mergeIntent=true)
│
Нет
↓
OTP → текущий email (владелец изменяемого аккаунта подтверждает)
↓
Обычная смена: updateEmail(currentEmail, newEmail)
↓
Письма на оба адреса
Принцип безопасности
OTP всегда отправляется на текущий email — тот, которым вошёл пользователь. Только владелец изменяемого аккаунта может подтвердить смену. Контроль над новым адресом не даёт права на подтверждение.
Обычная смена email (новый адрес свободен)¶
Условие: findActiveByEmail(newEmail) возвращает пусто.
- Генерируется 6-значный OTP, действителен 15 минут
sendChangeEmailOtp(currentEmail, newEmail, otp)— письмо на старый адрес- Пользователь открывает
/auth/protected?emailStep=confirm#accountи вводит код updateEmail(currentEmail, newEmail)в таблицеusersfinalizeEmailChange— обновляет сессию, отправляет уведомления на оба адреса
Уведомления после смены¶
| Получатель | Содержание |
|---|---|
| Старый email | «Ваш email old@… отвязан. Теперь привязан new@…. Если не вы — пишите в поддержку.» |
| Новый email | «Email new@… теперь привязан к аккаунту dix.su вместо old@…. Войти в кабинет →» |
Слияние аккаунтов (новый адрес занят)¶
Условие: findActiveByEmail(newEmail) находит активный аккаунт.
- OTP на текущий email, в сессию кладётся
mergeIntent=true - После подтверждения:
mergeAccounts(currentUser, targetUser, chosenSlug) - Победитель — аккаунт с меньшим
id(зарегистрированный раньше) - Данные loser-а переносятся к winner-у
- Loser немедленно физически удаляется (
hardDeleteLoser) updateEmail(winner.getEmail(), newEmail)— email winner-а меняется на новый- Уведомления на оба адреса
Что переносится от 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» |