Добавить 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 — КАК ЭТО РАБОТАЕТ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- Пользователь залогинен. Нажимает "Привязать NewProvider".
- GET /auth/link/newprovider:
- Пишет в сессию: linkModeUserId = user.getId()
- Пишет в сессию: linkModeProvider = "newprovider"
- Редиректит на OAuth-flow провайдера
- Провайдер редиректит на callback.
- Обработчик (OAuth2AuthSuccessHandler или кастомный фильтр):
- Читает linkModeUserId из сессии
- Вызывает linkOAuthProviderToUser(linkModeUserId, provider, providerId, ...)
- Результат "ok" → успех, восстановить контекст, /auth/protected?linked=newprovider
- Результат "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 — КАК ЭТО РАБОТАЕТ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Сценарий: провайдер уже привязан к другому аккаунту.
- В сессии хранится: mergeCandidateId, mergeCandidateProvider, mergeCandidateSlug
- protected-page.html показывает баннер с предложением объединить аккаунты.
- Если у кандидата есть slug — пользователь выбирает какой slug оставить.
- POST /auth/merge-with-candidate (AuthController.mergeWithCandidate):
- Читает authenticatedUserEmail из сессии (НЕ из ReactiveSecurityContextHolder!)
- Читает candidateId из сессии
- Читает chosenSlug из @RequestParam (может быть null)
- Вызывает mergeAccounts(currentUser, candidateUser, chosenSlug)
- 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") - Обновляет сессию: security context = winner's email-based auth
- Редирект на /auth/protected
Почему читаем authenticatedUserEmail, а не security context:
После link_mode OAuth flow контекст Spring Security = OAuth2AuthenticationToken
(subject = providerId провайдера). mergeWithCandidate вызывается именно в этот момент.
findByEmail(providerId) вернёт empty → Mono
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ЧАСТЬ 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_
[ ] 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. ДЕПЛОЙ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
-
Коммит и пуш в ветку prod_update: cd /root/dixsu/modulauth && git add -A && git commit -m "feat: add newprovider auth" && git push
-
Если менялся dixu_proxy (новые internal endpoints): cd /root/dixsu/dixu_proxy && git add -A && git commit -m "feat: ..." && git push
-
Ребилд: /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-
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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━