Тема
ТЗ — Magic-login (cms/magic-login)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: notal Статус: ТЗ к разработке
Назначение и возможности
Вход по одноразовой подписанной ссылке из письма — без пароля. Опциональный passwordless-режим для всего сайта либо как дополнительный способ входа рядом с паролем.
- Запрос ссылки по email с ограниченным TTL (по умолчанию 15 минут);
- одноразовость: переход по ссылке инвалидирует её независимо от результата;
- rate-limit на запросы ссылки (per-email, per-IP) — защита от спама почтой;
- аудит входов через magic-link (кто, когда, IP, устройство);
- опциональный passwordless-режим сайта (форма входа без поля пароля вообще);
- подпись ссылки через
URL::temporarySignedRouteядра, без собственного секрета; - подтверждение кликом (промежуточная страница «Войти»), а не мгновенный вход по GET — защита от почтовых сканеров/антивирусов, которые открывают ссылки из письма заранее;
- не обходит второй фактор: при enforced-роли (
cms/two-factor) успешный переход по ссылке не выдаёт полный доступ сразу — та же частичная сессия + challenge, что и при входе по паролю (см. «Зависимости и выключение», «События и обмен»).
Зависимости и выключение
requires: ядро (auth, notifications-bus) · suggests: cms/attack-monitor (сигнал перебора/повторных запросов ссылки), cms/two-factor (модуль закрывает только первый фактор, второй не обходится) · provides: auth-provider
Поведение при выключении: форма входа по ссылке скрывается, остаётся вход по паролю (если passwordless-режим не был включён — иначе при выключении модуля автоматически требуется установка пароля через сброс). Уже выданные ссылки, по которым не перешли, становятся недействительными сразу — не «доживают» до истечения TTL молча.
Совместимость с cms/two-factor (критично). auth-provider от magic-login — это первый фактор, аналог пары логин/пароль, не полный вход. Если роль пользователя входит в two-factor.enforced_roles, ядро при построении цепочки входа после MagicLoginSucceeded запрашивает тот же challenge контракта two-factor-auth, что и после входа по паролю — полный доступ выдаётся только после его прохождения (контракт цепочки входа описан в two-factor.md; сама ревизия — ревизия ядра 14.07.2026, п. 4). Это гейт — контрактный тест, а не рекомендация: magic-login физически не может выдать сессию в обход challenge, если он требуется для роли пользователя.
Стоимость внешних API (матрица v2.2): не применимо — модуль не вызывает платных внешних API напрямую, только cms/notifications-bus (тарифы и деградация — в его ТЗ).
Модель данных
Своих таблиц с секретами нет — использует подписанные URL (temporarySignedRoute) без хранения токена в БД; журнал входов пишется в cms_magic_login_attempts.
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_magic_login_attempts | id, user_id (nullable), email, ip, user_agent, device_fingerprint (nullable), status, created_at | аудит запросов и переходов |
Журнал — append-only, BRIN-индекс по created_at под ретеншн/аналитику; status — PHP Enum (requested/confirmed/succeeded/expired). Индекс на (email, created_at) под rate-limit и под проверку «не более одной активной ссылки на email» (§ ниже).
ПДн-паспорт (матрица v2.2). Таблица хранит email, ip, user_agent, device_fingerprint — персональные и косвенно идентифицирующие данные. Срок хранения — настройка magic-login.log_retention_days (дефолт 90 дней), по истечении которого запись анонимизируется джобой ретеншна (см. «Фоновая работа»), не удаляется физически: журнал безопасности нужен для последующего расследования инцидентов (append-only, исправление — сторно, §4 стандарта). При UserDeleted (ревизия ядра 14.07.2026, п. 3) записи по user_id анонимизируются немедленно, вне расписания ретеншна: email → плейсхолдер, ip/user_agent/device_fingerprint → null, user_id и status сохраняются для агрегатной статистики безопасности. Выгрузка «всё по субъекту» отдаёт записи по user_id/email без device-полей.
Входные и выходные данные
Входы:
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Форма запроса ссылки (публичная) | email | FormRequest: required|email|max:255; rate-limit per-email и per-IP (rate_limit_per_hour); honeypot ядра |
| Переход по ссылке (GET, подписанный URL) | signature, expires, идентификатор попытки в подписи | URL::hasValidSignature() ядра; статус записи в cms_magic_login_attempts строго requested (не confirmed/succeeded/expired) |
| Подтверждение клика (POST со страницы «Войти по ссылке») | id попытки (из подписанного URL страницы) | повторная проверка подписи + идемпотентность (один клик = один вход, конкурентные клики не создают двух сессий) |
Всё, что не входит в этот список (произвольные query-параметры, тело запроса сверх email), отвергается на уровне FormRequest — whitelist-принцип §11 стандарта.
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Ядро — аутентификация | user_id, факт успешного входа | provides-контракт auth-provider: веб-сессия / Sanctum-токен по правилам ядра |
cms/notifications-bus | письмо со ссылкой: email, ссылка на страницу подтверждения, link_ttl_minutes | шаблон письма модуля (lang/), отправка через шину |
| Filament (журнал входов) | email, ip, user_agent, status, created_at | таблица с фильтром по email/IP/статусу |
cms:magic-login:audit --json | агрегат попыток за период | JSON |
Настройки (группа magic-login)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
magic-login.link_ttl_minutes | int | 15 | нет | Срок жизни ссылки |
magic-login.rate_limit_per_hour | int | 5 | нет | Лимит запросов ссылки на email/IP в час |
magic-login.passwordless_mode | bool | false | нет | Отключить парольный вход полностью |
magic-login.require_click_confirmation | bool | true | нет | Требовать клик по кнопке на промежуточной странице вместо мгновенного входа по GET (защита от почтовых сканеров) |
magic-login.bind_to_device | bool | false | нет | Привязывать вход к устройству, с которого запрошена ссылка (сверка device_fingerprint/IP-подсети при переходе) |
magic-login.max_active_links_per_email | int | 1 | нет | Сколько неиспользованных ссылок может существовать одновременно на email (повторный запрос инвалидирует предыдущие) |
magic-login.log_retention_days | int | 90 | нет | Срок хранения записей журнала попыток до анонимизации |
magic-login.emergency_disable | bool | false | нет | Kill-switch: аварийно скрывает форму запроса ссылки и блокирует выдачу новых, не трогая passwordless_mode |
rate_limit_per_hour и max_active_links_per_email — явные лимиты и квоты (матрица v2.2): защита от спама почтой и накопления неиспользованных ссылок; достижение лимита — понятная ошибка формы, не 500 и не тихий пропуск.
emergency_disable — kill-switch (матрица v2.2), отдельный от passwordless_mode: включается на время инцидента (например, подозрение на перехват ссылок через скомпрометированный cms/notifications-bus), скрывает форму и блокирует POST /request (см. «API», «Крайние случаи»), не требует выключения модуля и не трогает уже настроенный passwordless-режим — клиент не остаётся вообще без входа.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/auth/magic-login/request | public (rate-limit) | Запросить ссылку на email |
| GET | /api/v1/auth/magic-login/{signature} | public (подписанный URL) | Страница подтверждения перехода (не выполняет вход сама по себе, если включено require_click_confirmation) |
| POST | /api/v1/auth/magic-login/{signature}/confirm | public (подписанный URL) | Подтверждение кликом → фактический вход |
При magic-login.emergency_disable=true — POST /request отвечает 423 Locked с человеческим сообщением («вход по ссылке временно недоступен, используйте пароль»); уже выданные до инцидента ссылки продолжают работать штатно (kill-switch не отзывает их массово — для этого есть ручная «инвалидировать все активные ссылки», см. «Безопасность»).
Компоненты
Filament: журнал входов по magic-link с фильтром по email/IP/статусу, карточка «активные ссылки» с ручной инвалидацией. Команды: cms:magic-login:audit --json.
Ранбук (матрица v2.2, эксплуатация §15 ядра) — типовые инциденты модуля:
| Симптом | Что проверить | Команда/действие |
|---|---|---|
| Волна писем не доходит | health cms/notifications-bus (очередь/транспорт) | queue:failed + health-чек шины; при затяжном сбое — включить magic-login.emergency_disable, чтобы не копить «зависшие» requested-попытки |
Всплеск MagicLoginRequested с одного IP | корреляция в cms/attack-monitor (если включён), журнал попыток по IP | Filament → журнал, фильтр по IP → массовая ручная инвалидация активных ссылок затронутых email |
| Жалобы «ссылка не работает» | рассинхрон link_ttl_minutes и require_click_confirmation (письмо не открыли вовремя / открыли, но не нажали кнопку) | cms:magic-login:audit --json за период по email — статус попытки (expired vs requested) объясняет причину без ручного доступа к БД |
События и обмен
| Событие | Когда | Payload |
|---|---|---|
MagicLoginRequested | запрошена ссылка | email, ip |
MagicLoginConfirmed | клик по ссылке подтверждён, ожидает входа (если require_click_confirmation) | email, ip |
MagicLoginSucceeded | успешный вход по ссылке | user_id, ip |
MagicLoginExpired | переход по просроченной/использованной ссылке | email, ip |
MagicLoginSucceeded — доменное событие модуля (факт прохождения именно magic-ссылки), не заменяет и не дублирует событие ядра UserLoggedIn (ревизия ядра 14.07.2026, п. 4): оба издаются на своём уровне, UserLoggedIn — при фактической выдаче сессии ядром, то есть после challenge, если он требовался для роли.
Слушает: UserDeleted (ревизия ядра 14.07.2026, п. 3) — тонкий слушатель ставит джобу анонимизации записей cms_magic_login_attempts по user_id в очередь magic-login, сам не пишет в БД синхронно (§9 стандарта; см. «ПДн-паспорт» и «Фоновая работа»). Отправку письма со ссылкой инициирует через cms/notifications-bus. Provides-контракт auth-provider — потребляется ядром наравне с паролем/OAuth; для enforced-ролей cms/two-factor — как первый фактор в цепочке входа, не как её завершение (см. «Зависимости и выключение»).
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
| Ядро — аутентификация | provides-контракт auth-provider | out | модуль регистрируется как способ входа; ядро вызывает его при валидной подтверждённой ссылке |
| Ядро — пользователи | требуется (requires, часть auth ядра) | in | резолвинг user_id по email при запросе ссылки; email без учётки — попытка логируется как requested, письмо не выдаёт существование/отсутствие аккаунта (нейтральный ответ формы) |
| Ядро — пользователи | событие UserDeleted | in | джоба анонимизирует записи журнала попыток по user_id (§4 стандарта, ПДн-каскад) |
cms/notifications-bus | сервис-вызов (requires) + очередь | out | отправка письма со ссылкой; таймаут/сбой шины — попытка остаётся requested, пользователь видит нейтральное «если email существует, письмо отправлено» |
cms/attack-monitor (suggests) | событие MagicLoginExpired/серия MagicLoginRequested | out | сигнал перебора токенов или спама запросами — используется детектором брутфорса, если модуль включён; при выключенном attack-monitor события просто не имеют слушателя |
cms/two-factor (suggests) | provides-контракт two-factor-auth, потребляется ядром | in | после MagicLoginSucceeded для enforced-роли ядро запрашивает challenge; полный доступ — только после его прохождения, magic-login не выдаёт сессию в обход |
Фоновая работа
Синхронный путь — запрос и переход по ссылке в рамках HTTP-запроса, письмо со ссылкой уходит через очередь cms/notifications-bus. Фоновые джобы:
- ретеншн журнала — расписание через
ScheduleRegistrarядра, ежесуточно: анонимизирует записиcms_magic_login_attemptsстаршеlog_retention_days(email/ip/user_agent/device_fingerprint → null), идемпотентна (повторный прогон не трогает уже анонимизированные записи — фильтрemail IS NOT NULL); - анонимизация по
UserDeleted— тонкий слушатель кладёт джобу в очередьmagic-login; джоба анонимизирует записи поuser_idвне расписания, сразу после удаления пользователя (см. «ПДн-паспорт»), идемпотентна при повторной доставке события.
Производительность и кеш
- Ожидаемый объём: десятки–сотни запросов ссылки в сутки на средний сайт, всплеск при фишинговых/брутфорс-атаках — до тысяч записей в
cms_magic_login_attemptsза час (закладывается в бюджет rate-limit, не в размер таблицы — журнал append-only и лёгкий); - горячий путь —
POST /requestиGET/POST /{signature}/confirm: бюджет ≤2 запроса к БД (rate-limit по(email, created_at)индексу + вставка/обновление записи попытки), без N+1; - индекс
(email, created_at)критичен для rate-limit и дляmax_active_links_per_email; без него полнотабличный скан журнала на каждый запрос ссылки; - отдельных тегов кеша не объявляет — журнал попыток персонален, в общий page-cache не попадает; настройки группы
magic-loginчитаются из кеша settings-store (0 запросов на горячем пути); - ретеншн-джоба и анонимизация по
UserDeleted— батчем (chunkById), вне горячего пути, бюджет запросов страницы входа не затрагивают.
Безопасность
Ссылка подписывается штатным URL::temporarySignedRoute ядра — без собственного секрета и без предсказуемых параметров. Rate-limit на запрос ссылки (per-email, per-IP). Повторный переход по уже использованной или просроченной ссылке отклоняется до проверки бизнес-логики. Векторы атак, специфичные для модуля:
- пересылка ссылки третьему лицу — одноразовость (первый переход инвалидирует ссылку независимо от исхода) плюс опциональная привязка к устройству (
bind_to_device) снижает ущерб, но не заменяет её — при включённой привязке переход с другого устройства/подсети требует повторного запроса; - почтовые сканеры/антивирусы, «кликающие» ссылки —
require_click_confirmation: GET на подписанный URL только показывает страницу, вход выполняется отдельным POST по явному действию пользователя; - перебор подписей —
URL::hasValidSignature()криптографически стоек, но попытки логируются и доступныcms/attack-monitorкак сигнал; - enumeration email (проверка, зарегистрирован ли адрес) — ответ формы запроса ссылки одинаков независимо от существования аккаунта;
- обход второго фактора через альтернативный способ входа — magic-login не лазейка мимо
cms/two-factor:auth-providerмодуля закрывает только первый фактор, ядро всегда достраивает цепочку challenge для enforced-ролей (см. «Зависимости и выключение»); проверяется контрактным тестом как гейт, не как рекомендация.
Матрица ролей (матрица v2.2):
| Роль | magic-login.view (видит журнал попыток) | magic-login.manage (инвалидирует все активные ссылки, переключает emergency_disable) |
|---|---|---|
| admin | да | да |
| studio | да | да |
| менеджер | да | нет |
| редактор | нет | нет |
Массовая инвалидация активных ссылок и kill-switch emergency_disable — только magic-login.manage (повышенная роль), не входит в дефолтные права менеджера/редактора (§11 стандарта: опасные действия — только повышенные роли).
UX-требования
Админ:
- пустой журнал попыток — подсказка «попыток входа по ссылке ещё не было» вместо голой таблицы;
- массовое действие в журнале — «инвалидировать все активные ссылки» (например, при подозрении на компрометацию почтового ящика клиента), с подтверждением;
- ошибки на человеческом языке: «письмо не удалось отправить — проверьте настройки
cms/notifications-bus», а не стек-трейс транспорта; - переключение
passwordless_modeвtrueпри пользователях без пароля — предупреждение и подтверждение необратимого шага («пользователи без пароля не смогут войти иначе»); - включённый
emergency_disable— баннер в Filament «вход по ссылке временно отключён» с кнопкой быстрого выключения; форма запроса ссылки на сайте скрывается автоматически,passwordless_modeпри этом не трогается.
Посетитель:
- форма запроса ссылки после отправки показывает нейтральное сообщение («если такой email зарегистрирован, письмо отправлено») — не подтверждает и не опровергает существование аккаунта;
- страница подтверждения перехода — явная кнопка «Войти», не автоматический редирект;
- истёкшая/использованная ссылка — понятная страница с кнопкой «запросить новую», не общая ошибка 403/404;
- переход по ссылке при enforced-2FA роли (см. «Зависимости») — сразу экран ввода TOTP/recovery-кода, не дашборд: страница подтверждения не показывает «вы вошли», пока challenge не пройден.
Крайние случаи и типовые баги
- двойной клик по кнопке подтверждения (гонка) → идемпотентность на уровне записи попытки: первый запрос переводит статус
requested → succeededатомарно (lockForUpdate/условное обновление), второй получает уже «использовано», не создаёт вторую сессию; - почтовый сканер открывает ссылку раньше пользователя → при
require_click_confirmation=trueGET не меняет статус попытки, только показывает страницу; статус меняется исключительно явным POST-подтверждением; - ссылка переслана третьему лицу → первый переход/подтверждение инвалидирует ссылку; при
bind_to_device=trueпереход с другого fingerprint/подсети отклоняется с логированием как подозрительная попытка; - истечение TTL и повторный запрос →
max_active_links_per_email=1по умолчанию: новый запрос инвалидирует предыдущую неиспользованную ссылку, письмо уходит с новой; - перебор подписей (brute force токена) → криптографическая стойкость подписи делает перебор непрактичным, но серия неудачных
GET/POST /{signature}с невалидной подписью логируется и доступнаcms/attack-monitorпри его включении; - выключение модуля посреди активной ссылки → уже выданные ссылки перестают работать сразу (провайдер
auth-providerснят из реестра), пользователь видит «вход по ссылке недоступен, используйте пароль» — не 500; cms/attack-monitorне установлен → событияMagicLoginRequested/Expiredиздаются как обычно (шина не требует слушателей), деградации функциональности нет — просто нет автоблокировки перебора на уровне attack-monitor;- сбой
cms/notifications-bus→ попытка остаётся в статусеrequested, письмо не отправлено; повторный запрос в пределах rate-limit создаёт новую попытку, старая не блокирует (не «залипает» как активная навечно — есть TTL); - passwordless-режим включён, у пользователя нет email (создан только с логином) → ⚠️ Противоречие: ТЗ не описывает поведение для пользователей без email в passwordless-режиме, где вход только по ссылке. Разрешение: passwordless-режим требует email как обязательное поле профиля — валидация при включении режима в Filament (список пользователей без email блокирует включение или помечается к ручной донастройке);
- измерение site/locale отсутствует (одиночный сайт без мультисайта) → письмо и страница подтверждения используют дефолтную локаль/сайт без ветвления кода —
RequestContextотдаёт nullable-измерения, шаблон письма не завязан наsite_id; - пользователь с enforced 2FA входит по magic-ссылке →
MagicLoginSucceededне равен полному входу: ядро запрашиваетchallengeконтрактаtwo-factor-auth, страница ведёт на экран TOTP/recovery-кода; прямой вызов API в обход challenge — 403 (гейт, контрактный тест, не рекомендация); emergency_disable=trueв момент, когда письмо уже отправлено → уже выданные ссылки продолжают действовать штатно (kill-switch не отзывает их массово), новыеPOST /requestотвечают423; после выключения kill-switch форма появляется снова без побочных эффектов наpasswordless_mode;UserDeletedпри незавершённых (requested) попытках в журнале → анонимизация не зависит от статуса попытки: анонимизируются все записи поuser_idнезависимо отstatus, запись не удаляется — аудит безопасности не теряется.
Донорский код
| Что взять | Путь |
|---|---|
| Логика одноразовых ссылок входа | notal (модуль passwordless-входа) |
Легаси-импорт (§16 стандарта) — не применимо. Модуль не хранит собственных учётных данных: вход построен на подписанных URL без секрета в БД, переносить нечего. Единственные исторические данные донора (notal) — журнал попыток входа по ссылке; он не переносится: это аудит безопасности конкретного инстанса (IP/user-agent/фингерпринты старой системы не релевантны новой инфраструктуре и её собственному ретеншну), импорт создал бы записи с чужими гарантиями актуальности.
Тесты и приёмка
- [ ] Контрактный тест: повторный переход по уже использованной ссылке отклоняется;
- [ ] Ссылка после истечения
link_ttl_minutesне проходит валидацию подписи/времени; - [ ] Rate-limit блокирует повторные запросы ссылки сверх лимита в час;
- [ ] Аудит фиксирует каждую попытку (успех/просрочку/повтор), доступен в Filament;
- [ ] Деградация при выключении не блокирует вход по паролю (если passwordless не был единственным);
- [ ] Ссылка не содержит предсказуемых параметров — подпись через штатный механизм ядра;
- [ ] GET по подписанному URL при
require_click_confirmation=trueне выполняет вход — только показывает страницу; вход выполняется отдельным POST; - [ ] Двойной (параллельный) клик подтверждения не создаёт двух сессий — идемпотентность на уровне записи попытки;
- [ ] При
bind_to_device=trueпереход с другого устройства/подсети отклоняется и логируется; - [ ] Повторный запрос ссылки инвалидирует предыдущую активную ссылку (
max_active_links_per_email); - [ ] Ответ формы запроса ссылки не раскрывает существование email в системе;
- [ ] Вход по magic-ссылке пользователем с enforced 2FA не даёт полного доступа без прохождения
challenge— гейт, контрактный тест; - [ ]
UserDeletedанонимизирует записи журнала попыток поuser_id(email/ip/user_agent/device_fingerprint → плейсхолдер/null) независимо отstatus, не удаляет их; - [ ]
magic-login.emergency_disable=trueскрывает форму и возвращает423наPOST /request, не трогаяpasswordless_modeи уже выданные ссылки; - [ ] Ретеншн-джоба анонимизирует записи старше
log_retention_days, повторный прогон идемпотентен; - [ ] Контрактный набор
cms-testingзелёный, testbench-изоляция пакета, feature-тест на каждый роут; - [ ] Тестовая БД только
magic-login_test;migrate:fresh/refresh/reset,db:wipeзапрещены.