Skip to content

ТЗ — 2FA (cms/two-factor)

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

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

Двухфакторная аутентификация для админки и клиентских кабинетов: TOTP-приложения, резервные коды на случай потери устройства, доверенные устройства для снижения навязчивости. Для ролей студии в managed-парке — обязательна (см. /cms-v2/security).

  • Настройка TOTP через QR-код (Google Authenticator, Authy и совместимые);
  • генерация и одноразовое использование recovery-кодов (набор N штук, перегенерация);
  • принудительная 2FA для указанных ролей (студия — обязательно, клиент — опционально);
  • доверенные устройства: пропуск повторного запроса на N дней после успешного входа;
  • отзыв доверия устройству вручную (из профиля или админом);
  • журнал событий 2FA (включение, отключение, использование recovery-кода).

Перспектива (не MVP): WebAuthn/passkeys как второй фактор — платформенный аналог задачи, которую сейчас частично закрывают доверенные устройства (trusted_device_days, дефолт 30 дней — «запомнить это устройство» без повторного запроса). В MVP не реализуется, только TOTP + recovery-коды; provides-контракт two-factor-auth проектируется в терминах «второй фактор подтверждён/не подтверждён», а не жёстко под TOTP, — WebAuthn добавляется позже отдельным провайдером фактора без breaking change.

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

requires: ядро (auth) · suggests: cms/attack-monitor (сигнал перебора TOTP/recovery-кодов) · provides: two-factor-auth

Поведение при выключении: вход происходит по логину/паролю без второго фактора; ранее настроенные секреты TOTP сохраняются в БД и подхватываются при повторном включении. Если для роли была включена принудительная 2FA — при выключении модуля это требование снимается автоматически (деградация, не блокировка входа).

Стоимость внешних API: не применимо — модуль не вызывает платных сервисов. TOTP (google2fa) и QR из otpauth://-строки — чистый CPU без сети; тарифной квоты нет и не появится, пока не добавлен внешний провайдер фактора (см. «WebAuthn/passkeys» выше).

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

ТаблицаКлючевые поляПримечание
cms_two_factor_secretsid, user_id, secret (encrypted), confirmed_atсекрет TOTP, шифруется через encrypted cast
cms_two_factor_recovery_codesid, user_id, code_hash, used_atодноразовые коды, хранятся хешем
cms_two_factor_trusted_devicesid, user_id, device_hash, expires_at, last_used_atдоверенные устройства

user_id — FK constrained()->index() на всех трёх таблицах. Индекс на (user_id, used_at) в recovery_codes под выборку неиспользованных кодов; на (user_id, expires_at) в trusted_devices под проверку актуальности доверия.

ПДн-паспорт. Секрет TOTP, recovery-коды и отпечаток устройства — не классические ПДн (имя/адрес/телефон), но идентифицирующие данные пользователя, доступ к которым равен компрометации аккаунта; трактуются по той же дисциплине хранения, что и ПДн:

ТаблицаСрок храненияУчастие в «забыть по запросу»
cms_two_factor_secretsпока включена 2FA пользователя; удаляется при DELETE /two-factor или отключении аккаунтада
cms_two_factor_recovery_codesдо использования конкретного кода или до перегенерации пачки (старые немедленно инвалидируются)да
cms_two_factor_trusted_devicesдо expires_at (trusted_device_days), протухшие не вычищаются активно — просто не проходят проверкуда

Модуль подписан на канонический UserDeleted ядра (см. п.3 ревизии ядра 14.07.2026) и по нему синхронно удаляет строки всех трёх таблиц по user_id — часть каскада «забыть по запросу» (152-ФЗ). В экспорт «выгрузить всё по субъекту» секрет и recovery-коды не попадают в открытом виде (они и в БД не открытые) — только факт/статус и список доверенных устройств (device_hash, даты).

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

Входы:

