Тема
ТЗ — Госуслуги/ЕСИА (cms/esia)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Вход через ЕСИА (OAuth поверх Госуслуг): авторизация пользователя, получение верифицированных персональных данных по запрошенным скоупам, хранение с шифрованием полей ядра и фиксация согласия на обработку.
- OAuth-вход через ЕСИА как способ входа наравне с провайдерами семейства
cms/oauth-*(VK ID/Telegram/Яндекс) — через общий контрактauth-provider(см. «Зависимости») - Получение верифицированных данных (ФИО, документы) по разрешённым скоупам запроса
- Хранение полученных данных через шифрование полей ядра (не в открытом виде)
- Фиксация согласия на обработку персональных данных при первом входе через
cms/consents - Требование минимального уровня учётной записи ЕСИА для входа (упрощённая/стандартная/ подтверждённая — см. «Крайние случаи»), настраиваемого на сайт
- Привязка ЕСИА-профиля к существующему аккаунту — только с явным подтверждением пользователя (см. «⚠️ Противоречие» в разделе «Крайние случаи»), либо создание нового
- Журнал входов через ЕСИА отдельно от обычных OAuth-провайдеров (для комплаенса)
Зависимости и выключение
requires: ядро (auth), cms/consents · provides: auth-provider
ЕСИА — самостоятельный способ входа (аналогично семейству cms/oauth-*, решение oauth.md от 2026-07-13: провайдеры входа не зависят друг от друга, у каждого свой тонкий контракт auth-provider). Модуль не требует cms/oauth как пакет — прежняя зависимость от него была унаследованной неточностью прежнего черновика ТЗ и здесь исправлена: cms/oauth как единый пакет не существует, есть семейство cms/oauth-*, и esia в это семейство не входит, а лишь регистрируется в ядре тем же механизмом (provides: auth-provider), что и позволяет ядру выстраивать из esia и включённых cms/oauth-* единую цепочку способов входа.
Поведение при выключении: вход через ЕСИА пропадает из формы входа (кнопка снята из реестра auth-provider), пользователи, ранее вошедшие через ЕСИА, продолжают использовать привязанный аккаунт через альтернативный способ входа (пароль/другой auth-provider), если он настроен; если ЕСИА была единственным способом входа — пользователь восстанавливает доступ через «забыли пароль» (письмо на email профиля, если он есть) — ранее сохранённые верифицированные данные не удаляются.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_esia_profiles | id, user_id (nullable), suggested_user_id (nullable), esia_oid, account_level, verified_data_encrypted (json), scopes (json), status, revoked_at (nullable), created_at | верифицированные данные, поля шифруются на уровне модели |
cms_esia_login_log | id, user_id, esia_oid, ip_hash, created_at | журнал входов через ЕСИА для комплаенса |
- FK
user_id/suggested_user_id—constrained()->index()(оба nullable:user_idпуст, пока привязка/создание аккаунта не завершены — см. «Крайние случаи»,suggested_user_idзаполняется только на время предложенной, но не подтверждённой привязки); - уникальный индекс на
esia_oid— идемпотентность привязки (гонка двойного входа); account_level— PHP Enum (simplified/standard/confirmed— см. «Крайние случаи»),status— PHP Enum (linked/link_pending/revoked);scopes—json()+ castarray;verified_data_encrypted— шифрование на уровне модели (encryptedcast), не хранится в открытом виде — состав полей внутри см. «Безопасность» (ПДн-паспорт);cms_esia_login_log— append-only, BRIN поcreated_at,ip_hash— обезличенный (не сырой IP), индекс поuser_idпод «выгрузить всё по субъекту».
Входные и выходные данные
Входы:
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
GET /redirect (guest) | query redirect (опционально) | whitelist: относительный путь сайта или хост из esia.allowed_redirect_hosts — защита от open redirect, по аналогии с cms/oauth-* |
GET /callback (guest) | code, state от ЕСИА | state сверяется с подписанным значением в сессии (CSRF); code одноразовый; ответ ЕСИА с ошибкой (error/error_description) даёт понятную ошибку, не 500 |
| Ответ ЕСИА по колбэку (данные субъекта) | верифицированные поля по запрошенным esia.scopes (ФИО, СНИЛС, паспорт, адрес — в зависимости от скоупа), account_level | схема ответа ЕСИА API; account_level сверяется с esia.min_account_level — ниже требуемого блокирует вход до сохранения каких-либо данных (см. «Крайние случаи») |
POST /link/confirm (auth) | password или confirmation_code (из письма/SMS) | принадлежность suggested_user_id текущему аутентифицированному пользователю; пароль сверяется хешем; код — TTL esia.link_confirmation_ttl_days |
POST /link/decline (auth) | — (тело не принимается) | действует только на предложение привязки текущего пользователя |
GET /admin/esia/login-log | filter[esia_oid], filter[account_level], sort, cursor | FormRequest whitelist (конвенция API ядра), Policy esia.view |
Всё, что не перечислено выше (произвольные поля в ответе ЕСИА сверх заявленных esia.scopes, лишние query-параметры) отбрасывается — whitelist-принцип §11 стандарта.
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Ядро — аутентификация | user_id, факт успешного входа | provides-контракт auth-provider: веб-сессия/Sanctum-токен по правилам ядра |
cms/consents (requires) | фиксация согласия на обработку ПДн | сервис-вызов до сохранения верифицированных данных |
| Filament (журнал входов, очередь предложенных привязок) | таблицы | список |
cms:esia:doctor --json | доступность контура ЕСИА (health-чек) | JSON |
GET /api/v1/auth/esia/availability | флаг доступности ЕСИА (для формы входа) | JSON, публичный, из кеша health-пинга |
события EsiaLoginSucceeded/EsiaProfileLinked/EsiaConsentRecorded/EsiaLinkSuggested/EsiaAccessRevoked | см. «События и обмен» | payload события (канал 1) |
Обмен с самой ЕСИА — стандартный OAuth-редирект/колбэк (не через cms/integrations-bus, синхронный флоу входа, как и у cms/oauth-*); фиксация согласия — вызов cms/consents по requires.
Настройки (группа esia)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
esia.enabled | bool | false | да | Показывать вход через ЕСИА на форме входа (kill-switch модуля) |
esia.scopes | array | ["fullname"] | нет | Запрашиваемые скоупы данных |
esia.test_mode | bool | true | нет | Использовать тестовый контур ЕСИА |
esia.min_account_level | string | "standard" | нет | Минимальный уровень учётной записи ЕСИА, допускаемый к входу (simplified/standard/confirmed) |
esia.auto_link_by | string | "email" | нет | Поле совпадения для предложения привязки к существующему аккаунту (не автопривязка — см. «Крайние случаи») |
esia.link_confirmation_ttl_days | int | 7 | нет | Срок жизни предложенной привязки до автоотклонения |
esia.allowed_redirect_hosts | array | [] | нет | Доп. хосты, разрешённые в параметре redirect (whitelist, защита от open redirect) |
esia.callback_rate_limit_per_minute | int | 10 | нет | Rate-limit на callback-эндпоинт (per IP) |
esia.retention_days_after_deletion | int | 30 | нет | Хранение верифицированных данных после каскада «забыть» (команда ядра cms:privacy:forget), затем обезличивание |
Секреты (client_id/client_secret ЕСИА, сертификат подписи запроса) — только .env.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/auth/esia/redirect | guest | Редирект на авторизацию ЕСИА |
| GET | /api/v1/auth/esia/callback | guest | Обработка колбэка ЕСИА, вход/регистрация/предложение привязки |
| GET | /api/v1/auth/esia/availability | public | Флаг доступности ЕСИА для формы входа (health-check из кеша) |
| POST | /api/v1/auth/esia/link/confirm | auth | Подтверждение предложенной привязки паролем/кодом |
| POST | /api/v1/auth/esia/link/decline | auth | Отклонение предложенной привязки |
| GET | /api/v1/admin/esia/login-log | admin (esia.view) | Журнал входов через ЕСИА |
| GET | /api/v1/admin/esia/link-suggestions | admin (esia.view) | Очередь предложенных, не подтверждённых привязок |
Журнал входов и очередь привязок — keyset-пагинация, не OFFSET.
Компоненты
Filament: журнал входов через ЕСИА, очередь предложенных привязок (с TTL до автоотклонения). Команды: cms:esia:doctor --json (проверка доступности контура ЕСИА, также используется расписанием для обновления кеша esia:availability).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
EsiaLoginSucceeded | пользователь успешно вошёл через ЕСИА | user_id, esia_oid, account_level |
EsiaProfileLinked | ЕСИА-профиль привязан к существующему аккаунту (подтверждено) | user_id, esia_oid, linked_by |
EsiaLinkSuggested | найдено совпадение по esia.auto_link_by, привязка предложена, но не подтверждена | suggested_user_id, esia_oid |
EsiaAccessRevoked | обновление токена вернуло invalid_grant (пользователь отозвал доступ на Госуслугах) | user_id, esia_oid |
EsiaConsentRecorded | зафиксировано согласие на обработку данных | user_id, consent_id |
Эти события несут только ЕСИА-специфичные метаданные (esia_oid, account_level) для комплаенс-журнала — не подмена канона: сам факт входа/регистрации идёт через provides-контракт auth-provider, ядро издаёт канонические UserLoggedIn/ UserRegistered (ревизия ядра 14.07.2026, п. 4) независимо от провайдера входа, их слушают session/audit/attack-monitor/two-factor. EsiaProfileLinked — не UserMerged: это привязка дополнительного способа входа к уже существующему аккаунту, а не слияние двух аккаунтов с переносом данных (баллы, заказы, согласия); UserMerged модуль не издаёт.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
| Ядро — аутентификация | provides-контракт auth-provider | out | модуль регистрируется как способ входа наравне с паролем/cms/oauth-* |
| Ядро — пользователи | requires (auth ядра) | in | резолвинг/создание user_id; привязка к существующему — только после подтверждения (link/confirm), не по совпадению auto_link_by автоматически |
cms/consents (requires) | сервис-вызов | out | фиксация согласия на обработку до сохранения верифицированных данных |
cms/notifications-bus (мягкая интеграция, не requires) | сервис-вызов + очередь | out | письмо/баннер-уведомление о предложенной привязке; при недоступности — предложение остаётся видимым только внутри сайта (баннер в кабинете), без деградации входа |
cms/attack-monitor (suggests) | событие о серии неудачных state-проверок/callback | out | сигнал брутфорса callback, если модуль включён |
cms/audit (если включён) | события модуля | out | вход/привязка/отзыв доступа — в аудит-лог |
cms/health | esia:availability (кеш health-пинга) | out | статус контура ЕСИА виден на общем экране здоровья модулей |
Фоновая работа
Сама авторизация синхронна (редирект → колбэк → создание/привязка аккаунта), пользователь ждёт результат сразу. Дополнительно — две лёгкие фоновые задачи через ScheduleRegistrar:
- health-пинг контура ЕСИА (
cms:esia:doctor) каждые несколько минут — результат кладётся в кешesia:availability, который читает форма входа (не живой запрос к ЕСИА на каждый показ страницы); cms:esia:expire-link-suggestions(ежедневно) — предложенные привязки старшеesia.link_confirmation_ttl_daysпереводятся в отклонённые автоматически, чтобы очередь не копилась бессрочно.
Производительность и кеш
- Ожидаемый объём: вход через ЕСИА — не горячий путь страницы, а редкое действие пользователя (разовая авторизация, не каждый визит) — на сайтах регулируемых ниш (финтех, маркетплейсы с обязательной верификацией) счёт на тысячи входов в месяц, не на каждый запрос;
- горячий путь
GET /callback— синхронный внешний HTTP-вызов к ЕСИА (обменcodeна токен + запрос верифицированных данных по скоупу), 300–1000 мс сверх обычного запроса; обязателен таймаут с graceful fallback — недоступность ЕСИА даёт понятную ошибку входа, форма при этом заранее показывает кнопку как временно недоступную (health-check, не дожидаясь фактического отказа при попытке входа); - бюджет БД на callback: ≤3 запроса (поиск профиля по
esia_oid→ создание/ обновление профиля → создание/обновление/поиск пользователя), без N+1; - уникальный индекс на
esia_oidкритичен: без него — полнотабличный скан на каждый callback и открытая гонка при параллельной обработке (двойной клик/два таба — см. «Крайние случаи»); esia.enabledиesia.min_account_levelчитаются из кеша группы настроек — 0 запросов на форме входа;esia:availability— отдельный кеш-тег результата health-пинга (не тег данных), TTL в несколько минут, инвалидируется по расписанию, не по событию;- собственных тегов кеша данных нет — профиль и журнал входов не кешируются (персональные данные, консистентность важнее скорости чтения).
Безопасность
- Верифицированные данные хранятся зашифрованными на уровне модели, не в открытом виде.
- Согласие на обработку персональных данных фиксируется через
cms/consentsдо сохранения верифицированных данных — не после. - Повторный вход с тем же
esia_oidне создаёт дублирующий аккаунт (идемпотентность привязки по уникальному индексу; параллельный вход в два таба/двойной клик — вторая вставка отклоняется на уровне БД, второй запрос получает «профиль уже обрабатывается», не тихий дубль и не 500). - В логи и журнал входов не попадает сырой IP — только
ip_hash; секреты (client_secret, сертификат) — только.env. - ПДн-паспорт (матрица v2.2):
verified_data_encryptedв зависимости от запрошенныхesia.scopesможет содержать ФИО, СНИЛС, серию/номер паспорта, дату и код подразделения выдачи, адрес регистрации, ИНН — все поля шифруютсяencryptedcast, не индексируются в открытом виде. 152-ФЗ: локализация хранения — БД с этими данными обязана быть физически в РФ (без реплик/бэкапов в зарубежной юрисдикции для этой таблицы). Срок хранения: пока аккаунт активен; после команды ядраcms:privacy:forget(обработчик модуля зарегистрирован в контрактеPrivacyRegistry,cms/core-contracts) — grace-периодesia.retention_days_after_deletion(дефолт 30 дней), затемverified_data_encryptedобезличивается (не удаляется вся строка — журнал входов сesia_oidбез прямой привязки к расшифровываемым данным сохраняется для комплаенса, как и у consent-записей). «Выгрузить всё по субъекту» — обработчик модуля вPrivacyRegistry, агрегируется командойcms:privacy:export; отдаёт расшифрованные верифицированные данные и журнал входов (безip_hash, только даты) владельцу аккаунта. - Матрица ролей:
esia.view— просмотр журнала входов и очереди привязок (админ, studio);esia.manage— настройки модуля, ручной сброс/отклонение предложенной привязки за пользователя (админ, studio); подтверждение собственной привязки (link/confirm) — не отдельное право, действие пользователя над своим же профилем. - Права:
esia.view,esia.manage.
UX-требования
Посетитель:
- кнопка «Войти через Госуслуги» — узнаваемая иконка ЕСИА; при недоступности контура (health-check) — кнопка неактивна с подсказкой «Госуслуги временно недоступны, используйте пароль или другой способ входа», не мёртвая ссылка на ошибку;
- недостаточный уровень учётной записи (
account_levelнижеesia.min_account_level) — понятное сообщение «подтвердите учётную запись на Госуслугах» со ссылкой на инструкцию подтверждения (МФЦ/банк), не техническая ошибка OAuth; - баннер предложенной привязки («похоже, это ваш аккаунт — подтвердите») — ненавязчивый, доступен при следующем обычном входе паролем, с явными кнопками «подтвердить»/ «отклонить», без давления на немедленное решение;
- после подтверждения привязки — сообщение об успешном объединении и что именно привязалось (без вывода самих ПДн в UI избыточно).
Админ:
- пустой журнал входов — «входов через ЕСИА пока не было»;
- очередь предложенных привязок показывает оставшееся время до автоотклонения (
esia.link_confirmation_ttl_days); - массовое действие — экспорт журнала входов за период (для отчёта проверяющему органу, как у
cms/consents); - для studio-роли — статус
cms:esia:doctorвиден на общем экране здоровья модулей; - ошибка «недостаточный уровень учётной записи» видна в журнале как причина отказа входа (не просто «ошибка входа» без деталей).
Крайние случаи и типовые баги
- уровень учётной записи ниже требуемого (
esia.min_account_level) → вход отклоняется до сохранения каких-либо данных профиля; пользователь видит «подтвердите учётную запись на Госуслугах» (уровни: упрощённая — только ФИО/email/телефон без верификации; стандартная — + СНИЛС/паспорт после проверки в ПФР/ФМС; подтверждённая — верифицирована лично в МФЦ/банке, доступны паспортные данные/ИНН/адрес регистрации); - отзыв согласия пользователем на стороне Госуслуг (пользователь в личном кабинете Госуслуг отзывает доступ приложению) → при следующей попытке token refresh — ошибка
invalid_grant; аккаунт на сайте не блокируется, профиль ЕСИА помечаетсяstatus = revoked(EsiaAccessRevoked), пользователю предлагается повторно авторизоваться через ЕСИА или использовать альтернативный способ входа; - недоступность ЕСИА (сервис Госуслуг лёг) → вход по паролю (если задан) остаётся рабочим — ЕСИА не единственная точка входа; форма показывает кнопку ЕСИА как временно недоступную по данным health-check, не по факту неудачного клика пользователя;
- ⚠️ Противоречие: настройка
esia.auto_link_by = "email"в исходной редакции ТЗ предполагала автоматическую молчаливую привязку ЕСИА-профиля к существующему аккаунту по совпадению email — риск account takeover (кто-то мог зарегистрировать чужой email раньше владельца, либо email в аккаунте устарел). Разрешение (единообразно сcms/oauth-*, см. oauth.md): совпадение поauto_link_byтолько предлагает объединение — создаётсяEsiaLinkSuggested,cms_esia_profiles.status = link_pending,suggested_user_idзаполнен,user_idпуст; баннер на аккаунте-кандидате требует явного действия (POST /link/confirmс паролем существующего аккаунта или кодом из письма/SMS); без подтверждения в течениеesia.link_confirmation_ttl_daysпредложение автоотклоняется и при следующем входе через тот жеesia_oidсоздаётся новый аккаунт с ЕСИА-профилем. Неразрешимая часть (что делать, если у кандидата на привязку нет ни пароля, ни подтверждённого email/ телефона для кода подтверждения) дублируется в открытые вопросы; - дубль профиля (два входа с одним
esia_oidпараллельно — гонка двойного клика/двух вкладок) → уникальный индекс наesia_oidотклоняет вторую конкурентную вставку на уровне БД; второй запрос получает «профиль уже обрабатывается, обновите страницу», не тихий дубль и не 500 (контрактный тест обязателен); - ЕСИА вернула меньше данных, чем запрошено скоупом (пользователь не дал согласие на часть данных на стороне Госуслуг) → вход не блокируется, сохраняются только фактически полученные поля, отсутствующие остаются
nullв зашифрованном блоке — предупреждение в журнале для админа, не ошибка входа для пользователя; - упрощённая учётная запись без email (в ЕСИА такое возможно) →
auto_link_byпо email невозможен — кандидата на привязку нет, создаётся новый аккаунт напрямую (без этапа предложения), это ожидаемое поведение, не баг; - модуль выключается посреди callback (пользователь ушёл к ЕСИА, вернулся — модуль уже выключен) → роут
/callbackснят из реестраauth-providerвместе с модулем, запрос получает 404 с понятной страницей «вход через Госуслуги временно недоступен», не 500; уже созданные/привязанные профили не удаляются; - измерение locale/site отсутствует →
cms_esia_profiles/cms_esia_login_logне несут собственных измерений locale/city/site в минимальной конфигурации (данные пользователя, не контент) — при мультисайтеuser_idуже разрешает принадлежность сайту через модель пользователя ядра, отдельного измерения на таблицах esia не требуется.
Донорский код
Донор: — (новая разработка).
Миграция legacy-данных (§16 стандарта): прямого донора с боевыми ЕСИА-данными нет. Если у клиента была прежняя платформа с сохранёнными привязками user_id ↔ esia_oid, cms:esia:import-legacy --source=<профиль> переносит только связку (без устаревших verified_data, чтобы не тащить непроверенные ПДн без повторной верификации) — идемпотентно по уникальному esia_oid, --dry-run с отчётом расхождений; пользователь получает актуальные верифицированные данные при следующем обычном входе через ЕСИА.
Тесты и приёмка
- [ ] Мок API ЕСИА: тест полного цикла OAuth (редирект → колбэк → создание/привязка аккаунта)
- [ ] Верифицированные данные хранятся зашифрованными, не в открытом виде (проверка на уровне БД)
- [ ] Согласие на обработку фиксируется через
cms/consentsдо сохранения персональных данных - [ ] Вход с
account_levelнижеesia.min_account_levelотклоняется до сохранения данных, с понятным сообщением - [ ] Совпадение по
esia.auto_link_byне привязывает аккаунт автоматически — создаётlink_pendingи требуетlink/confirm - [ ] Просроченное предложение привязки (
link_confirmation_ttl_days) автоотклоняется джобой, следующий вход создаёт новый аккаунт - [ ]
invalid_grantпри refresh помечает профильrevoked, не блокирует аккаунт, alt-способ входа остаётся рабочим - [ ] Недоступность ЕСИА не блокирует вход по паролю; форма показывает кнопку недоступной по
esia:availability - [ ] Повторный вход с тем же
esia_oidне создаёт дублирующий аккаунт (идемпотентность привязки), гонка двух параллельных запросов отклоняет второй без 500 - [ ] «Выгрузить всё»/«забыть по запросу» реализованы: экспорт отдаёт расшифрованные данные владельцу, запрос на удаление запускает
retention_days_after_deletionи обезличивание - [ ] При выключении модуля вход через ЕСИА недоступен (404 с понятной страницей), ранее созданные аккаунты не блокируются
- [ ] Права
esia.view/esia.manageразграничивают просмотр журнала/очереди привязок и управление настройками - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут; тестовая БД только
esia_test,migrate:freshзапрещён