Тема
ТЗ — Соцвход (семейство модулей 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 | Яндекс ID | OAuth 2.0 | Laravel Socialite (+ socialite-providers/yandex) |
cms/oauth-max | MAX (max.ru) | OAuth 2.0 | Socialite-драйвер (custom provider) |
cms/oauth-telegram | Telegram | Login 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>_identities | id, user_id, provider_user_id, access_token (encrypted, nullable), refreshed_at | связка пользователя с провайдером |
user_id — FK constrained()->index(). Уникальный индекс (provider_user_id) в рамках таблицы провайдера — один соцаккаунт не привязывается к двум пользователям. access_token — encrypted 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, state | Socialite: 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/OauthAccountUnlinked | user_id, provider | шина событий (канал 1) |
Настройки (группа модуля, напр. oauth-yandex)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
<slug>.enabled | bool | false | нет | Включён ли провайдер (кнопка на форме входа) |
<slug>.require_verified_contact | bool | true | нет | Требовать подтверждённый email/телефон от провайдера |
<slug>.first_login_required_fields | array | ["phone"] | нет | Поля для донабора при первом входе |
<slug>.allowed_redirect_hosts | array | [] | нет | Доп. хосты, разрешённые в параметре redirect после входа (whitelist, защита от open redirect) |
<slug>.callback_rate_limit_per_minute | int | 10 | нет | 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_minutes | int | 5 | нет | TTL подписи Login Widget (см. Крайние случаи) |
oauth-telegram.bot_username | string | — | да | Публичное имя бота для рендера виджета на форме входа (не секрет, но меняет разметку кешируемого блока) |
API
OAuth-провайдеры (oauth-max, oauth-yandex):
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/auth/oauth/{provider}/redirect | public | Редирект на провайдера |
| GET | /api/v1/auth/oauth/{provider}/callback | public | Callback, создание/линковка сессии |
| DELETE | /api/v1/admin/oauth/{provider} | auth | Отвязать провайдера от своего аккаунта |
Telegram (oauth-telegram):
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/auth/telegram/verify | public | Проверка подписи Login Widget, создание/линковка сессии |
| DELETE | /api/v1/admin/oauth/telegram | auth | Отвязать Telegram от своего аккаунта |
Компоненты
Filament: список подключённых провайдеров пользователя в карточке профиля (каждый модуль добавляет свою строку). Команды: cms:oauth-<provider>:doctor --json (проверка ключей).
Ранбук (§15 стандарта):
| Симптом | Диагностика | Действие |
|---|---|---|
| Провайдер отдаёт 5xx на callback | cms: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-provider | out | каждый модуль семейства регистрируется как способ входа; ядро вызывает реализацию при валидном 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-потока. - Токены провайдера —
encryptedcast, не логируются. - Отвязка провайдера запрещена, если это единственный способ входа без пароля.
- 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у каждого) → перенос identitysecondary→primaryне выполняется тихой перезаписью: обработчик ловит конфликт уникального индекса, фиксирует его в лог слияния (виден сотруднику/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)переносит identitysecondaryна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запрещены.