Тема
ТЗ — 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_secrets | id, user_id, secret (encrypted), confirmed_at | секрет TOTP, шифруется через encrypted cast |
cms_two_factor_recovery_codes | id, user_id, code_hash, used_at | одноразовые коды, хранятся хешем |
cms_two_factor_trusted_devices | id, 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 /confirm | code (6 цифр TOTP) | FormRequest: required|digits:6; сверяется с секретом записи confirmed_at IS NULL текущего пользователя |
POST /challenge (частичная сессия после логин/пароль) | ровно одно из: code (TOTP) или recovery_code | FormRequest whitelist двух полей: code — digits:6, recovery_code — маска xxxx-xxxx; оба поля одновременно — 422 |
DELETE /two-factor | password (текущий пароль пользователя) | FormRequest: required|current_password; для enforced-роли запрос отклоняется 403 независимо от пароля |
| Отзыв доверенного устройства (Filament/API) | device_id | route-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 /enable | secret (base32), otpauth:// URL / QR | JSON-конверт ядра {data: {...}} |
Ответ POST /confirm | recovery_codes[] (N штук в открытом виде) | JSON; коды показываются только в этом ответе, повторно — никогда |
Ответ POST /challenge | факт входа, при recovery — remaining_recovery_codes | provides-контракт two-factor-auth → сессия/Sanctum-токен ядра |
| Filament (список пользователей) | индикатор статуса (enabled / enforced_missing) | бейдж в колонке списка |
cms:two-factor:audit --json | пользователи enforced-ролей без настроенной 2FA | JSON |
| Шина событий | TwoFactorEnabled/Disabled/RecoveryCodeUsed | payload — раздел «События и обмен» |
Настройки (группа two-factor)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
two-factor.enforced_roles | array | [] | нет | Роли с обязательной 2FA (студия — всегда) |
two-factor.recovery_codes_count | int | 8 | нет | Количество резервных кодов при генерации |
two-factor.trusted_device_days | int | 30 | нет | Срок доверия устройству |
two-factor.issuer_name | string | config('app.name') | нет | Имя издателя в QR-коде |
two-factor.totp_window | int | 1 | нет | Допустимый дрейф часов клиента, ±N периодов по 30 сек |
two-factor.max_challenge_attempts | int | 5 | нет | Лимит стандарта (§ Лимиты и квоты v2.2): неудачных попыток challenge до временной блокировки |
two-factor.challenge_lockout_minutes | int | 5 | нет | Лимит стандарта: длительность блокировки после превышения лимита попыток |
two-factor.emergency_disable_enforcement | bool | false | нет | 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/enable | auth | Начать настройку: получить QR/секрет |
| POST | /api/v1/admin/two-factor/confirm | auth | Подтвердить кодом, активировать |
| POST | /api/v1/admin/two-factor/challenge | auth (частичная сессия) | Проверка кода при входе |
| DELETE | /api/v1/admin/two-factor | auth | Отключить 2FA (запрет, если роль enforced) |
Компоненты
Filament: страница настройки 2FA в профиле, список доверенных устройств с отзывом, индикатор статуса 2FA в списке пользователей (для ролей studio). Команды: cms:two-factor:audit --json (пользователи с обязательной 2FA, но без настройки), cms:two-factor:import-legacy --source=<профиль> [--dry-run] --json (перенос состояния 2FA с доноров при миграции клиента, см. «Донорский код»).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
TwoFactorEnabled | пользователь подтвердил TOTP | user_id |
TwoFactorRecoveryCodeUsed | использован recovery-код | user_id, remaining_codes |
TwoFactorDisabled | 2FA отключена | 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-auth | out | ядро вызывает контракт при построении цепочки входа для enforced-ролей: после успешного логин/пароль запрашивает challenge вместо немедленной выдачи сессии |
| Ядро — пользователи/роли | сервис-вызов requires (auth) | in | резолвинг роли пользователя для проверки enforced_roles; смена роли не пересчитывает уже открытую сессию (см. «Крайние случаи») |
| Ядро — события аутентификации | событие UserPasswordChanged (канал 1) | in | инвалидация доверенных устройств пользователя (см. таблицу выше) |
| Ядро — события удаления | событие UserDeleted (канал 1) | in | каскадная очистка трёх таблиц модуля |
cms/attack-monitor (suggests) | событие TwoFactorRecoveryCodeUsed / серия неудачных challenge | out | сигнал перебора 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.view | two-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 не реализуем. Разрешение: добавить nullablelast_used_stepв миграцию модуля перед стартом разработки (обратно совместимо, состав таблиц ТЗ не меняется по сути); - timing-атака при сравнении recovery-кода — сравнение хеша только constant-time (
Hash::check/hash_equals), не===/strcmp; - утечка секрета через QR —
otpauth://-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 хранится только в зашифрованном виде (
encryptedcast), не светится в логах; - [ ] Деградация при выключении модуля не блокирует вход пользователям;
- [ ] 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.