Skip to content

ТЗ — Соцвход (семейство модулей cms/oauth-*)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

Решение (2026-07-13): соцвход строится не одним модулем, а отдельным модулем на каждого провайдераcms/oauth-max (MAX, max.ru), cms/oauth-telegram, cms/oauth-yandex. Google не реализуется (осознанный отказ). Каждый провайдер включается/выключается независимо, ставится и лицензируется отдельно. Общий у них — тонкий контракт auth-provider ядра, а не общий модуль-зависимость.

Назначение и возможности

Вход и регистрация через российские соцсети/мессенджеры. Каждый модуль реализует одного провайдера поверх соответствующего механизма и регистрируется в ядре как auth-provider — ядро выстраивает из включённых провайдеров цепочку способов входа наравне с паролем/magic-login.

Общие для всех провайдеров возможности (реализуются каждым модулем на общий контракт):

  • линковка соцаккаунта к существующему пользователю (по email/телефону или явным подтверждением);
  • first-login сценарий: донабор обязательных полей (телефон, согласие ПДн) после первого входа;
  • отвязка провайдера от аккаунта (с проверкой, что остаётся способ входа);
  • отображение подключённых провайдеров в профиле пользователя;
  • защита от подмены state/подписи и повторного использования authorization code.

Различие механизмов (важно для реализации):

МодульПровайдерМеханизмПакет/приём
cms/oauth-yandexЯндекс IDOAuth 2.0Laravel Socialite (+ socialite-providers/yandex)
cms/oauth-maxMAX (max.ru)OAuth 2.0Socialite-драйвер (custom provider)
cms/oauth-telegramTelegramLogin Widgetне OAuth: проверка hash (HMAC-SHA256 от bot-token) + auth_date TTL

Telegram намеренно вынесен: у него нет OAuth-redirect/callback — виджет отдаёт подписанный payload, который модуль верифицирует, поэтому его контроллер и тесты отличаются от OAuth-провайдеров.

Зависимости и выключение

Каждый модуль: requires: ядро (auth) · provides: auth-provider · conflicts: — (провайдеры независимы, включаются в любой комбинации). auth-provider — контракт канонического реестра provides (§2 стандарта); в отличие от контрактов с выбором реализации (storage-backend и т.п.) ядро резолвит все включённые auth-provider разом в цепочку способов входа, без настройки выбора.

Поведение при выключении одного модуля: его кнопка соцвхода скрывается на форме входа; пользователи, ранее вошедшие через этот провайдер, продолжают работать по email/паролю или другому подключённому провайдеру; если это был единственный способ входа — вход блокируется до восстановления пароля через email (правило отвязки — см. Безопасность).

Стоимость внешних API: не применимо — авторизация через MAX/Яндекс ID/Telegram бесплатна, провайдер не выставляет счёт за OAuth-вызовы/verify. Оговорка: у каждого провайдера свой rate-limit на его стороне (не денежный, защитный) — встречная защита с нашей стороны — callback_rate_limit_per_minute (см. Настройки).

Модель данных

Каждый провайдер-модуль владеет своей таблицей (правило владения данными: модуль не пишет в чужие таблицы), схема единообразна:

ТаблицаКлючевые поляПримечание
cms_oauth_<provider>_identitiesid, user_id, provider_user_id, access_token (encrypted, nullable), refreshed_atсвязка пользователя с провайдером

user_id — FK constrained()->index(). Уникальный индекс (provider_user_id) в рамках таблицы провайдера — один соцаккаунт не привязывается к двум пользователям. access_tokenencrypted cast (у Telegram токена нет — поле null). <provider> в имени таблицы — конкретный слаг (max/telegram/yandex).

ПДн-паспорт. Идентифицирующие данные: provider_user_id и access_token (при наличии, у Telegram нет). Срок хранения: пока привязка активна — отвязка или удаление аккаунта удаляют identity немедленно, архив/soft-delete не предусмотрены. Участие в каскадах ядра (ревизия ядра 14.07.2026, п.3/п.13 — КРИТИЧНО, модуль обязан слушать оба события): UserDeleted → identity удаляется вместе с пользователем; UserMerged(primary, secondary) → identity переносится с secondary на primary, не дублируется (механика — «События и обмен», конфликт при переносе — «Крайние случаи»).

