Skip to content

ТЗ — Политика паролей (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_historyid, user_id, password_hash, created_atпоследние N хешей паролей пользователя

FK user_idconstrained()->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_confirmationFormRequest ядра расширяется правилом модуля (PasswordPolicyRule): длина, состав символов, история, HIBP-проверка (асинхронно/синхронно по настройке) с офлайн-словарём как fallback
Форма входа (ядро)email/login, passwordне изменяется модулем — только проверка expires_days/force_change_on_first_login после успешной аутентификации (редирект на смену), с учётом kill-switch emergency_disable_enforcement
Filament: настройка политикивсе ключи группы password-policywhitelist типов и диапазонов (например, 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_lengthint10нетЛимит стандарта: минимальная длина пароля
password-policy.history_countint5нетЛимит стандарта: запрет повтора последних N паролей; та же настройка задаёт глубину/срок хранения cms_password_history (см. ПДн-паспорт)
password-policy.expires_daysint0нетЛимит стандарта: срок действия пароля (0 — бессрочно)
password-policy.check_breachedbooltrueнетПроверять пароль через HIBP k-анонимность
password-policy.offline_wordlist_fallbackbooltrueнетПодключать офлайн-словарь скомпрометированных/слабых паролей при длительной недоступности HIBP (см. «Крайние случаи»)
password-policy.force_change_on_first_loginbooltrueнетПринудительная смена при первом входе для аккаунтов от админа
password-policy.apply_to_api_tokensboolfalseнетПрименять эти же правила к паролям сервисных/API-учёток (иначе — только веб-вход)
password-policy.grace_logins_after_policy_changeint3нетСколько успешных входов разрешить по старому паролю после ужесточения политики, прежде чем принудить смену
password-policy.emergency_disable_enforcementboolfalseнет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 запрещены.

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