ИсточникДанные/поляЧем валидируется
POST /enable (форма/API, авторизованный пользователь)тело пустое, пользователь — из сессии/токенатолько auth; повторный вызов до confirm перегенерирует секрет незавершённой настройки
POST /confirmcode (6 цифр TOTP)FormRequest: required|digits:6; сверяется с секретом записи confirmed_at IS NULL текущего пользователя
POST /challenge (частичная сессия после логин/пароль)ровно одно из: code (TOTP) или recovery_codeFormRequest whitelist двух полей: codedigits:6, recovery_code — маска xxxx-xxxx; оба поля одновременно — 422
DELETE /two-factorpassword (текущий пароль пользователя)FormRequest: required|current_password; для enforced-роли запрос отклоняется 403 независимо от пароля
Отзыв доверенного устройства (Filament/API)device_idroute-model binding в рамках user_id (сам пользователь) или two-factor.manage (админ)
Слушаемое событие UserPasswordChanged (ядро, канал 1)user_idканоническое DTO ядра, не сырое Illuminate\Auth\Events\*; триггерит инвалидацию доверенных устройств (см. «События и обмен»)
Слушаемое событие UserDeleted (ядро, канал 1)user_idкаскадное удаление трёх таблиц модуля (см. «Модель данных», ПДн-паспорт)
Импорт (CLI) cms:two-factor:import-legacy --source=<профиль>донорская выгрузка (er/freelance)маппинг по external_id/user_id; см. «Донорский код»
Вебхукмодуль не принимает данные по этому каналу

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

Выходы:

ПотребительДанныеФормат
Ответ POST /enablesecret (base32), otpauth:// URL / QRJSON-конверт ядра {data: {...}}
Ответ POST /confirmrecovery_codes[] (N штук в открытом виде)JSON; коды показываются только в этом ответе, повторно — никогда
Ответ POST /challengeфакт входа, при recovery — remaining_recovery_codesprovides-контракт two-factor-auth → сессия/Sanctum-токен ядра
Filament (список пользователей)индикатор статуса (enabled / enforced_missing)бейдж в колонке списка
cms:two-factor:audit --jsonпользователи enforced-ролей без настроенной 2FAJSON
Шина событийTwoFactorEnabled/Disabled/RecoveryCodeUsedpayload — раздел «События и обмен»

Настройки (группа two-factor)

КлючТипДефолтaffectsPageCacheОписание
two-factor.enforced_rolesarray[]нетРоли с обязательной 2FA (студия — всегда)
two-factor.recovery_codes_countint8нетКоличество резервных кодов при генерации
two-factor.trusted_device_daysint30нетСрок доверия устройству
two-factor.issuer_namestringconfig('app.name')нетИмя издателя в QR-коде
two-factor.totp_windowint1нетДопустимый дрейф часов клиента, ±N периодов по 30 сек
two-factor.max_challenge_attemptsint5нетЛимит стандарта (§ Лимиты и квоты v2.2): неудачных попыток challenge до временной блокировки
two-factor.challenge_lockout_minutesint5нетЛимит стандарта: длительность блокировки после превышения лимита попыток
two-factor.emergency_disable_enforcementboolfalseнетKill-switch: аварийно снимает требование enforced_roles для всех ролей, не выключая модуль целиком — сценарий: массовая блокировка studio-пользователей из-за бага/рассинхрона времени, когда штатный вход временно недоступен даже персоналу студии

max_challenge_attempts/challenge_lockout_minutes/recovery_codes_count — явные лимиты и квоты модуля (матрица v2.2): у каждого дефолт, достижение — понятная ошибка (429 + Retry-After для challenge), не 500 и не тихое обрезание.

API

МетодПутьДоступНазначение
POST/api/v1/admin/two-factor/enableauthНачать настройку: получить QR/секрет
POST/api/v1/admin/two-factor/confirmauthПодтвердить кодом, активировать
POST/api/v1/admin/two-factor/challengeauth (частичная сессия)Проверка кода при входе
DELETE/api/v1/admin/two-factorauthОтключить 2FA (запрет, если роль enforced)

Компоненты

Filament: страница настройки 2FA в профиле, список доверенных устройств с отзывом, индикатор статуса 2FA в списке пользователей (для ролей studio). Команды: cms:two-factor:audit --json (пользователи с обязательной 2FA, но без настройки), cms:two-factor:import-legacy --source=<профиль> [--dry-run] --json (перенос состояния 2FA с доноров при миграции клиента, см. «Донорский код»).

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