Входные и выходные данные

Входы:

ИсточникДанные/поляЧем валидируется
GET /redirect (public, OAuth: max, yandex)query redirect (опционально — URL возврата после входа)whitelist: только относительный путь текущего сайта или хост из <slug>.allowed_redirect_hosts; посторонний хост отклоняется до старта OAuth-потока — защита от open redirect (см. Безопасность)
GET /callback (public, OAuth: max, yandex)code, stateSocialite: state сверяется с подписанным значением в сессии (CSRF-барьер); code одноразовый — обмен на токен у провайдера отклоняет повтор; ответ провайдера с error/error_description даёт понятную ошибку, не 500
POST /telegram/verify (public, Telegram)hash, auth_date, id, first_name, last_name (nullable), username (nullable), photo_url (nullable)FormRequest: hash/auth_date/id обязательны; hash пересчитывается как HMAC-SHA256 отсортированной строки полей ключом sha256(bot_token); auth_date — TTL (widget_auth_date_ttl_minutes), просроченный отклоняется
DELETE /admin/oauth/{provider} (auth)— (тело не принимается)действует только на аккаунт вызывающего из RequestContext; {provider} — whitelist установленных модулей семейства
Донабор first-login (форма профиля, auth)поля из <slug>.first_login_required_fields (например phone)rules движка полей ядра, как у обычной формы профиля

Всё, что не перечислено выше (произвольные query-параметры redirect/callback, лишние поля payload Telegram), отвергается на уровне FormRequest — whitelist-принцип §11 стандарта.

Выходы:

ПотребительДанныеФормат
Ядро — аутентификацияuser_id, факт успешного входаprovides-контракт auth-provider: веб-сессия/Sanctum-токен по правилам ядра
cms/notifications-bus (если доступен, только сценарий линковки по email)письмо-подтверждение привязки аккаунташаблон модуля (lang/), отправка через шину; недоступность шины не блокирует вход — блокирует только подтверждение линковки этим способом (см. Крайние случаи)
Filament (профиль пользователя)provider, provider_user_id (обезличенно в UI), linked_atстрока в списке подключённых провайдеров
cms:oauth-<provider>:doctor --jsonвалидность ключей/bot-token, доступность провайдераJSON
события OauthAccountLinked/OauthFirstLoginCompleted/OauthAccountUnlinkeduser_id, providerшина событий (канал 1)

Настройки (группа модуля, напр. oauth-yandex)

КлючТипДефолтaffectsPageCacheОписание
<slug>.enabledboolfalseнетВключён ли провайдер (кнопка на форме входа)
<slug>.require_verified_contactbooltrueнетТребовать подтверждённый email/телефон от провайдера
<slug>.first_login_required_fieldsarray["phone"]нетПоля для донабора при первом входе
<slug>.allowed_redirect_hostsarray[]нетДоп. хосты, разрешённые в параметре redirect после входа (whitelist, защита от open redirect)
<slug>.callback_rate_limit_per_minuteint10нетRate-limit на callback/verify-эндпоинт (per IP) — единственный лимит/квота модуля (матрица продуманности)

Секреты (client_id/secret, bot-token) — только в .env, не в settings-store.

Kill-switch: отдельного флага не заводится — <slug>.enabled уже им является (кнопка выключается без деинсталляции модуля и без потери созданных identity).

Telegram (oauth-telegram) дополнительно:

КлючТипДефолтaffectsPageCacheОписание
oauth-telegram.widget_auth_date_ttl_minutesint5нетTTL подписи Login Widget (см. Крайние случаи)
oauth-telegram.bot_usernamestringдаПубличное имя бота для рендера виджета на форме входа (не секрет, но меняет разметку кешируемого блока)

API

OAuth-провайдеры (oauth-max, oauth-yandex):

МетодПутьДоступНазначение
GET/api/v1/auth/oauth/{provider}/redirectpublicРедирект на провайдера
GET/api/v1/auth/oauth/{provider}/callbackpublicCallback, создание/линковка сессии
DELETE/api/v1/admin/oauth/{provider}authОтвязать провайдера от своего аккаунта

