Добавить OAuth-провайдер

================================================================ Дата последнего обновления: 2026-05-23 Автор: Claude (на основе реализации Google/GitHub/VK + механизма link_mode/merge)

Этот файл описывает ВСЕ точки изменений при добавлении нового провайдера авторизации (например, Telegram, Apple, Discord, Twitter/X и т.д.). Читай его полностью перед началом работы.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 1. ДВА ТИПА ПРОВАЙДЕРОВ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Тип A — Стандартный Spring OAuth2 (Google, GitHub, Yandex и любой OIDC/OAuth2) → Spring сам выполняет OAuth2 flow, вызывает OAuth2AuthSuccessHandler → Нужно: ClientRegistration в SecurityConfig + обновить switch-ветки в обработчике

Тип Б — Кастомный фильтр (VK и те, где Spring OAuth2 не работает из коробки) → Нужно написать отдельный WebFilter по образцу VkCallbackFilter.java → Все ветки (link_mode, conflict, normal login) реализовать вручную → Зарегистрировать фильтр в SecurityConfig

Определяй тип по наличию стандартного OIDC/OAuth2 у провайдера.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 2. ФАЙЛЫ И ЧТО В НИХ МЕНЯТЬ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Модуль: /root/dixsu/modulauth/ Рабочая ветка: prod_update

───────────────────────────────────────────────────────────────────────────── 2.1 SecurityConfig.java src/main/java/com/dixsu/server/security/SecurityConfig.java ─────────────────────────────────────────────────────────────────────────────