СобытиеКогдаPayload
TwoFactorEnabledпользователь подтвердил TOTPuser_id
TwoFactorRecoveryCodeUsedиспользован recovery-кодuser_id, remaining_codes
TwoFactorDisabled2FA отключенаuser_id, by_user_id

Слушает (канал 1, только канонические DTO ядра — никогда Illuminate\Auth\Events\* напрямую, см. п.4 ревизии ядра 14.07.2026):

Событие ядраРеакция модуля
UserPasswordChangedнемедленная инвалидация (delete) всех записей cms_two_factor_trusted_devices пользователя — скомпрометированный пароль не должен продолжать пропускать challenge через старое доверенное устройство
UserDeletedкаскадное удаление строк всех трёх таблиц модуля по user_id (см. ПДн-паспорт в «Модель данных»)

Provides-контракт two-factor-auth — потребляется ядром при построении цепочки входа для enforced-ролей.

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

Сущность/модульКаналНаправлениеЧто происходит
Ядро — аутентификацияprovides-контракт two-factor-authoutядро вызывает контракт при построении цепочки входа для enforced-ролей: после успешного логин/пароль запрашивает challenge вместо немедленной выдачи сессии
Ядро — пользователи/ролисервис-вызов requires (auth)inрезолвинг роли пользователя для проверки enforced_roles; смена роли не пересчитывает уже открытую сессию (см. «Крайние случаи»)
Ядро — события аутентификациисобытие UserPasswordChanged (канал 1)inинвалидация доверенных устройств пользователя (см. таблицу выше)
Ядро — события удалениясобытие UserDeleted (канал 1)inкаскадная очистка трёх таблиц модуля
cms/attack-monitor (suggests)событие TwoFactorRecoveryCodeUsed / серия неудачных challengeoutсигнал перебора TOTP/recovery-кодов для детектора брутфорса, если модуль включён; при выключенном attack-monitor события просто без слушателя
Filament — список пользователейсервис-вызов (свой сервис модуля)outиндикатор статуса 2FA (enabled/enforced_missing) в колонке списка

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

Фоновой работы нет — challenge и подтверждение TOTP синхронны в рамках HTTP-запроса. Слушатели UserPasswordChanged/UserDeleted (см. «События и обмен») — тоже синхронные, объём работы (delete по индексу user_id) не требует очереди.

Мини-ранбук (§15 стандарта):

СимптомЧто проверитьКоманда/действие
Волна жалоб «неверный код» от пользователей одного клиентарассинхрон часов на стороне пользователей vs totp_window; иногда — смещённое время самого серверасверить date на сервере с NTP; при системном дрейфе — временно увеличить two-factor.totp_window (§ «Настройки»), затем починить NTP
Массовая блокировка challenge («не могу зайти», 429 у многих)max_challenge_attempts/challenge_lockout_minutes — не занижены ли после смены дефолтов; не связано ли с ботом, перебирающим чужие логиныcms:two-factor:audit --json для среза; снять блокировку конкретному пользователю — сброс счётчика попыток в БД по user_id (точечно, не массовым flush)
Всплеск неудачных challenge/TwoFactorRecoveryCodeUsed (подозрение на брутфорс)корреляция по времени/IP с cms/attack-monitor, если модуль включён (suggests); при выключенном attack-monitor — только локальные логи модулясмотреть канал attack-monitor или логи модуля two-factor; при подтверждённой атаке — принудительный отзыв доверенных устройств и recovery-кодов целевого пользователя

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

Ожидаемые объёмы (порядок величин, средний managed-проект):

  • доля пользователей с включённой 2FA: практически 100% для studio/enforced-ролей, единицы–десятки % для опциональных клиентских ролей;
  • доверенных устройств на пользователя: 1–5 (браузер, телефон, рабочий комп); trusted_device_days (дефолт 30) не даёт таблице расти неограниченно — старые записи просто перестают проходить проверку expires_at, активная чистка для консистентности не нужна;
  • recovery-кодов на пользователя: recovery_codes_count (дефолт 8), таблица растёт слабо линейно только при перегенерациях (каждая — новая пачка, старые помечаются использованными).