Telegram (oauth-telegram):

МетодПутьДоступНазначение
POST/api/v1/auth/telegram/verifypublicПроверка подписи Login Widget, создание/линковка сессии
DELETE/api/v1/admin/oauth/telegramauthОтвязать Telegram от своего аккаунта

Компоненты

Filament: список подключённых провайдеров пользователя в карточке профиля (каждый модуль добавляет свою строку). Команды: cms:oauth-<provider>:doctor --json (проверка ключей).

Ранбук (§15 стандарта):

СимптомДиагностикаДействие
Провайдер отдаёт 5xx на callbackcms:oauth-<provider>:doctor --jsonдеградация до входа паролем/другим провайдером; при затяжном сбое выключить <slug>.enabled
Волна ложных 403 на callback/verify (легитимный трафик упирается в лимит)график 403 по эндпоинту + cms:oauth-<provider>:doctor --jsonпересмотреть callback_rate_limit_per_minute в settings-store (без деплоя)
Telegram: массовые «подпись невалидна»сверить bot_token с BotFather; проверить рассинхрон времени сервера (auth_date TTL)синхронизировать NTP или обновить bot_token; identity не мигрируют

События и обмен

СобытиеКогдаPayload
OauthAccountLinkedпривязан провайдерuser_id, provider
OauthFirstLoginCompletedзавершён донабор данныхuser_id, provider
OauthAccountUnlinkedпровайдер отвязанuser_id, provider

События канонические (общие для семейства, объявлены ядром/контрактом auth-provider), provider в payload различает источник.

Слушает (обязательная подписка каждого модуля семейства — ревизия ядра 14.07.2026, п.3/п.13):

СобытиеИсточникРеакция модуля
UserMerged(primary, secondary)ядро — пользователиidentity, принадлежащая secondary, переносится на primary (UPDATE user_id); если у primary уже есть identity этого же провайдера — перенос отклоняется явной ошибкой в лог слияния, а не тихой перезаписью/дублем (см. Крайние случаи)
UserDeletedядро — пользователиidentity удаляется вместе с пользователем

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
Ядро — аутентификацияprovides-контракт auth-provideroutкаждый модуль семейства регистрируется как способ входа; ядро вызывает реализацию при валидном callback/verify наравне с паролем/magic-login
Ядро — пользователиrequires (часть auth ядра)inрезолвинг/создание user_id; линковка к существующему аккаунту — только через явное подтверждение пользователя (пароль или письмо-ссылка), не по совпадению email автоматически; каскады UserMerged/UserDeleted — таблица выше
cms/notifications-bus (мягкая интеграция, не requires)сервис-вызов + очередьoutписьмо-подтверждение при линковке по email; при выключенном notifications-bus остаётся только подтверждение вводом пароля — деградация, не отказ входа
cms/attack-monitor (suggests)событие/серия неудачных state/hash-проверокoutсигнал брутфорса callback/verify, если модуль включён; при выключенном — просто нет слушателя
Filament — профиль пользователякомпонент (представительство модуля)outстрока подключённого провайдера в карточке; данные — из сервиса модуля, не прямых запросов из Blade

Фоновая работа

Нет — redirect/callback/verify синхронны в рамках HTTP-запроса.