[ ] Для Типа А: добавить ClientRegistration в метод clientRegistrationRepository(): .registration(ClientRegistrations .fromIssuerLocation("https://accounts.NEW-PROVIDER.com") // OIDC .registrationId("newprovider") .clientId("${NEW_PROVIDER_CLIENT_ID}") .clientSecret("${NEW_PROVIDER_CLIENT_SECRET}") .scope("openid", "email", "profile") .build()) Либо через application.properties: spring.security.oauth2.client.registration.newprovider.client-id=... spring.security.oauth2.client.registration.newprovider.client-secret=... spring.security.oauth2.client.registration.newprovider.scope=openid,email,profile spring.security.oauth2.client.provider.newprovider.authorization-uri=... spring.security.oauth2.client.provider.newprovider.token-uri=... spring.security.oauth2.client.provider.newprovider.user-info-uri=... spring.security.oauth2.client.provider.newprovider.user-name-attribute=id

[ ] Для Типа Б: зарегистрировать кастомный фильтр: @Bean public NewProviderCallbackFilter newProviderCallbackFilter(...) { ... } И добавить в цепочку (в http.addFilterBefore или через .securityWebFilterChain): .addFilterBefore(newProviderCallbackFilter(...), SecurityWebFiltersOrder.AUTHENTICATION) Разрешить callback URL без аутентификации: .pathMatchers("/auth/newprovider/callback").permitAll()

───────────────────────────────────────────────────────────────────────────── 2.2 OAuth2AuthSuccessHandler.java (только для Типа А) src/main/java/com/dixsu/server/security/OAuth2AuthSuccessHandler.java ─────────────────────────────────────────────────────────────────────────────

[ ] extractProviderId(OAuth2User user, String registrationId): Добавить ветку в switch: case "newprovider" -> user.getAttribute("sub"); // или "id"

[ ] extractEmail(OAuth2User user, String registrationId): case "newprovider" -> user.getAttribute("email");

[ ] extractName(OAuth2User user, String registrationId): case "newprovider" -> user.getAttribute("name");

[ ] extractSocialUsername(OAuth2User user, String registrationId): case "newprovider" -> user.getAttribute("login"); // или "username" ВАЖНО: это имя уходит в user_verified_socials.username

[ ] extractAvatar(OAuth2User user, String registrationId): case "newprovider" -> user.getAttribute("avatar_url"); // или "picture"

Всё остальное (link_mode ветка, conflict ветка, context restore,
upsertVerifiedSocial) — уже реализовано обобщённо и работает автоматически.

───────────────────────────────────────────────────────────────────────────── 2.3 NewProviderCallbackFilter.java (только для Типа Б) src/main/java/com/dixsu/server/security/ ─────────────────────────────────────────────────────────────────────────────

Создать по образцу VkCallbackFilter.java. Обязательные блоки:

[ ] Инжектировать: UnifiedAuthService, UserService, WebSessionServerSecurityContextRepository

[ ] Обработка link_mode (проверять ПЕРВЫМ в основном flatMap): Long linkModeUserId = (Long) session.getAttributes().get("linkModeUserId"); if (linkModeUserId != null) { return unifiedAuthService.linkOAuthProviderToUser( linkModeUserId, "newprovider", providerId, displayName, avatarUrl) .flatMap(result -> { if ("ok".equals(result)) { // уведомить UI session.getAttributes().remove("linkModeUserId"); session.getAttributes().remove("linkModeProvider"); // восстановить security context оригинального пользователя return unifiedAuthService.findById(linkModeUserId) .flatMap(origUser -> { var auth = new UsernamePasswordAuthenticationToken( origUser.getEmail(), null, Collections.singletonList( new SimpleGrantedAuthority("ROLE_" + origUser.getRole()))); return secContextRepo.save(exchange, new SecurityContextImpl(auth)); }) .then(upsertVerifiedSocial(linkModeUserId, "newprovider", socialUsername)) .then(redirect(exchange, "/auth/protected?linked=newprovider")); } // conflict: long otherId = Long.parseLong(result.substring(9)); session.getAttributes().put("mergeCandidateId", otherId); session.getAttributes().put("mergeCandidateProvider", "newprovider"); return Mono.zip( unifiedAuthService.findById(otherId), unifiedAuthService.findById(linkModeUserId)) .flatMap(t -> { var candidate = t.getT1(); var origUser = t.getT2(); if (candidate.getSlug() != null && !candidate.getSlug().isBlank()) { session.getAttributes().put("mergeCandidateSlug", candidate.getSlug()); } var origAuth = new UsernamePasswordAuthenticationToken( origUser.getEmail(), null, Collections.singletonList( new SimpleGrantedAuthority("ROLE_" + origUser.getRole()))); return secContextRepo.save(exchange, new SecurityContextImpl(origAuth)); }) .then(redirect(exchange, "/auth/protected?mergeOffer=true")); }); }

[ ] Нормальный вход (если нет linkModeUserId): unifiedAuthService.findOrCreate(email, "newprovider", providerId, displayName, avatarUrl) .flatMap(user -> { session.getAttributes().put("authenticatedUserEmail", user.getEmail()); // upsert verified social // сохранить security context // redirect to /auth/protected })

[ ] Вспомогательный redirect() — скопировать из VkCallbackFilter

───────────────────────────────────────────────────────────────────────────── 2.4 UnifiedAuthService.java src/main/java/com/dixsu/server/devices/service/UnifiedAuthService.java ─────────────────────────────────────────────────────────────────────────────

[ ] Новых методов ДОБАВЛЯТЬ НЕ НУЖНО — все универсальные уже есть: findOrCreate(email, provider, providerId, displayName, avatarUrl) linkOAuthProviderToUser(userId, provider, providerId, displayName, avatarUrl) findLinkedOauthProviders(userId) upsertVerifiedSocial(userId, platform, username) findById(Long userId) mergeAccounts(User winner, User loser, String chosenSlug) disconnectTunnel(String slug) [приватный]

[ ] Убедиться что провайдер корректно принимает строку provider: "newprovider" Это строка — она нигде не валидируется, просто хранится в user_auth_providers.provider и в user_verified_socials.platform

───────────────────────────────────────────────────────────────────────────── 2.5 AuthController.java src/main/java/com/dixsu/server/devices/controller/AuthController.java ─────────────────────────────────────────────────────────────────────────────

[ ] GET /auth/link/{provider} — уже универсальный, ничего менять не нужно. Он просто записывает linkModeUserId + linkModeProvider в сессию и редиректит на /oauth2/authorization/{provider} (для Типа А) или на /auth/newprovider/login (для Типа Б — нужно адаптировать URL).

Для Типа Б добавить ветку в switch внутри метода:
  case "newprovider" -> "/auth/newprovider/login";

[ ] POST /auth/merge-with-candidate — уже универсальный, ничего не менять.

[ ] showProtectedPage() — уже читает mergeCandidateSlug, linkedProviders — ничего не менять.

───────────────────────────────────────────────────────────────────────────── 2.6 protected-page.html src/main/resources/templates/protected-page.html ─────────────────────────────────────────────────────────────────────────────

[ ] В блок "Привязать аккаунт" добавить кнопку для нового провайдера: Привязать NewProvider

[ ] В блок иконок соцсетей (если показываются иконки верифицированных сетей): Добавить иконку/цвет для "newprovider" в updateSocialIconStates() в JS: if (platforms.includes("newprovider")) { newproviderIcon.style.opacity = "1"; newproviderIcon.style.filter = "none"; } И сам элемент иконки в HTML.

[ ] В блок уведомления об успешной привязке: NewProvider успешно привязан (уже есть общий ${linkedProvider != null} блок — может потребоваться только имя)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 3. DATABASE — НИЧЕГО НОВОГО НЕ НУЖНО ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Таблицы уже поддерживают любой провайдер по строковому ключу:

user_auth_providers: - user_id, provider (строка), provider_id, display_name, avatar_url - UNIQUE(provider, provider_id)

user_verified_socials: - user_id, platform (строка), username - UNIQUE(platform, lower(username))

Новые миграции Flyway добавлять не нужно, если ты не вводишь новые поля.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 4. МЕХАНИЗМ LINK_MODE — КАК ЭТО РАБОТАЕТ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  1. Пользователь залогинен. Нажимает "Привязать NewProvider".
  2. GET /auth/link/newprovider:
  3. Пишет в сессию: linkModeUserId = user.getId()
  4. Пишет в сессию: linkModeProvider = "newprovider"
  5. Редиректит на OAuth-flow провайдера
  6. Провайдер редиректит на callback.
  7. Обработчик (OAuth2AuthSuccessHandler или кастомный фильтр):
  8. Читает linkModeUserId из сессии
  9. Вызывает linkOAuthProviderToUser(linkModeUserId, provider, providerId, ...)
  10. Результат "ok" → успех, восстановить контекст, /auth/protected?linked=newprovider
  11. Результат "conflict:N" → конфликт, записать mergeCandidateId/Provider/Slug, /auth/protected?mergeOffer=true

КРИТИЧЕСКИ ВАЖНО — восстановление security context: После OAuth flow Spring Security ПЕРЕЗАПИСЫВАЕТ security context в сессии на OAuth2AuthenticationToken (с subject = providerId, а не email). Все последующие запросы через ReactiveSecurityContextHolder вернут providerId! ВСЕГДА после link_mode явно вызывать: secContextRepo.save(exchange, new SecurityContextImpl(emailBasedAuth)) где emailBasedAuth = new UsernamePasswordAuthenticationToken(user.getEmail(), null, roles)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 5. МЕХАНИЗМ MERGE — КАК ЭТО РАБОТАЕТ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Сценарий: провайдер уже привязан к другому аккаунту.

  1. В сессии хранится: mergeCandidateId, mergeCandidateProvider, mergeCandidateSlug
  2. protected-page.html показывает баннер с предложением объединить аккаунты.
  3. Если у кандидата есть slug — пользователь выбирает какой slug оставить.
  4. POST /auth/merge-with-candidate (AuthController.mergeWithCandidate):
  5. Читает authenticatedUserEmail из сессии (НЕ из ReactiveSecurityContextHolder!)
  6. Читает candidateId из сессии
  7. Читает chosenSlug из @RequestParam (может быть null)
  8. Вызывает mergeAccounts(currentUser, candidateUser, chosenSlug)
  9. mergeAccounts(winner, loser, chosenSlug): a. Переносит все ссылки (links) с loser на winner (UPDATE links SET user_id=winner WHERE user_id=loser) b. Переносит auth_providers (если не дублируются) c. Переносит verified_socials (если не дублируются) d. Отключает туннель loser: disconnectTunnel(loser.slug) e. Если chosenSlug == loser.slug: отключает туннель winner тоже (смена slug = новый токен нужен) f. Очищает slug у loser: userRepository.clearSlug(loser.getId()) g. Если chosenSlug == loser.slug: обновляет slug у winner на loser.slug h. Деактивирует loser: updateEmail(loser.email, "merged_@noemail.dix.su")
  10. Обновляет сессию: security context = winner's email-based auth
  11. Редирект на /auth/protected

Почему читаем authenticatedUserEmail, а не security context: После link_mode OAuth flow контекст Spring Security = OAuth2AuthenticationToken (subject = providerId провайдера). mergeWithCandidate вызывается именно в этот момент. findByEmail(providerId) вернёт empty → Mono будет пустым → браузер получит 200 без тела. ВСЕГДА читай email из session.getAttributes().get("authenticatedUserEmail").

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 6. SLUG И ТУННЕЛИ — ВАЖНЫЕ ИНВАРИАНТЫ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[ ] При обнулении slug у любого пользователя — ВСЕГДА вызывать disconnectTunnel(slug). Иначе: туннель жив в dixu_proxy (Registry держит PID), но вход по этому slug с новым токеном невозможен (WHERE slug=X AND token=Y не найдёт строку).

[ ] dixu_proxy хранит активные соединения в памяти (DixuProxy.DeviceRegistry). Изменение slug в БД НЕ влияет на живые соединения — нужен явный disconnect.

[ ] disconnectTunnel() — fire-and-forget (onErrorResume пишет warn, не падает). Если proxy недоступен — продолжаем merge, туннель "умрёт" естественно.

[ ] isSlugTaken() исключает деактивированные аккаунты: WHERE slug = :slug AND email NOT LIKE 'merged_%@noemail.dix.su' Деактивированный аккаунт = email вида merged_@noemail.dix.su

[ ] clearSlug: UPDATE users SET slug = NULL WHERE id = :id После этого бывший slug свободен для любого пользователя.

[ ] Смена slug на slug кандидата (chosenSlug = loser.slug): - Отключаем оба туннеля (winner и loser) - clearSlug(loser) - updateSlug(winner.email, loser.slug) - Клиент должен переподключиться с новым токеном от winner'ского аккаунта

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 7. TRUST SCORE И ВЕРИФИЦИРОВАННЫЕ СОЦСЕТИ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[ ] При каждом успешном входе/привязке провайдера вызывать: upsertVerifiedSocial(userId, "newprovider", socialUsername) Это добавляет запись в user_verified_socials. UNIQUE(platform, lower(username)) — один username на платформу на всю систему.

[ ] Trust score считается автоматически при каждом запросе профиля. Каждая верифицированная соцсеть = +10 к trust score. Ничего дополнительно реализовывать не нужно.

[ ] socialUsername — это публичный username пользователя на платформе (не providerId). Для GitHub: login. Для Google: нет username → можно передавать null или email. Для VK: username или "id{userId}". Для новых: смотри API провайдера.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 8. ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ И КОНФИГУРАЦИЯ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[ ] Добавить в docker-compose (или .env): NEW_PROVIDER_CLIENT_ID=... NEW_PROVIDER_CLIENT_SECRET=...

[ ] Добавить в application.properties (или application-prod.properties): spring.security.oauth2.client.registration.newprovider.client-id=${NEW_PROVIDER_CLIENT_ID} spring.security.oauth2.client.registration.newprovider.client-secret=${NEW_PROVIDER_CLIENT_SECRET} (для Типа А)

[ ] Callback URL у провайдера зарегистрировать: https://dix.su/login/oauth2/code/newprovider (для Типа А — Spring стандарт) https://dix.su/auth/newprovider/callback (для Типа Б — кастомный)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 9. ДЕПЛОЙ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  1. Коммит и пуш в ветку prod_update: cd /root/dixsu/modulauth && git add -A && git commit -m "feat: add newprovider auth" && git push

  2. Если менялся dixu_proxy (новые internal endpoints): cd /root/dixsu/dixu_proxy && git add -A && git commit -m "feat: ..." && git push

  3. Ребилд: /root/dixsu/rebuild_modulauth.sh (если нужен proxy): /root/dixsu/rebuild_dixu_proxy.sh

ВАЖНО: не запускать параллельно — Docker конфликт имён контейнеров!

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 10. КРАТКИЙ ЧЕКЛИСТ (БЫСТРАЯ ВЕРСИЯ) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ТИП А (стандартный Spring OAuth2): [ ] SecurityConfig: добавить ClientRegistration [ ] OAuth2AuthSuccessHandler: добавить ветки в 5 switch (id/email/name/username/avatar) [ ] protected-page.html: кнопка "Привязать", иконка соцсети [ ] Env: CLIENT_ID + CLIENT_SECRET [ ] Callback URL у провайдера: /login/oauth2/code/newprovider

ТИП Б (кастомный фильтр, как VK): [ ] Создать NewProviderCallbackFilter.java с блоками: link_mode, conflict, normal login [ ] SecurityConfig: зарегистрировать фильтр, разрешить callback URL [ ] AuthController.GET /auth/link/{provider}: добавить URL в switch если нужно [ ] protected-page.html: кнопка "Привязать", иконка соцсети [ ] Env: API ключи провайдера [ ] Callback URL у провайдера: /auth/newprovider/callback

ОБЩЕЕ ДЛЯ ОБОИХ ТИПОВ: [ ] upsertVerifiedSocial вызывается при успехе (trust score++) [ ] Security context восстанавливается на email-based auth после link_mode [ ] mergeCandidateSlug записывается при конфликте [ ] disconnectTunnel вызывается в mergeAccounts (автоматически) [ ] Деплой: rebuild_modulauth.sh (не параллельно с dixu_proxy)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 11. ВЕРИФИКАЦИЯ ДОПОЛНИТЕЛЬНЫХ ИДЕНТИФИКАТОРОВ (к реализации) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Добавлено: 2026-05-23

КОНЦЕПЦИЯ: OAuth-привязка (Google/GitHub/VK/...) сама по себе является верификацией — если пользователь прошёл OAuth, он доказал владение аккаунтом. Отдельная верификация соцсетей НЕ НУЖНА. Иконка активируется сразу после успешного link_mode OAuth.

Кастомная верификация остаётся только для двух типов: 1. Публичный email (не тот, через который вход) 2. Сайт/домен

───────────────────────────────────────────────────────────────────────────── 11.1 ВЕРИФИКАЦИЯ ПУБЛИЧНОГО EMAIL (к реализации) ─────────────────────────────────────────────────────────────────────────────

Схема: пользователь указывает произвольный email → система высылает OTP (временный код/пароль) → пользователь вводит его в поле → email считается верифицированным.

Точки реализации: [ ] Новая таблица (НЕ смешивать с user_verified_socials): CREATE TABLE user_verified_emails ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL REFERENCES users(id), email TEXT NOT NULL, verified_at TIMESTAMPTZ, UNIQUE(email) ); + отдельная таблица pending OTP: CREATE TABLE email_verification_tokens ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, email TEXT NOT NULL, token TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL );

[ ] Flyway миграция: следующий номер после V13

[ ] Эндпоинты в AuthController: POST /auth/verify-email/request — принять email, сгенерить OTP, отправить письмо POST /auth/verify-email/confirm — принять email + OTP, верифицировать

[ ] Email-отправка: использовать уже настроенный SMTP/JavaMailSender (или добавить)

[ ] UI в protected-page.html: Поле ввода email + кнопка "Отправить код" После отправки — поле ввода кода + кнопка "Подтвердить" После верификации — email отображается в профиле как верифицированный

[ ] Trust score: +10 за каждый верифицированный email (аналогично соцсетям)

ВАЖНО: user_verified_emails отдельно от user_verified_socials. Разные таблицы — разная логика уникальности и верификации.

───────────────────────────────────────────────────────────────────────────── 11.2 ВЕРИФИКАЦИЯ САЙТА/ДОМЕНА (к реализации) ─────────────────────────────────────────────────────────────────────────────

ЦЕЛЬ Пользователь подтверждает владение сайтом → иконка «website» в профиле становится верифицированной (с точкой). Один верифицированный сайт на аккаунт (аналогично email — replace, не append).

МЕТОД ВЕРИФИКАЦИИ — файл на хостинге Пользователь размещает файл: https://example.com/.well-known/dixsu-verification.txt Содержимое файла — строго токен (без лишних символов): dixsu-verify- Система делает GET и проверяет, что тело начинается с этого токена.

DNS TXT — не реализуем сейчас (сложнее в UI, требует внешней библиотеки).

ТОЧКИ РЕАЛИЗАЦИИ ──────────────────

[ ] 1. Flyway миграция V15__Site_verification.sql путь: src/main/resources/db/migration/V15__Site_verification.sql

  CREATE TABLE site_verification_tokens (
      user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
      domain     TEXT NOT NULL,
      token      TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      PRIMARY KEY (user_id)    -- один pending-токен на пользователя
  );

  CREATE TABLE user_verified_sites (
      id          BIGSERIAL PRIMARY KEY,
      user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
      domain      TEXT NOT NULL,
      verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      UNIQUE(user_id),         -- один сайт на аккаунт
      UNIQUE(domain)           -- домен принадлежит одному аккаунту
  );

[ ] 2. SiteVerificationService путь: src/main/java/com/dixsu/server/devices/service/SiteVerificationService.java

  Зависимости: DatabaseClient db (R2DBC), WebClient webClient

  Методы:

    Mono<Map<String,String>> initVerification(Long userId, String rawUrl)
      - нормализовать URL → домен (host.toLowerCase, без схемы/path/trailing slash)
      - если host пустой / неопределён → вернуть {result:"invalid_url"}
      - проверить не занят ли домен другим user_id:
          SELECT user_id FROM user_verified_sites WHERE domain = :domain
          если найден и user_id != userId → {result:"domain_taken"}
      - token = "dixsu-verify-" + UUID.randomUUID()
      - UPSERT в site_verification_tokens:
          INSERT INTO site_verification_tokens (user_id, domain, token)
          VALUES (:uid, :domain, :token)
          ON CONFLICT (user_id) DO UPDATE SET domain=:domain, token=:token, created_at=NOW()
      - вернуть {result:"sent", domain:domain, token:token}

    Mono<String> checkVerification(Long userId)
      - SELECT domain, token FROM site_verification_tokens WHERE user_id = :uid
      - если не найден → "no_pending"
      - проверить SSRF: isPrivateHost(domain) → "invalid_url"
      - WebClient GET https://{domain}/.well-known/dixsu-verification.txt
          responseTimeout(Duration.ofSeconds(5))
          прочитать не более 1024 байт
      - если body.trim().startsWith(token) → верифицировано:
          DELETE FROM site_verification_tokens WHERE user_id = :uid
          DELETE FROM user_verified_sites WHERE user_id = :uid   (замена!)
          INSERT INTO user_verified_sites (user_id, domain)
          → "verified"
      - иначе → "token_mismatch"
      - onErrorResume → "unreachable" (таймаут, DNS, SSL, HTTP ошибки)

    Mono<String> findVerifiedSite(Long userId)
      - SELECT domain FROM user_verified_sites WHERE user_id = :uid
      - вернуть domain или null

  Нормализация URL:
      private String normalizeDomain(String rawUrl) throws Exception {
          String u = rawUrl.trim();
          if (!u.startsWith("http")) u = "https://" + u;
          String host = new java.net.URI(u).getHost();
          if (host == null || host.isBlank()) throw new IllegalArgumentException();
          return host.toLowerCase();
      }

  Защита от SSRF (обязательно перед GET):
      private boolean isPrivateHost(String host) throws Exception {
          InetAddress addr = InetAddress.getByName(host);
          return addr.isLoopbackAddress()
              || addr.isSiteLocalAddress()
              || addr.isLinkLocalAddress();
      }
      Если isPrivateHost → вернуть "invalid_url" (не делать GET)

[ ] 3. AuthController.java — добавить эндпоинты, обновить showProtectedPage

  Новое поле:
    @Autowired private SiteVerificationService siteVerificationService;

  Обновить showProtectedPage:
    - добавить в цепочку .zipWith(siteVerificationService.findVerifiedSite(userId))
      Рекомендуется перейти на Mono.zip(m1,m2,m3,m4,m5) вместо цепочки zipWith
      когда tuple становится глубоко вложенным
    - в модель: "verifiedSite" → домен (или null)
    - trust score: if (verifiedSite != null) trustScore += 15
    - читать query param "siteVerify" (аналогично emailVerify)
    - из сессии: "pendingVerifySite" и "pendingVerifySiteToken"

  Новые эндпоинты (через exchange.getFormData(), не @RequestParam):

    @PostMapping("/verify-site/init")
    public Mono<Void> initSiteVerification(WebSession session, ServerWebExchange exchange) {
        String currentEmail = session.getAttribute("authenticatedUserEmail");
        if (currentEmail == null || currentEmail.isBlank()) {
            redirect → /auth/login-page
        }
        return exchange.getFormData().flatMap(form → {
            String siteUrl = form.getFirst("siteUrl");
            return userService.findByEmail(currentEmail)
                .switchIfEmpty(Mono.error(new IllegalStateException("no_user")))
                .flatMap(user → siteVerificationService.initVerification(user.getId(), siteUrl))
                .flatMap(resultMap → {
                    String res = resultMap.get("result");
                    if ("sent".equals(res)) {
                        session.getAttributes().put("pendingVerifySite", resultMap.get("domain"));
                        session.getAttributes().put("pendingVerifySiteToken", resultMap.get("token"));
                    }
                    redirect → /auth/protected?siteVerify={res}
                })
                .onErrorResume(IllegalStateException.class, e → redirect /auth/login-page);
        });
    }

    @PostMapping("/verify-site/check")
    public Mono<Void> checkSiteVerification(WebSession session, ServerWebExchange exchange) {
        String currentEmail = session.getAttribute("authenticatedUserEmail");
        if (currentEmail == null || currentEmail.isBlank()) {
            redirect → /auth/login-page
        }
        return userService.findByEmail(currentEmail)
            .switchIfEmpty(Mono.error(new IllegalStateException("no_user")))
            .flatMap(user → siteVerificationService.checkVerification(user.getId()))
            .flatMap(result → {
                if ("verified".equals(result)) {
                    session.getAttributes().remove("pendingVerifySite");
                    session.getAttributes().remove("pendingVerifySiteToken");
                }
                redirect → /auth/protected?siteVerify={result}
            })
            .onErrorResume(IllegalStateException.class, e → redirect /auth/login-page);
    }

[ ] 4. protected-page.html — UI блок верификации сайта (разместить под блоком email верификации, внутри Profile card)

  Переменные из модели:
    ${verifiedSite}           — домен (строка) или null
    ${siteVerify}             — результат последней операции
    ${pendingVerifySite}      — домен в ожидании проверки
    ${pendingVerifySiteToken} — токен для размещения в файле

  Логика:
    Если verifiedSite != null:
      «✓ example.com — сайт подтверждён» + кнопка "Изменить"
    Иначе если pendingVerifySite != null:
      Шаг 2: инструкция + путь к файлу + содержимое токена
      Кнопка "Проверить" (POST /auth/verify-site/check)
    Иначе:
      Шаг 1: форма с input[name=siteUrl] + кнопка "Начать верификацию"

    Статусные баннеры (th:if="${siteVerify == '...'}"):
      "verified"       → зелёный: «✓ Сайт успешно подтверждён»
      "token_mismatch" → жёлтый: «Файл не найден или токен не совпадает»
      "unreachable"    → жёлтый: «Сайт недоступен, проверьте размещение файла»
      "domain_taken"   → красный: «Этот домен уже привязан к другому аккаунту»
      "invalid_url"    → красный: «Некорректный URL»

[ ] 5. Иконка website в PLATFORMS JS (в protected-page.html, уже есть { key: 'website', icon: 'icon-website' })

  Добавить в PROFILE JS-объект:
    verifiedSite: /*[[${verifiedSite != null ? verifiedSite : null}]]*/ null,

  Обновить verifiedVal в PLATFORMS.forEach:
    const vSite = PROFILE.verifiedSite || null;
    const verifiedVal = p.key === 'email'
                      ? (vEmails.length > 0 ? vEmails[0] : null)
                      : p.key === 'website'
                      ? vSite
                      : vSocials[p.key];

[ ] 6. Trust score — итоговая формула в showProtectedPage: int trustScore = verifiedSocials.size() * 10 + verifiedEmails.size() * 10 + (verifiedSite != null ? 15 : 0); // максимум ограничивается в шаблоне: pct = min(trustScore, 100)

[ ] 7. Перенос при мерже аккаунтов (UnifiedAuthService) Добавить mergeSites(loserId, winnerId): UPDATE user_verified_sites SET user_id = :winnerId WHERE user_id = :loserId AND NOT EXISTS (SELECT 1 FROM user_verified_sites WHERE user_id = :winnerId) Вызвать из mergeAccounts (аналогично mergeEmails).

[ ] 8. Отмена верификации (опционально) Эндпоинт POST /auth/verify-site/cancel: session.remove("pendingVerifySite"); session.remove("pendingVerifySiteToken"); DELETE FROM site_verification_tokens WHERE user_id = :uid redirect /auth/protected

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━