Горячие пути:

  • POST /challenge — синхронный путь на каждом логине enforced-роли, критичен по задержке (блокирует вход): бюджет ≤2 запроса к БД (SELECT секрета/устройства по user_id + запись попытки), сама проверка TOTP — чистый CPU (google2fa), без внешних вызовов;
  • проверка доверенного устройства — 1 индексный запрос по (user_id, expires_at)до вызова TOTP-проверки — при попадании challenge пропускается полностью;
  • выдача recovery-кодов при confirm — один bulk-insert N хешей, не N отдельных запросов.

Индексы, критичные для этих путей (см. «Модель данных»): (user_id, used_at) в recovery_codes — под выборку неиспользованных кодов при challenge; (user_id, expires_at) в trusted_devices — под проверку доверия на каждом логине. Без них — полнотабличный скан журналов на каждый вход.

Кеш: enforced_roles читается из группы настроек two-factor — 0 запросов на горячем пути (settings-store кеширует группу целиком). Секреты и доверенные устройства персональны — в общий page-cache не попадают, отдельных тегов CacheTags модуль не объявляет.

Инвалидация: отзыв доверенного устройства — прямой DELETE записи (не через кеш-тег: список не кешируется), эффект мгновенный на следующем challenge этого устройства.

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

Вход в challenge-эндпоинт — только с частичной сессией после успешного логин/пароль; FormRequest-whitelist на код TOTP/recovery (только цифры/формат). Секрет TOTP хранится исключительно в зашифрованном виде (encrypted cast), recovery-коды — хешем, не светятся в логах. ПДн-паспорт (сроки хранения, каскад «забыть по запросу» через UserDeleted) — см. «Модель данных».