Производительность и кеш

  • Ожидаемый объём: соцвход обычно даёт 15–30% входов от общей базы аутентификаций на сайтах, где включён хотя бы один провайдер (порядок величины, не гарантия); распределение между провайдерами зависит от аудитории — Яндекс ID шире по узнаваемости, MAX растёт, Telegram силён на мобильной аудитории;
  • горячий путь OAuth (GET /callback) — синхронный, критичен по задержке: включает внешний HTTP-вызов к провайдеру (обмен code → access token + запрос профиля), обычно 200–800 мс сверх обычного запроса; обязателен таймаут с graceful fallback — недоступность провайдера даёт понятную ошибку входа, не зависший запрос;
  • бюджет БД на callback/verify: ≤3 запроса (поиск identity по provider_user_id → создание/обновление identity → создание/обновление пользователя), без N+1;
  • Telegram POST /verify не делает внешний HTTP-вызов (только локальная HMAC-проверка) — бюджет ≤2 запроса к БД, задержка ниже, чем у OAuth-провайдеров;
  • уникальный индекс (provider_user_id) на таблице каждого провайдера (см. Модель данных) критичен: без него — полнотабличный скан на каждый callback/verify и открытая гонка при параллельной линковке одного соцаккаунта (см. Крайние случаи);
  • кешируется флаг <slug>.enabled из settings-store — 0 запросов на форме входа (кнопки провайдеров рендерятся из кеша группы настроек, а не проверкой БД на каждый показ страницы);
  • отдельных тегов кеша модуль не объявляет — идентичности персональны, в общий page-cache не попадают; инвалидация флага enabled/bot_username — стандартным SettingChanged ядра, собственного механизма инвалидации нет.

Безопасность

  • OAuth-провайдеры: callback принимает только запросы с валидным state (CSRF Socialite), не допускает повторное использование authorization code; rate-limit на callback. PKCE обязателен для oauth-max/oauth-yandex: code_verifier/ code_challenge (S256) — при построении authorization URL и на обмене кода (Socialite поддерживает штатно, верификатор — в сессии рядом с state); без PKCE контрактный тест не проходит.
  • Telegram: payload виджета проверяется по hash (HMAC-SHA256 ключом от bot-token) и auth_date (TTL, отсечение старых подписей); rate-limit на verify. PKCE не применим — у Login Widget нет authorization code/redirect-потока.
  • Токены провайдера — encrypted cast, не логируются.
  • Отвязка провайдера запрещена, если это единственный способ входа без пароля.
  • Open redirect: параметр redirect (возврат после входа) принимается только по whitelist — относительный путь текущего сайта или хост из <slug>.allowed_redirect_hosts; посторонний хост отклоняется до старта OAuth-потока, не после успешного входа.
  • Account takeover через непроверенный email провайдера: email, вернувшийся от провайдера, никогда не привязывает аккаунт молча. Автолинковка к существующему пользователю возможна только при require_verified_contact=true и явном подтверждении самим пользователем (пароль существующего аккаунта или письмо-ссылка) — иначе злоумышленник, зарегистрировавший в провайдере чужой email без подтверждения, мог бы захватить существующий аккаунт (см. Крайние случаи).
  • Email без флага verified от провайдера трактуется как отсутствующий: не участвует ни в автолинковке, ни в тихом заполнении профиля.
  • Маппинг ролей из провайдера запрещён: first-login выдаёт ТОЛЬКО базовую роль по умолчанию сайта. Claims провайдера (включая потенциальные is_admin/группы) не парсятся и не используются для назначения прав — иначе злоумышленник, полностью контролирующий свой профиль в соцсети/боте, мог бы повлиять на роль через first-login (вектор повышения привилегий без участия администратора).

Матрица ролей:

ДействиеВладелец аккаунтаСотрудник (oauth.manage)studio
Привязка провайдера к своему аккаунту (первый вход/линковка)
Отвязка своего провайдера
Просмотр списка привязанных провайдеров любого пользователя (поддержка)
Принудительная отвязка чужого провайдера (компрометация/инцидент, см. UX)
Просмотр статуса cms:oauth-<provider>:doctor на экране здоровья модулей

Права: oauth.manage.own (свой аккаунт), oauth.manage (принудительная отвязка чужого провайдера сотрудником/studio из карточки профиля — см. UX-требования).

UX-требования

Посетитель:

  • кнопка входа — узнаваемая иконка/цвет провайдера (Яндекс ID, MAX, Telegram), подпись «Войти через …», не обезличенная «OAuth»;
  • совпадение email с существующим аккаунтом — честная форма линковки: явный экран «такой email уже зарегистрирован, подтвердите привязку» с выбором «войти паролем» или «получить письмо-ссылку», а не молчаливое объединение аккаунтов (см. Крайние случаи);
  • донабор обязательных полей при first-login сохраняет уже введённые значения при ошибке валидации — не сбрасывает форму целиком из-за одного неверного поля;
  • провайдер недоступен/отклонил вход — понятная ошибка («не удалось войти через Яндекс, попробуйте позже или используйте пароль»), не техническая деталь ответа провайдера;
  • отвязка последнего способа входа без пароля — предупреждение до подтверждения («это единственный способ входа, установите пароль или email перед отвязкой»).

