Тема
ТЗ — Политика паролей (cms/password-policy)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Правила сложности и жизненного цикла пароля поверх штатной аутентификации ядра: длина/сложность, срок действия, запрет повторного использования, проверка на утечку через k-анонимность HIBP.
- Настраиваемые правила сложности: минимальная длина, требования к составу символов;
- срок действия пароля (опционально) с принудительной сменой по истечении;
- история паролей: запрет установить один из последних N использованных;
- проверка компрометации через HIBP k-анонимность (запрос через
cms/integrations-bus, без передачи пароля целиком); - офлайн-словарь скомпрометированных/слабых паролей как fallback-уровень защиты при длительной недоступности HIBP (см. «Крайние случаи»);
- принудительная смена пароля при первом входе (для аккаунтов, созданных админом);
- уведомление пользователю при обнаружении пароля в утечке;
- раздельные профили строгости для веб-входа и API-паролей (сервисные/интеграционные учётки живут по своим правилам, не по правилам конечного пользователя);
- аварийный kill-switch, снимающий принуждение к смене пароля без выключения модуля целиком (см. «Настройки»).
Зависимости и выключение
requires: ядро (auth) · suggests: cms/integrations-bus (HIBP), cms/notifications-bus (уведомление об утечке)
Поведение при выключении: действуют только правила валидации пароля по умолчанию из ядра (минимальная длина Laravel Password::defaults()), без истории, срока действия и проверки на утечку. Смена политики (включение/ужесточение модуля) не требует немедленной смены пароля у существующих пользователей — новые правила применяются только при следующей смене пароля (см. «Крайние случаи»).
Стоимость внешних API: не применимо в денежном смысле — k-анонимность HIBP (/range/{prefix}) бесплатна, ключ API не нужен. Эксплуатационный риск иной природы: у HIBP собственный rate-limit на число запросов в единицу времени; при массовой смене паролей (например, принудительный сброс всем после инцидента) очередь запросов к HIBP может упереться в этот лимит — деградация закрывается офлайн-словарём (offline_wordlist_fallback, см. «Настройки», «Крайние случаи»), не блокировкой смены пароля.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_password_history | id, user_id, password_hash, created_at | последние N хешей паролей пользователя |
FK user_id — constrained()->index(); выборка последних N — по (user_id, created_at desc). Таблица растёт линейно с числом смен пароля — старые записи сверх history_count чистит фоновая джоба-ретеншн (см. «Фоновая работа»).
ПДн-паспорт. cms_password_history не хранит пароль в открытом виде и не хранит классические ПДн (имя/адрес/телефон) — только хеш и user_id, но привязка к пользователю относит её к дисциплине хранения данных субъекта:
| Аспект | Значение |
|---|---|
| Срок хранения | не календарный, а количественный — ограничен history_count (дефолт 5); записи сверх лимита удаляет фоновая джоба-ретеншн, привязанная к той же настройке (см. «Фоновая работа»); при history_count=0 таблица не растёт |
| Участие в «выгрузить всё по субъекту» | не применимо — хеш без исходного пароля не несёт пользователю полезной информации о себе, в экспорт не включается |
| Участие в «забыть по запросу» (152-ФЗ) | да, каскадно: модуль подписан на канонический UserDeleted ядра (см. п.3 ревизии ядра 14.07.2026) и синхронно удаляет всю историю паролей пользователя по user_id |
Входные и выходные данные
Входы:
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Форма смены пароля (ядро) | password, password_confirmation | FormRequest ядра расширяется правилом модуля (PasswordPolicyRule): длина, состав символов, история, HIBP-проверка (асинхронно/синхронно по настройке) с офлайн-словарём как fallback |
| Форма входа (ядро) | email/login, password | не изменяется модулем — только проверка expires_days/force_change_on_first_login после успешной аутентификации (редирект на смену), с учётом kill-switch emergency_disable_enforcement |
| Filament: настройка политики | все ключи группы password-policy | whitelist типов и диапазонов (например, min_length не может быть меньше системного минимума ядра) |
Слушаемое событие UserPasswordChanged (ядро, канал 1) | user_id | каноническое DTO ядра, не сырое Illuminate\Auth\Events\*; триггерит побочные эффекты после смены, не саму валидацию (см. «События и обмен») |
Слушаемое событие UserDeleted (ядро, канал 1) | user_id | каскадное удаление cms_password_history (см. «Модель данных», ПДн-паспорт) |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Ядро — auth (middleware принудительной смены) | флаг «пароль требует смены» | внутренний флаг сессии/пользователя, не публичный API |
cms/integrations-bus (suggests) | префикс SHA-1 хеша пароля (k-анонимность) | исходящий HTTP-запрос к HIBP через шину интеграций |
cms/notifications-bus (suggests) | user_id, факт компрометации | уведомление пользователю |
| Filament (отчёт, индикатор) | user_id, expires_at, force_change | таблица/индикатор в профиле |
cms:password-policy:audit --json | список пользователей с истёкшим/непринудительно сменённым паролем | JSON |
Настройки (группа password-policy)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
password-policy.min_length | int | 10 | нет | Лимит стандарта: минимальная длина пароля |
password-policy.history_count | int | 5 | нет | Лимит стандарта: запрет повтора последних N паролей; та же настройка задаёт глубину/срок хранения cms_password_history (см. ПДн-паспорт) |
password-policy.expires_days | int | 0 | нет | Лимит стандарта: срок действия пароля (0 — бессрочно) |
password-policy.check_breached | bool | true | нет | Проверять пароль через HIBP k-анонимность |
password-policy.offline_wordlist_fallback | bool | true | нет | Подключать офлайн-словарь скомпрометированных/слабых паролей при длительной недоступности HIBP (см. «Крайние случаи») |
password-policy.force_change_on_first_login | bool | true | нет | Принудительная смена при первом входе для аккаунтов от админа |
password-policy.apply_to_api_tokens | bool | false | нет | Применять эти же правила к паролям сервисных/API-учёток (иначе — только веб-вход) |
password-policy.grace_logins_after_policy_change | int | 3 | нет | Сколько успешных входов разрешить по старому паролю после ужесточения политики, прежде чем принудить смену |
password-policy.emergency_disable_enforcement | bool | false | нет | Kill-switch: аварийно снимает принуждение — force_change_on_first_login и просрочка по expires_days перестают требовать смены пароля до входа, без выключения модуля целиком; сценарий — ошибочно ужесточённая политика заблокировала вход множеству пользователей |
min_length/history_count/expires_days — явные лимиты и квоты модуля (матрица v2.2): у каждого есть дефолт, достижение обрабатывается понятной ошибкой валидации формы смены пароля, не 500 и не тихим игнорированием.
API
Отдельного API нет — правила применяются через FormRequest ядра при смене пароля.
Компоненты
Filament: индикатор «пароль скоро истечёт» в профиле, отчёт по пользователям с просроченным паролем, индикатор текущего режима проверки утечек (HIBP / офлайн-словарь / выключено). Команды: cms:password-policy:audit --json (пользователи с истёкшим сроком или без принудительной смены после создания).
Офлайн-словарь — статический бандл топ-N скомпрометированных/слабых паролей, поставляется вместе с модулем и обновляется релизами (не скачивается динамически в рантайме). Проверка — хеш-множество/bloom-фильтр в памяти, без дополнительных запросов к БД или сети; уступает HIBP по полноте, но не оставляет проверку полностью выключенной при недоступности внешнего сервиса.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
PasswordBreachDetected | пароль найден в базе утечек (HIBP или офлайн-словарь) | user_id, source (hibp/offline_wordlist) |
PasswordExpired | истёк срок действия пароля | user_id |
Слушает (канал 1, только канонические DTO ядра, см. п.3–4 ревизии ядра 14.07.2026):
| Событие ядра | Реакция модуля |
|---|---|
UserPasswordChanged | асинхронные побочные эффекты, не сама валидация: сброс флага force_change, если он был установлен (принудительная смена выполнена); опционально — уведомление через cms/notifications-bus («ваш пароль был изменён», сигнал против account takeover) |
UserDeleted | каскадное удаление cms_password_history пользователя по user_id (см. «Модель данных», ПДн-паспорт) |
Запись нового хеша в cms_password_history — не реакция на UserPasswordChanged: она выполняется синхронно, в той же транзакции, что и обновление хеша пароля пользователя (нужна атомарность — см. «Крайние случаи», гонка при параллельной смене). Событие UserPasswordChanged эмитится ядром уже после этой транзакции и используется модулем только для второстепенных эффектов, для которых синхронность не требуется.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
| Ядро — auth (смена пароля) | FormRequest-правило (расширение валидации ядра) | in | модуль встраивает свои rules в существующий FormRequest ядра, не подменяет его |
cms/integrations-bus (suggests) | сервис-вызов + очередь | out | запрос префикса хеша к HIBP; при отсутствии модуля — check_breached фактически не работает, ошибки нет (см. «Крайние случаи») |
cms/notifications-bus (suggests) | событие PasswordBreachDetected → отправка письма | out | уведомление пользователя об утечке; без модуля — только событие, без письма |
| Ядро — пользователи (аккаунт создан админом) | сервис-вызов (часть auth ядра) | in | флаг force_change_on_first_login читается при первом успешном входе |
| Ядро — события аутентификации | событие UserPasswordChanged (канал 1) | in | асинхронный сброс флага force_change, опциональное уведомление о смене пароля (см. таблицу выше) |
| Ядро — события удаления | событие UserDeleted (канал 1) | in | каскадная очистка cms_password_history пользователя |
Фоновая работа
Именованная очередь password-policy для запроса к HIBP (внешний HTTP-вызов — только из очереди, с graceful fallback при недоступности сервиса), для проверки expires_days по расписанию и для ретеншна cms_password_history (чистка записей сверх history_count, см. ПДн-паспорт).
Мониторинг доступности HIBP — счётчик подряд неуспешных запросов; при превышении порога (несколько часов подряд без успешного ответа) и включённом offline_wordlist_fallback модуль автоматически переключает проверку компрометации на офлайн-словарь до восстановления HIBP, без разрыва в защите (см. «Крайние случаи»).
Мини-ранбук (§15 стандарта):
| Симптом | Что проверить | Команда/действие |
|---|---|---|
| HIBP массово недоступен (таймауты/5xx у многих пользователей) | health-статус cms/integrations-bus и код последней ошибки HIBP-запроса | health-чек cms/integrations-bus; убедиться, что password-policy.offline_wordlist_fallback=true — модуль переключится на офлайн-словарь автоматически (см. «Фоновая работа») |
| Волна принудительных смен после ужесточения политики (всплеск обращений) | grace_logins_after_policy_change — не занижен ли после смены дефолтов; масштаб волны через cms:password-policy:audit --json | увеличить grace_logins_after_policy_change на переходный период; коммуникация с пользователями заранее (баннер/письмо о предстоящих требованиях); в крайнем случае — kill-switch emergency_disable_enforcement |
| Жалобы «новый пароль не принимается» | min_length/состав символов в настройках группы password-policy; не путается ли отказ по сложности с отказом по истории/офлайн-словарю | Filament → настройки password-policy; cms:password-policy:audit --json причину конкретного отказа не покажет — смотреть лог валидации по user_id |
Производительность и кеш
- Ожидаемый объём: одна проверка политики на каждую смену пароля (не на каждый вход) — низкая частота, десятки–сотни событий в месяц на средний сайт;
- горячий путь — смена пароля: бюджет ≤2 запроса (выборка истории по
(user_id, created_at desc) limit history_count, вставка нового хеша); HIBP-запрос вынесен в очередь и не входит в синхронный бюджет — форма не ждёт внешний HTTP; - офлайн-словарь (если активен) — статическая in-memory структура, проверка не добавляет запросов к БД/сети и укладывается в тот же синхронный бюджет;
- вход в систему (не смена пароля) — бюджет 0 дополнительных запросов сверх auth ядра: проверка
expires_days/force_change— по уже загруженной модели пользователя, без отдельного SELECT; - индекс
(user_id, created_at desc)вcms_password_historyкритичен — без него выборка последних N паролей деградирует до полного скана истории пользователя; - настройки читаются из кеша группы (0 запросов на горячем пути); отдельных тегов кеша модуль не объявляет — история и статус пароля персональны, в page-cache не попадают.
Безопасность
Проверка HIBP передаёт только префикс хеша (k-анонимность), не пароль и не полный хеш — запрос строго через cms/integrations-bus (SSRF-защита), не напрямую. История паролей хранит только хеши. Принудительная смена при первом входе не даёт доступа к остальному функционалу до смены. Векторы, специфичные для модуля:
- утечка пароля через логи — пароль в открытом виде никогда не пишется в лог/аудит, даже при ошибке HIBP-запроса (логируется только факт и код ошибки, не содержимое);
- timing-атака на сравнение с историей — сравнение хешей через штатные механизмы Laravel (
Hash::check), не через===/побайтовое сравнение; - обход политики через API-токены —
apply_to_api_tokens=falseпо умолчанию означает, что сервисные пароли/токены не подпадают под правила веб-пользователя; это осознанная развилка, а не дыра — API-пароли имеют свою модель угроз (ротация токенов, не «сложность запоминаемого пароля»); - HIBP как канал деанонимизации — только префикс (k-анонимность), исходящий запрос проходит SSRF-защиту
integrations-bus, не может быть переориентирован на внутренний адрес; - офлайн-словарь как единственная линия защиты — режим офлайн-словаря (см. «Фоновая работа») слабее HIBP по полноте базы, это осознанный компромисс временной деградации, а не постоянная замена: при восстановлении HIBP модуль возвращается к нему автоматически.
Матрица ролей (permission'ы password-policy.view, password-policy.manage):
| Роль | password-policy.view (отчёт по истечению паролей) | password-policy.manage (настройки политики, массовое принуждение к смене) |
|---|---|---|
| Админ студии (studio) | да | да |
| Контент-менеджер клиента | да (пользователи своего проекта) | нет |
| Редактор | нет | нет |
manage подразумевает view. Массовое действие «принудить смену пароля» (см. «UX-требования») и изменение настроек группы password-policy (включая kill-switch и offline_wordlist_fallback) — только password-policy.manage: рядовой контент-менеджер видит, у кого просрочен пароль, но не может ни массово принудить к смене, ни ослабить/ужесточить политику.
UX-требования
Админ:
- отчёт «пользователи с истёкшим паролем» — пустое состояние «все пароли актуальны»;
- массовое действие — «принудить смену пароля» для выбранных пользователей (например, после инцидента), доступно только
password-policy.manage(см. «Безопасность»); - ошибка «HIBP недоступен» на человеческом языке в логе/health-чеке, не в лице пользователя, меняющего пароль (пользователь не должен видеть техническую причину задержки проверки);
- изменение политики (например, повышение
min_length) в Filament — предупреждение «не затронет существующие пароли до следующей смены», чтобы админ не считал это мгновенным принуждением.
Посетитель/пользователь:
- индикатор «пароль скоро истечёт» — заранее (не в момент истечения), с прямой ссылкой на смену;
- уведомление об утечке — не паническое, с чёткими шагами («смените пароль по ссылке»);
- принудительная смена при первом входе — отдельный понятный экран «установите новый пароль», не общая ошибка доступа.
Крайние случаи и типовые баги
- смена политики не должна ломать вход существующим пользователям → ужесточение
min_length/состава символов не инвалидирует уже установленные пароли: старый пароль продолжает пускать во вход, принудительная смена по новым правилам происходит при следующей смене пароля пользователем (grace_logins_after_policy_changeсглаживает переход, если включена принудительная смена сразу); - проверка по словарям утечек недоступна (HIBP timeout/5xx) →
check_breached=trueне блокирует смену пароля при недоступности сервиса — запрос уходит в очередь с graceful fallback, пароль принимается, проверка повторяется позже job'ом; пароль не остаётся «непроверенным навсегда» — есть ретрай с backoff; - длительная недоступность HIBP → при превышении порога подряд неуспешных запросов (см. «Фоновая работа») и
offline_wordlist_fallback=trueмодуль переключает проверку на офлайн-словарь: пароли из локального топ-N по-прежнему отклоняются — защита не выключается полностью, хотя и уступает HIBP по полноте; событиеPasswordBreachDetectedв этом режиме несётsource=offline_wordlist; при восстановлении HIBP модуль возвращается к нему автоматически; - политика для API-паролей vs веб →
apply_to_api_tokens=falseпо умолчанию: сервисные учётки не подчиняются истории/сроку действия веб-пользователя; включениеtrueтребует отдельного пути ротации токенов, иначе интеграции падают синхронно с истечением — документируется как риск при включении; - гонка: параллельная смена пароля с двух вкладок → последняя успешно завершённая транзакция побеждает, история пароля пишется атомарно (вставка в
cms_password_historyв той же транзакции, что и обновление хеша пользователя) — не создаёт дублирующихся записей истории при повторе; - выключение модуля посреди принудительной смены → пользователь, которому не дали доступ до смены пароля, при выключении модуля должен получить обычный доступ сразу (флаг принудительной смены снимается вместе с модулем) — не залипает в заблокированном состоянии без объяснения;
- kill-switch
emergency_disable_enforcementвключён →force_change_on_first_loginи просрочка поexpires_daysперестают требовать смены пароля до входа; сами правила валидации нового пароля (min_length, история, HIBP/офлайн-словарь) при этом продолжают действовать при следующей добровольной смене — kill-switch снимает только принуждение, не ослабляет сложность нового пароля; - отсутствие
cms/integrations-bus(suggests не установлен) →check_breachedфактически не выполняется (нет пути к HIBP), политика не блокирует смену пароля из-за недоступности проверки — health-чек модуля отражает «HIBP: не настроено», не падает; офлайн-словарь при этом может оставаться единственным работающим уровнем проверки, еслиoffline_wordlist_fallback=true; - отсутствие
cms/notifications-bus→ событиеPasswordBreachDetectedиздаётся как обычно, письмо не уходит — пользователь не узнает об утечке проактивно (только индикатор в профиле при следующем входе, если UI это показывает); history_count=0илиmin_lengthменьше системного минимума ядра → ⚠️ Противоречие: текущее ТЗ не фиксирует нижнюю границу дляmin_length. Разрешение: Filament обязан валидироватьmin_lengthне ниже минимумаPassword::defaults()ядра при сохранении настроек — иначе модуль мог бы ослаблять защиту вместо её усиления;- пустая история паролей у нового пользователя → первая смена пароля не имеет с чем сравнивать — правило истории пропускается (не ошибка «нет истории»), запись создаётся с этого момента.
Донорский код
Донор: — (новая разработка).
Легаси-импорт (§16 стандарта): не применимо. Собственную историю паролей (cms_password_history) с донорских сайтов переносить не имеет смысла: хеши на доноре, как правило, в другом алгоритме/формате (bcrypt старой версии, MD5, собственная реализация), несовместимом с форматом ядра, а даже при совпадении алгоритма перенос чужих хешей не добавляет реальной защиты — правило истории смотрит на будущие смены пароля, а не на прошлые. Сами учётные записи пользователей переносятся общим импортёром ядра; этот модуль в переносе учёток не участвует.
Тесты и приёмка
- [ ] Контрактный тест: пароль из истории последних
history_countотклоняется при смене; - [ ] Проверка HIBP передаёт только префикс хеша (k-анонимность), не пароль и не полный хеш;
- [ ] Просроченный пароль (
expires_days) требует смены до продолжения работы в системе; - [ ] Принудительная смена при первом входе не даёт доступа к остальному функционалу до смены;
- [ ] Деградация при выключении не блокирует смену пароля — работают только базовые правила ядра;
- [ ] Запросы к HIBP идут через
cms/integrations-bus(SSRF-защита), не напрямую; - [ ] Ужесточение политики не требует немедленной смены пароля у существующих пользователей;
- [ ] Недоступность HIBP не блокирует смену пароля (graceful fallback + ретрай job'ом);
- [ ]
apply_to_api_tokens=false— сервисные учётки не подпадают под правила веб-пользователя; - [ ] Filament отклоняет
min_lengthниже системного минимума ядра; - [ ] Событие
UserDeletedкаскадно и синхронно удаляет всю историю паролей пользователя (cms_password_history); - [ ] Событие
UserPasswordChangedасинхронно сбрасывает флагforce_change— запись вcms_password_historyпри этом уже выполнена синхронно в транзакции смены пароля, не через это событие; - [ ] Kill-switch
password-policy.emergency_disable_enforcement=trueснимает принуждение (force_change_on_first_login,expires_days) без выключения модуля, правила валидации нового пароля продолжают действовать; - [ ] При длительной недоступности HIBP (порог подряд неуспешных запросов) и
offline_wordlist_fallback=trueмодуль отклоняет пароли из локального офлайн-словаря,PasswordBreachDetectedнесётsource=offline_wordlist; - [ ] Матрица ролей соблюдается в Policies:
password-policy.viewбезpassword-policy.manageне даёт доступа к массовому принуждению смены и настройкам политики; - [ ] Контрактный набор
cms-testingзелёный, testbench-изоляция пакета, feature-тест на каждый роут; - [ ] Тестовая БД только
password-policy_test;migrate:fresh/refresh/reset,db:wipeзапрещены.