Матрица ролей (permission'ы two-factor.view, two-factor.manage, two-factor.manage.own):

Рольtwo-factor.viewtwo-factor.manage (чужая 2FA)two-factor.manage.own (своя 2FA)
Админ студии (studio)дада (все пользователи, включая клиентских админов)да
Контент-менеджер клиентада (пользователи своего проекта)нетда
Редакторнетнетда
Сам пользователь (любая роль)нетнет — не может управлять чужой 2FA даже находясь «рядом» в спискеда, только свой профиль

manage подразумевает view для той же области. Отзыв чужого устройства и сброс чужой 2FA — только two-factor.manage, действие логируется (кто, кого, когда); Policy обязана отклонять manage.own-запрос над чужим user_id — 403, не молчаливое применение к себе.

Векторы атак, специфичные для модуля:

  • брутфорс TOTP/recovery-кода — rate-limit на challenge: max_challenge_attempts (дефолт 5) неудачных попыток per-user → блокировка на challenge_lockout_minutes (дефолт 5), ответ 429 + Retry-After; серия неудач — сигнал cms/attack-monitor (если включён, suggests);
  • replay-атака — один и тот же валидный TOTP-код, предъявленный повторно в пределах своего 30-секундного окна, не должен приниматься дважды (иначе перехват кода = повторный вход). ⚠️ Противоречие: текущая модель данных (cms_two_factor_secrets) не хранит state последнего принятого time-step — без него anti-replay не реализуем. Разрешение: добавить nullable last_used_step в миграцию модуля перед стартом разработки (обратно совместимо, состав таблиц ТЗ не меняется по сути);
  • timing-атака при сравнении recovery-кода — сравнение хеша только constant-time (Hash::check/hash_equals), не ===/strcmp;
  • утечка секрета через QRotpauth://-URL и сырой secret не должны попадать в access-логи, аналитику и события шины (payload событий несёт только user_id, ни разу — секрет или recovery-код в открытом виде);
  • скомпрометированный пароль + старое доверенное устройство — атакующий не должен проходить challenge через ранее доверенное устройство жертвы. Закрывается подпиской на UserPasswordChanged (п.4 ревизии ядра 14.07.2026): смена пароля немедленно инвалидирует все доверенные устройства пользователя.

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

Посетитель/пользователь:

  • QR-код и текстовый секрет (base32) показываются вместе — секрет как fallback, когда камера/сканирование недоступны (десктоп-приложения аутентификатора);
  • явное предупреждение при выдаче recovery-кодов: «сохраните эти коды сейчас — при следующем открытии страницы их не покажут повторно», с кнопкой копирования/скачивания;
  • неверный TOTP/recovery-код — общая формулировка «неверный код» (не раскрывает, какой именно фактор проверялся), но с указанием оставшихся попыток до временной блокировки («осталось попыток: 3»);
  • подтверждение перед отключением 2FA — модальное окно с предупреждением о снижении защиты + повторный ввод пароля (см. «Входные и выходные данные», DELETE).

Админ:

  • принудительная настройка при входе для enforced-ролей — сразу после успешного логин/пароль пользователь видит экран «Настройте двухфакторную аутентификацию» вместо дашборда; доступ к остальной системе заблокирован до завершения confirm (см. «Крайние случаи»);
  • список пользователей без обязательной 2FA (аудит) — Filament-список/виджет с фильтром «enforced без настройки» (данные из cms:two-factor:audit --json), пустой список — подсказка «все пользователи enforced-ролей настроили 2FA», не голая таблица;
  • отзыв доверенного устройства — из профиля (сам пользователь) или из Filament (two-factor.manage), с подтверждением; при подозрении на компрометацию — массовое действие «отозвать все устройства пользователя».

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

  • потеря устройства TOTP → вход через recovery-код; после успешного входа — явный баннер/редирект «перенастройте TOTP-приложение», секрет не сбрасывается автоматически (пользователь сам инициирует пересоздание через /enable);
  • 2FA обязательна для роли, пользователь ещё не настроил → сразу после логин/пароль — принудительная настройка (см. UX выше), доступ к остальной системе заблокирован (guard/middleware) до подтверждения confirm, не «настройте позже»;
  • брутфорс TOTP/recovery-кодовmax_challenge_attempts + challenge_lockout_minutes (см. «Безопасность»), интеграция с cms/attack-monitor, если включён;
  • часы устройства пользователя разошлись с серверомtotp_window (дефолт ±1 период, 30 сек) допускает небольшой дрейф — без этого валидные коды массово отклоняются как «неверные», что выглядит как баг модуля, а не проблема времени;
  • исчерпаны все recovery-коды → пользователь запрашивает перегенерацию — обязательна повторная аутентификация (пароль) перед выдачей новой пачки, старые коды инвалидируются немедленно;
  • двойной сабмит формы подтверждения TOTP (confirm) → идемпотентность на уровне записи секрета: confirmed_at выставляется атомарно один раз, повторный сабмит получает «уже подтверждено», а не повторную генерацию recovery-кодов;
  • replay одного и того же TOTP-кода в пределах окна → см. «⚠️ Противоречие» в «Безопасность» (нужен last_used_step) — без разрешения перехваченный код действителен повторно все 30 секунд окна;
  • смена роли на enforced у уже залогиненного пользователя → ⚠️ Противоречие: ТЗ не фиксирует момент применения требования. Разрешение: требование проверяется при следующей аутентификации (новый логин/новый токен) — уже открытая сессия не рвётся принудительно; глобальный force-logout вне зоны ответственности модуля;
  • выключение модуля, пока у роли включена принудительная 2FA → уже залогиненные ничего не замечают (сессия не рвётся); пользователь, которого до этого держал экран принудительной настройки, при следующей проверке видит его снятым — auth-provider контракт из цепочки входа исчез, вход по паролю проходит сразу;
  • гонка на границе TTL доверенного устройства (expires_at истекает в момент проверки) → трактуется как недоверенное, challenge запрашивается заново — не 500, не implicit-доверие по «последнему известному» статусу;
  • параллельный DELETE уже отключённой 2FA / уже отозванного устройства (две вкладки) → операции идемпотентны: повторный вызов возвращает понятную ошибку (404/«уже отключено»), не 500;
  • смена пароля пользователем → все доверенные устройства инвалидируются синхронно по UserPasswordChanged (см. «Безопасность», «События и обмен»); если у пользователя не было доверенных устройств — событие обрабатывается, эффекта нет (идемпотентно, не ошибка);
  • import-legacy находит секрет TOTP в нерасшифровываемом виде у донора → секрет не переносится: для пользователя выполняется принудительный сброс 2FA (запись помечена как требующая перенастройки) и отправляется уведомление — не тихий пропуск и не попытка угадать/восстановить секрет.

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

Что взятьПуть
TOTP-обвязка (google2fa)freelance/project/src/app/Domains/Identity/

Легаси-импорт (§16 стандарта). При миграции клиента с er/freelance на CMS v2 у части пользователей уже была включена 2FA на донорской платформе — команда cms:two-factor:import-legacy --source=<профиль> [--dry-run] --json переносит состояние вместо принудительной перенастройки всех:

  • <профиль> (er/freelance) — маппинг донорских таблиц/экспорта на схему модуля;
  • ключ сопоставления external_id/user_id — повторный прогон обновляет, не дублирует;
  • --dry-run — отчёт расхождений без записи, обязателен перед боевым прогоном;
  • секрет TOTP переносится только если донор хранил его в расшифровываемом виде — иначе принудительный сброс 2FA с уведомлением пользователя вместо переноса. Recovery-коды донора не переносятся никогда (проще перегенерировать).

Тесты и приёмка

  • [ ] Контрактный тест: попытка входа роли с enforced-2FA без настроенного второго фактора блокируется до настройки;
  • [ ] Recovery-код одноразовый — повторное использование того же кода отклоняется;
  • [ ] Доверенное устройство пропускает challenge только в пределах trusted_device_days, после — запрос повторяется;
  • [ ] Секрет TOTP хранится только в зашифрованном виде (encrypted cast), не светится в логах;
  • [ ] Деградация при выключении модуля не блокирует вход пользователям;
  • [ ] Rate-limit на попытки ввода TOTP-кода и recovery-кода (защита от перебора);
  • [ ] max_challenge_attempts превышен → блокировка на challenge_lockout_minutes, ответ 429 + Retry-After;
  • [ ] TOTP-код принимается при дрейфе времени в пределах totp_window (±1 период) и отклоняется за его границей;
  • [ ] Один и тот же TOTP-код не проходит challenge повторно в пределах своего окна (anti-replay);
  • [ ] Сравнение recovery-кода — constant-time (Hash::check), не подвержено timing-атаке;
  • [ ] Двойной (параллельный) сабмит confirm не создаёт вторую пачку recovery-кодов;
  • [ ] Enforced-роль без настроенной 2FA блокируется от остальной системы до confirm, не только от логина;
  • [ ] Смена роли на enforced у уже залогиненного пользователя не рвёт текущую сессию, требование применяется со следующего логина;
  • [ ] Секрет и otpauth://-URL не попадают в логи/события шины;
  • [ ] Контрактный набор cms-testing зелёный, testbench-изоляция пакета, feature-тест на каждый роут;
  • [ ] Тестовая БД только two-factor_test; migrate:fresh/refresh/reset, db:wipe запрещены;
  • [ ] Смена пароля пользователем (UserPasswordChanged) синхронно инвалидирует все его доверенные устройства;
  • [ ] cms:two-factor:import-legacy идемпотентен (повторный прогон не дублирует), --dry-run не пишет в БД, донор с нерасшифровываемым секретом → принудительный сброс + уведомление, не тихий пропуск;
  • [ ] Kill-switch two-factor.emergency_disable_enforcement=true снимает требование enforced_roles для всех ролей без выключения модуля (модуль остаётся enabled, настройка 2FA доступна желающим);
  • [ ] Событие UserDeleted синхронно очищает все три таблицы модуля (secrets, recovery_codes, trusted_devices) по user_id — часть каскада «забыть по запросу»;
  • [ ] Матрица ролей соблюдается в Policies: пользователь с two-factor.manage.own получает 403 при попытке отозвать устройство/сбросить 2FA другого user_id; только two-factor.manage управляет чужой 2FA.

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