Админ:

  • список подключённых провайдеров пользователя — в карточке профиля, каждый модуль добавляет свою строку (иконка, дата привязки, кнопка принудительной отвязки);
  • принудительная отвязка администратором — подтверждение необратимого действия с предупреждением, если это единственный способ входа пользователя без пароля;
  • для studio-роли — статус cms:oauth-<provider>:doctor виден на общем экране здоровья модулей (валидность client_id/secret или bot-token, доступность провайдера);
  • пустое состояние (провайдером ещё никто не пользовался) — подсказка «пока никто не входил через …», не пустая таблица без контекста.

Крайние случаи и типовые баги

  • email от провайдера совпадает с существующим email/паролем-аккаунтом (линковка) → автоматическое объединение аккаунтов запрещено. Модуль показывает экран «email уже зарегистрирован» и требует явное подтверждение одним из способов: (а) вход по паролю существующего аккаунта в том же потоке, или (б) письмо-подтверждение на этот email со ссылкой привязки (аналог magic-login), если доступен cms/notifications-bus. До подтверждения соцаккаунт не создаёт identity и не входит под существующим пользователем — иначе злоумышленник, узнавший чужой email и зарегистрировавший его в провайдере, получает доступ к чужому аккаунту (account takeover). Это гейт, не рекомендация — контрактный тест обязателен;
  • провайдер не возвращает email вовсе (Telegram — почти всегда; MAX/Яндекс — при неполном согласии пользователя) → вход разрешён без email, если require_verified_contact выключен для сайта; иначе донабор email вручную обязателен на экране first-login до завершения регистрации (поле берётся из <slug>.first_login_required_fields);
  • отсутствующий или подделанный state в OAuth callback (CSRF) → callback отклоняется до обращения к провайдеру за токеном; пользователь видит «сессия входа истекла, попробуйте снова» с кнопкой повторного входа — не техническую ошибку 419/500;
  • отвязка последнего способа входа без пароля → операция блокируется с ошибкой «нельзя отвязать единственный способ входа». Восстановление: пользователь сначала задаёт пароль через форму «забыли пароль» (письмо → установка пароля) или привязывает второй провайдер — только после этого отвязка первого разрешается;
  • повторное использование authorization code (replay) → второй обмен того же code отклоняется на стороне провайдера при обмене на токен; модуль трактует ошибку обмена как отказ входа и не создаёт identity повторно; попытка логируется для cms/attack-monitor;
  • Telegram: auth_date устарел (виджет открыт давно, пользователь долго не нажимал «войти») → TTL (widget_auth_date_ttl_minutes) истёк, вежливая ошибка «ссылка входа устарела, обновите страницу и попробуйте снова» — отдельная от ошибки «подпись невалидна», хоть обе ветки близки в коде;
  • гонка: два пользователя почти одновременно линкуют один и тот же provider_user_id → уникальный индекс на provider_user_id отклоняет вторую вставку на уровне БД; второй запрос получает «этот аккаунт уже привязан к другому пользователю» вместо тихого дублирования или 500;
  • UserMerged: у primary и secondary есть привязка одного и того же провайдера (разные provider_user_id у каждого) → перенос identity secondaryprimary не выполняется тихой перезаписью: обработчик ловит конфликт уникального индекса, фиксирует его в лог слияния (виден сотруднику/studio) и оставляет обе identity как есть до ручного разбора — молчаливая потеря любой из привязок недопустима;
  • модуль-провайдер выключается, пока пользователь на середине OAuth-редиректа (ушёл к провайдеру, вернулся — модуль уже выключен) → callback-роут снят из реестра вместе с модулем, запрос на /callback получает 404 с понятной страницей «вход через … временно недоступен», не 500; уже созданные identity не удаляются — доступ восстанавливается при повторном включении модуля (деградация, не потеря данных);
  • provider отдаёт email без флага verified → email трактуется как отсутствующий: не участвует в автолинковке и не заполняет профиль без явного подтверждения — закрывает account takeover через непроверенный email на стороне провайдера;
  • пользователь отменяет согласие/закрывает окно провайдера → callback не наступает, сессия входа истекает по стандартному TTL сессии ядра; повторная попытка — обычный новый запуск потока, не «зависшая» попытка;
  • сбой cms/notifications-bus в момент подтверждения линковки по email → ⚠️ Противоречие: ТЗ не описывает fallback, если письмо-подтверждение не ушло, а у пользователя нет пароля (например аккаунт создан только через другой соцвход). Разрешение: подтверждение вводом пароля доступно только когда пароль установлен; если пароля нет и письмо не ушло — линковка недоступна, модуль показывает «не удалось подтвердить привязку, попробуйте позже» и не создаёт identity; повторная попытка — обычный новый запрос линковки, не блокировка навсегда.

