Skip to content

ТЗ — Госуслуги/ЕСИА (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_profilesid, 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_logid, user_id, esia_oid, ip_hash, created_atжурнал входов через ЕСИА для комплаенса
  • FK user_id/suggested_user_idconstrained()->index() (оба nullable: user_id пуст, пока привязка/создание аккаунта не завершены — см. «Крайние случаи», suggested_user_id заполняется только на время предложенной, но не подтверждённой привязки);
  • уникальный индекс на esia_oid — идемпотентность привязки (гонка двойного входа);
  • account_level — PHP Enum (simplified/standard/confirmed — см. «Крайние случаи»), status — PHP Enum (linked/link_pending/revoked);
  • scopesjson() + cast array; verified_data_encrypted — шифрование на уровне модели (encrypted cast), не хранится в открытом виде — состав полей внутри см. «Безопасность» (ПДн-паспорт);
  • 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-logfilter[esia_oid], filter[account_level], sort, cursorFormRequest 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.enabledboolfalseдаПоказывать вход через ЕСИА на форме входа (kill-switch модуля)
esia.scopesarray["fullname"]нетЗапрашиваемые скоупы данных
esia.test_modebooltrueнетИспользовать тестовый контур ЕСИА
esia.min_account_levelstring"standard"нетМинимальный уровень учётной записи ЕСИА, допускаемый к входу (simplified/standard/confirmed)
esia.auto_link_bystring"email"нетПоле совпадения для предложения привязки к существующему аккаунту (не автопривязка — см. «Крайние случаи»)
esia.link_confirmation_ttl_daysint7нетСрок жизни предложенной привязки до автоотклонения
esia.allowed_redirect_hostsarray[]нетДоп. хосты, разрешённые в параметре redirect (whitelist, защита от open redirect)
esia.callback_rate_limit_per_minuteint10нетRate-limit на callback-эндпоинт (per IP)
esia.retention_days_after_deletionint30нетХранение верифицированных данных после каскада «забыть» (команда ядра cms:privacy:forget), затем обезличивание

Секреты (client_id/client_secret ЕСИА, сертификат подписи запроса) — только .env.

API

МетодПутьДоступНазначение
GET/api/v1/auth/esia/redirectguestРедирект на авторизацию ЕСИА
GET/api/v1/auth/esia/callbackguestОбработка колбэка ЕСИА, вход/регистрация/предложение привязки
GET/api/v1/auth/esia/availabilitypublicФлаг доступности ЕСИА для формы входа (health-check из кеша)
POST/api/v1/auth/esia/link/confirmauthПодтверждение предложенной привязки паролем/кодом
POST/api/v1/auth/esia/link/declineauthОтклонение предложенной привязки
GET/api/v1/admin/esia/login-logadmin (esia.view)Журнал входов через ЕСИА
GET/api/v1/admin/esia/link-suggestionsadmin (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-provideroutмодуль регистрируется как способ входа наравне с паролем/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-проверок/callbackoutсигнал брутфорса callback, если модуль включён
cms/audit (если включён)события модуляoutвход/привязка/отзыв доступа — в аудит-лог
cms/healthesia: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 может содержать ФИО, СНИЛС, серию/номер паспорта, дату и код подразделения выдачи, адрес регистрации, ИНН — все поля шифруются encrypted cast, не индексируются в открытом виде. 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 запрещён

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