Донорский код

Донор: — (новая разработка; OAuth — Laravel Socialite, Telegram — верификация подписи вручную).

Миграция legacy (§16 стандарта). При переезде клиента с площадки, где соцвход через того же провайдера уже был реализован: cms:oauth-<provider>:import-legacy --source=<профиль> [--dry-run] — маппинг старых provider_user_id на identity текущего модуля. Идемпотентен: ключ — provider_user_id (та же уникальность, что и у обычной линковки), повторный прогон не создаёт дублей. --dry-run печатает план без записи в БД. Профиль --source — конфиг полей донорской таблицы конкретного проекта, в модуль не входит.

Тесты и приёмка (на каждый модуль семейства)

  • [ ] OAuth: callback с невалидным state отклоняется без создания сессии;
  • [ ] Telegram: payload с неверным hash или просроченным auth_date отклоняется;
  • [ ] First-login без обязательных полей не даёт полного доступа до донабора;
  • [ ] Отвязка провайдера запрещена, если это единственный способ входа без пароля;
  • [ ] Токены провайдера хранятся зашифрованными, не логируются;
  • [ ] Деградация при выключении не ломает вход по email/паролю и другим провайдерам;
  • [ ] Rate-limit на callback/verify-эндпоинт;
  • [ ] Линковка по совпадению email не происходит автоматически — требует подтверждения паролем или письмом-ссылкой;
  • [ ] Email без флага verified не используется для автолинковки и не заполняет профиль без подтверждения;
  • [ ] Параметр redirect с посторонним хостом отклоняется (open redirect);
  • [ ] Повторное использование authorization code отклоняется, вторая identity не создаётся;
  • [ ] Гонка на уникальный provider_user_id — вторая параллельная линковка получает конфликт, не тихий дубль;
  • [ ] Telegram: просроченный auth_date отклоняется с понятной ошибкой, отличной от «подпись невалидна»;
  • [ ] Callback/verify под выключенным модулем отдаёт 404 с понятной страницей, не 500;
  • [ ] OAuth-поток строит authorization URL и обменивает код с PKCE (code_verifier/code_challenge=S256), без PKCE тест падает;
  • [ ] UserMerged(primary, secondary) переносит identity secondary на primary; конфликт двух привязок одного провайдера при переносе даёт явную ошибку в лог слияния, не тихую перезапись/дубль;
  • [ ] UserDeleted удаляет identity вместе с пользователем;
  • [ ] роль пользователя после first-login всегда базовая по умолчанию сайта независимо от claims провайдера (в т.ч. is_admin-подобных полей в тестовом фикстуре);
  • [ ] import-legacy идемпотентен: повторный прогон на тех же данных не создаёт дублей и не меняет уже смигрированные identity; --dry-run не пишет в БД;
  • [ ] Контрактный набор cms-testing зелёный, testbench-изоляция пакета, feature-тест на каждый роут;
  • [ ] Тестовая БД только <slug>_test; migrate:fresh/refresh/reset, db:wipe запрещены.

Внутренняя база знаний студии