Тема
ТЗ — Session-управление (cms/session)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: — Статус: ТЗ к разработке
Назначение и возможности
Управление активными сессиями пользователя: список устройств/входов, принудительный выход (один вход или все), уведомление о новом входе с непривычного устройства или гео.
- Список активных сессий пользователя (устройство, браузер, IP, гео, дата входа);
- принудительное завершение конкретной сессии или всех кроме текущей;
- метки устройства/User-Agent и приблизительной геолокации по IP;
- уведомление о входе с нового устройства/гео (через
cms/notifications-bus); - автоматическое истечение сессии по неактивности (настраиваемо);
- журнал входов отдельно от списка активных сессий (аудит).
Зависимости и выключение
requires: — · suggests: cms/notifications-bus · provides: — (гео-lookup — через контракт geo-provider, канал 3, при наличии реализации; свой функционал наружу — публичный сервис модуля, канал 4)
При выключении сессии продолжают работать через штатный драйвер Laravel, но без UI-управления, гео-меток и уведомлений о новом входе. cms/geoip — отдельный инфра-модуль-провайдер (provides: geo-provider, ревизия ядра 14.07.2026, п.9); без него у контракта geo-provider 0 реализаций — гео-метки не проставляются, вход не блокируется (та же деградация, что при недоступности провайдера). Стоимость и лимиты внешнего API геолокации — зона ответственности cms/geoip (провайдера контракта), не cms/session: модуль-потребитель контракта их не декларирует.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_user_sessions | user_id, session_id, device, device_fingerprint_hash, ip, geo (json), last_active_at | Расширение над драйвером сессий Laravel |
cms_login_log | user_id, ip, device, status, logged_in_at | Журнал попыток входа (успех/провал) |
geo — JSON-столбец с cast array (GIN не требуется — точечные чтения по user_id, не выборки по содержимому). FK user_id в обеих таблицах — constrained()->index(). cms_login_log — журнальная append-only таблица, кандидат на BRIN по logged_in_at при большом объёме входов. device_fingerprint_hash — денормализованный необратимый хеш (SHA-256) от связки User-Agent + клиентских характеристик (часовой пояс, язык, разрешение экрана и т.п.), без IP и точных данных — устойчивее к минорным изменениям User-Agent (обновление браузера), чем сравнение сырой строки, при определении «это то же устройство» для NewDeviceLoginDetected. Не является ПДн (см. «Безопасность»).
Входные и выходные данные
Whitelist-принцип (§11 стандарта): всё, что не перечислено ниже как вход, модуль обязан отвергать — неизвестное поле формы/API-параметр → 422, не игнор и не запись «на всякий случай».
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
Форма логина (ядро, /api/v1/auth/token) | email, password, User-Agent, IP запроса | LoginRequest ядра; модуль сам форму логина не принимает |
DELETE /api/v1/session/active/{id} | id записи cms_user_sessions (route param) | Policy: запись принадлежит текущему user_id либо есть session.manage |
DELETE /api/v1/session/active | include_current (bool, query, дефолт false) — false: все сессии кроме текущей; true: включая текущую («выйти отовсюду») | ownership по user_id из RequestContext; whitelist единственного query-параметра — прочие → 422 |
GET /api/v1/admin/session/log | filter[user_id], filter[status], filter[ip], sort | FormRequest, whitelist фильтров/сортировки (§7 стандарта), неизвестный параметр → 422 |
Событие входа/выхода/смены пароля ядра (UserLoggedIn/UserLoggedOut/UserPasswordChanged, канал 1) | user_id, IP, User-Agent запроса | канонические события ядра (ревизия ядра 14.07.2026, п.4) — ядро само транслирует framework-события в свои DTO, доверенный источник |
Гео-провайдер (geo-provider, provides-контракт, канал 3) | ip → country, city | резолв реализации через DI; таймаут + graceful fallback; ответ санитизируется перед записью в geo (не доверяем чужому JSON слепо); 0 реализаций контракта — деградация, geo не проставляется |
Настройки (settings-store, группа session) | все ключи из раздела «Настройки» | схема настройки (тип, дефолт, валидация) при декларации |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
Пользователь (GET /api/v1/session/active) | список активных сессий: устройство, IP, гео, last_active_at, признак «это вы сейчас» | конверт {data, meta} |
| Администратор (Filament, журнал входов) | cms_login_log с фильтрами | Filament-таблица; в API — keyset-пагинация |
cms/notifications-bus | событие NewDeviceLoginDetected | provides-контракт notification-bus (канал 3) |
Внешние подписчики (cms/audit и т.п., если включены) | события SessionStarted/SessionTerminated | событие (канал 1) |
Health/диагностика (cms/health, cms:session:doctor --json) | доступность резолвленной реализации geo-provider (если есть хоть одна), отставание очереди session | JSON |
Настройки (группа session)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
session.idle_timeout_minutes | int | 120 | — | Истечение сессии по неактивности |
session.max_concurrent | int | 5 | — | Максимум одновременных активных сессий |
session.max_concurrent_strategy | string | evict_oldest | — | Поведение при превышении лимита: evict_oldest (вытеснить старейшую) либо reject (отклонить новый вход) |
session.notify_new_device | bool | true | — | Уведомлять о входе с нового устройства |
session.geo_lookup_enabled | bool | true | — | Kill-switch: определять гео по IP через geo-provider (требует реализации контракта; аварийно выключается без выключения модуля) |
session.geo_lookup_timeout_ms | int | 800 | — | Таймаут синхронного вызова geo-provider при логине |
session.login_log_retention_days | int | 180 | — | Ретеншн cms_login_log, старше — удаляется PruneExpiredSessions |
session.pin_admin_sessions_to_ip | bool | false | — | Привязка сессии админ-роли к IP: смена IP посреди сессии инвалидирует её (защита от session hijacking для привилегированных ролей). Дефолт false — ложные срабатывания у мобильных админов (смена сети/IP оператора), по сути kill-switch включения фичи |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/session/active | токен | Список активных сессий текущего пользователя |
| DELETE | /api/v1/session/active/{id} | токен | Завершить конкретную сессию |
| DELETE | /api/v1/session/active | токен | Завершить все сессии кроме текущей (дефолт include_current=false) |
| DELETE | /api/v1/session/active?include_current=true | токен | «Выйти со всех устройств»: завершить все сессии, включая текущую — явное действие, отдельное от предыдущего |
| GET | /api/v1/admin/session/log | session.manage | Журнал входов всех пользователей (keyset-пагинация, аудит) |
Компоненты
Filament: страница «Мои сессии» в профиле пользователя, ресурс журнала входов для администратора. Команды: cms:session:prune-expired --json, cms:session:doctor --json.
Демо-сидер. SessionDemoSeeder для галереи/playground: несколько демо-сессий одного демо-пользователя с разными устройствами (десктоп/мобильный/планшет), браузерами и гео — список «Мои сессии» показывает реальный UI (иконки устройств, гео-метки, «это вы сейчас») без ручного логина с разных устройств.
Фронтенд-бюджет. Страница «Мои сессии» — список без карт/видео/слайдеров, тяжёлых ассетов нет; иконка типа устройства — SVG-спрайт темы, не отдельный запрос на строку списка; список и кнопки завершения сессии доступны с клавиатуры (фокус, aria-label на иконках-кнопках).
События и обмен
| Событие | Когда | Payload |
|---|---|---|
SessionStarted | Успешный вход | user_id, session_id, device, ip |
SessionTerminated | Принудительное завершение сессии | user_id, session_id, by_user_id |
NewDeviceLoginDetected | Вход с непривычного устройства | user_id, device, ip, geo |
NewDeviceLoginDetected резолвится в уведомление через контракт notification-bus (cms/notifications-bus), если модуль включён. Собственных provides-контрактов и фильтров FilterBus нет. Слушает канонические события аутентификации ядра (UserLoggedIn/UserLoggedOut/UserPasswordChanged, канал 1) для построения журнала и инвалидации сессий.
Взаимодействия
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро — аутентификация (UserLoggedIn/UserLoggedOut/UserPasswordChanged) | событие (канал 1, EventBus, ревизия ядра 14.07.2026, п.4) | in | триггер записи в cms_user_sessions/cms_login_log, издание SessionStarted; UserPasswordChanged — инвалидация всех сессий кроме текущей |
Ядро — RequestContext | сервис-вызов (контракт ядра cms/core-contracts) | in | user_id/session_id текущего запроса для скоупа «свои сессии» |
| Ядро — драйвер сессий Laravel (native) | сервис-вызов | out | принудительное завершение обязано инвалидировать сессию на уровне драйвера немедленно |
cms/notifications-bus | provides-контракт notification-bus (канал 3) | out | резолв NewDeviceLoginDetected в уведомление по каналам получателя; при выключенном модуле — молчаливая деградация |
Гео-провайдер (cms/geoip и аналоги) | provides-контракт geo-provider (канал 3, ревизия ядра 14.07.2026, п.9) | out | резолв активной реализации через DI, синхронный запрос IP→гео при логине, таймаут + fallback; 0 реализаций — деградация |
Внешние подписчики (cms/audit, cms/attack-monitor, если включены) | событие (канал 1) | out | слушают SessionStarted/SessionTerminated, cms/session о них не знает |
Фоновая работа
Очередь session: джоба PruneExpiredSessions (идемпотентна) по расписанию через ScheduleRegistrar ядра, частота — из idle_timeout_minutes. Гео-лукап по IP — синхронный резолв контракта geo-provider (канал 3) с коротким таймаутом (session.geo_lookup_timeout_ms) и graceful fallback (0 реализаций/таймаут — гео-метка не проставляется, вход не блокируется).
Мини-ранбук (§15 стандарта):
| Симптом | Что проверить | Чем чинить |
|---|---|---|
| Массовые жалобы на разлогин | Регрессия в PruneExpiredSessions (слишком агрессивный ретеншн/idle_timeout_minutes), недавняя смена настройки группы session | cms:session:doctor --json — покажет отставание очереди и последний прогон джобы; при регрессии в настройке — откат значения через settings-store |
| Подозрение на компрометацию аккаунта | Свежие записи cms_login_log по user_id (новый IP/гео/устройство), активные записи cms_user_sessions | Массовое завершение сессий пользователя из Filament (см. «UX-требования») — включая текущую («выйти отовсюду»); сверить cms_login_log, что вход действительно был чужой |
| Гео-метки перестали проставляться | cms:session:doctor --json — статус резолва geo-provider; включён ли cms/geoip (или другая реализация) | Если 0 реализаций контракта — ожидаемая деградация, не баг; при таймаутах — поднять session.geo_lookup_timeout_ms или временно session.geo_lookup_enabled = false (kill-switch) |
Бэкап/рестор. В бэкап попадают обе таблицы модуля — cms_user_sessions (активные сессии) и cms_login_log (журнал входов). Пересоздания командой после рестора не требуется — модуль не держит агрегатов/индексов над этими данными; после рестора устаревшие cms_user_sessions донезачищаются штатным прогоном PruneExpiredSessions.
Производительность и кеш
Ожидаемые объёмы. Активных сессий на пользователя — единицы, порядок session.max_concurrent (дефолт 5); у активных админов/менеджеров — до пары десятков одновременно (несколько браузеров/устройств). cms_login_log для студийного проекта (сотни–тысячи пользователей, единицы-десятки входов в день на активного) — от единиц до нескольких десятков миллионов строк за пару лет без ретеншна; с session.login_log_retention_days (дефолт 180) таблица держится в разумных границах.
Горячие пути и бюджет запросов:
- список активных сессий (
GET /api/v1/session/active) — 1 запрос поuser_id, без N+1 на гео (гео уже денормализовано вcms_user_sessions.geo, не джойн/вызов провайдера на чтении); - проверка лимита при логине (
max_concurrent) — 1 запросCOUNT/выборка поuser_idв той же транзакции, что запись новой сессии, с блокировкой строк (lockForUpdate) — иначе гонка при параллельном логине (см. «Крайние случаи»); - журнал входов админа — keyset-пагинация по индексу.
Критичные индексы (сверка с «Модель данных»): cms_user_sessions(user_id) — под список сессий и проверку лимита на горячем пути; cms_login_log(user_id, logged_in_at) — под журнал по конкретному пользователю; cms_login_log(logged_in_at) BRIN — под ретеншн и выборки за период при объёме из абзаца выше.
Кеш. Список активных сессий и cms_login_log — не кешируются: данные короткоживущие (last_active_at меняется каждым запросом), кеш дал бы устаревшее «это вы сейчас» и рассинхрон с реальным списком устройств. Настройки группы session кешируются штатно settings-store ядра (0 запросов на горячем пути — контракт §6 стандарта). Собственных тегов CacheTags модуль не объявляет, инвалидировать нечего; на page-cache не влияет (данные персональные, в общий page-cache не попадают — §10 стандарта).
Безопасность
API /api/v1/session/* — только собственные сессии пользователя, кроме session.manage; Filament — журнал входов только на чтение. Завершение сессии обязано инвалидировать её на уровне драйвера Laravel немедленно, не дожидаясь истечения TTL. Гео-провайдер — резолв контракта geo-provider с таймаутом, не блокирует поток аутентификации при недоступности/отсутствии реализации.
Конкретные векторы:
- Session fixation —
session_idобязан перевыпускаться при смене привилегий (логин, повышение роли); переиспользование чужого/старогоsession_idпосле логина запрещено. Регенерацию делает штатный auth-стек ядра, модуль её не переопределяет, только фиксирует новыйsession_idвcms_user_sessions. - Session hijacking через утечку
session_id— API и Filament никогда не отдаютcms_user_sessions.session_id(внутренний ключ драйвера) в ответах; список сессий оперирует собственнымidзаписи. Утечка списка сессий (например, через XSS в другом модуле) не даёт возможности перехватить чужую сессию напрямую. - Подделка device-fingerprint —
device/User-Agentприсылает клиент, это не источник авторизационных решений (не подменяет проверку токена/сессии), а только метка для отображения и эвристики «новое устройство». Худший случай подделки — ложный/пропущенныйNewDeviceLoginDetected, не обход аутентификации.device_fingerprint_hashсчитается на сервере из тех же клиентских данных — тот же довод: усиливает эвристику, не решение об авторизации. - Session hijacking через смену IP у привилегированных ролей —
session.pin_admin_sessions_to_ip(дефолтfalse, kill-switch включения фичи): при включении сессия админ-роли инвалидируется при смене IP посреди сессии, что затрудняет использование угнанногоsession_idс другого IP. Дефолтfalse— включать точечно, дефолтtrueдал бы ложные разлогины мобильных админов при смене сети оператора (см. «Крайние случаи»). - ПДн.
ip,geo,device,user_idвcms_user_sessions/cms_login_log— персональные данные; хранение ограниченоsession.login_log_retention_days, участие в «выгрузить/забыть по запросу субъекта» (152-ФЗ) — через штатный экспорт/ анонимизацию ядра, точечных обработчиков модуль не заводит.device_fingerprint_hashПДн не является: необратимый (SHA-256) хеш от User-Agent и клиентских характеристик, не идентифицирует физлицо напрямую и не восстанавливается до исходных данных — участия в «забыть по запросу» не требует.
Права: session.view.own, session.manage.own, session.manage (чужие сессии).
| Роль | view.own | manage.own | session.manage (чужие) |
|---|---|---|---|
| Посетитель без аккаунта | — | — | — |
| Пользователь | ✅ | ✅ | — |
| Менеджер | ✅ | ✅ | — |
| Админ | ✅ | ✅ | ✅ |
| Studio | ✅ | ✅ | ✅ |
UX-требования
Для пользователя (страница «Мои сессии»):
- список устройств — иконка типа (десктоп/мобильный/планшет по разбору User-Agent), браузер/ОС, город (если гео включено и доступно), дата последней активности; текущая сессия помечена «это вы сейчас» и не предлагается к завершению как «чужая»;
- завершение сессии/устройства — подтверждение перед необратимым действием («Завершить сеанс: iPhone, Safari, Москва? Пользователь будет разлогинен»);
- отдельная кнопка «Выйти со всех устройств» (включая текущую,
include_current=true) — подтверждение с явным предупреждением «вы тоже будете разлогинены»; отличается от кнопки «Завершить все остальные» рядом со списком (та не трогает текущую сессию); - ошибка на человеческом языке: превышен лимит сессий → «Слишком много активных входов. Завершите один из них или войдите заново», не HTTP-код/стектрейс;
- уведомление о новом устройстве (если
notifications-busвключён) — короткое, с явным «это не я» → ссылка на список сессий для немедленного завершения.
Для администратора (журнал входов):
- фильтры: пользователь, IP, статус (успех/провал), диапазон дат;
- пустое состояние — «Входов пока нет» вместо пустой таблицы без пояснения;
- массовое завершение сессий по пользователю (при подозрении на компрометацию аккаунта) — одно действие с подтверждением; каждая завершённая сессия издаёт свой
SessionTerminatedсby_user_idадмина, а не одно неинформативное групповое событие.
Крайние случаи и типовые баги
- Смена пароля → событие ядра
UserPasswordChangedинвалидирует все сессии пользователя, кроме текущей (той, из которой пароль менялся) — иначе пользователь сам себя разлогинивает при штатной смене пароля. Полная инвалидация (включая текущую, «выйти отовсюду») — отдельное явное действие черезDELETE /api/v1/session/active?include_current=trueлибо принудительный сброс админом при компрометации. - «Выкинуть устройство» при активном запросе с этого устройства → уже выполняющийся на сервере запрос не прерывается (нет механизма remote-kill in-flight запроса), но сессия инвалидируется на уровне драйвера немедленно — следующий запрос с этого устройства получает 401.
- Превышение
session.max_concurrentпри новом логине → поведение поsession.max_concurrent_strategy:evict_oldest(дефолт) — вытесняется старейшая поlast_active_atсессия, новый логин проходит;reject— новый логин отклоняется с человеко-читаемой ошибкой (см. «UX-требования»). Kill-switch без выключения модуля. - Гонка двух одновременных логинов за последний слот лимита → проверка лимита и запись новой сессии — в одной транзакции с блокировкой строк по
user_id(lockForUpdate); без неё оба логина могут пройти и лимит временно превышается. notifications-busвыключен →NewDeviceLoginDetectedиздаётся, но ни в один канал не резолвится (провайдера уведомлений нет) — вход не блокируется, факт остаётся только вcms_login_log; это ожидаемая деградация, не ошибка.- Гео-провайдер недоступен/таймаут либо 0 реализаций контракта
geo-provider→ вход не блокируется,geoостаётсяnull,NewDeviceLoginDetectedиздаётся без гео-метки; механизм — резолв DI-контракта возвращает пусто/бросает таймаут, не отличим от отсутствияcms/geoipв парке модулей — деградация в обоих случаях одна и та же. - Смена IP у мобильного админа при включённом
session.pin_admin_sessions_to_ip→ ложное срабатывание: смена сети оператора/переключение Wi-Fi↔LTE меняет IP без смены устройства, сессия админа инвалидируется хотя компрометации нет. Поэтому дефолт настройкиfalse— включать точечно для ролей с повышенным риском, зная это ограничение (см. «Безопасность»). PruneExpiredSessionsпадает посреди прогона → идемпотентна: повторный запуск по расписанию удаляет только оставшиеся истёкшие записи поlast_active_at/ретеншну, не дублирует эффект и не трогает валидные сессии.- Пустой список сессий у только что созданного пользователя → штатное состояние до первого входа, не ошибка; UI отдаёт пустое состояние, API — пустой
data: []. - Повторное завершение уже завершённой сессии (двойной клик/повтор запроса) → идемпотентно, вторая попытка получает 404 («сессия уже не активна»), не 500.
- Рассинхрон часов сервера/клиента влияет на
idle_timeout→ таймаут считается исключительно по серверномуlast_active_at, клиентские часы не участвуют — сбитые часы браузера не могут продлить или преждевременно оборвать сессию. - Разрешено ревизией ядра 14.07.2026, п.4: журнал входов и инвалидация сессий строятся на канонических событиях ядра
UserLoggedIn/UserLoggedOut/UserPasswordChanged(канал 1, EventBus) — ядро само транслирует framework-событияIlluminate\Auth\Events\*в эти DTO,cms/sessionпрямых слушателей framework-событий не заводит. Другие модули (audit, attack-monitor, two-factor) могут подписаться на «вход» через штатный канал 1 тем же способом — открытый вопрос снят. - Разрешено ревизией ядра 14.07.2026, п.9: гео-лукап по IP идёт через provides-контракт
geo-provider(канал 3), пополнивший канонический реестр (стандарт §2);cms/session— задекларированный потребитель. Реализацию поставляет отдельный инфра-модуль (cms/geoipи аналоги,suggests— см. «Зависимости и выключение»), прямого HTTP из сервиса модуля больше нет.
Донорский код
Донор: — (новая разработка). Легаси-импорт (§16 стандарта) не применяется: сессии не переносятся с донорских/старых платформ по своей природе — это живое состояние (session_id, last_active_at), а не архивные данные; после переезда пользователи логинятся заново и заводят новые записи cms_user_sessions. Историю входов (cms_login_log) старой платформы переносить незачем — она теряет смысл вне контекста старого auth-стека. Команда cms:session:import-legacy не нужна.
Тесты и приёмка
- [ ] Контрактные тесты: завершение сессии инвалидирует её мгновенно на следующем запросе;
- [ ]
UserLoggedIn/UserLoggedOutпорождают запись вcms_user_sessions/cms_login_logи изданиеSessionStarted; прямых слушателейIlluminate\Auth\Events\*в коде модуля нет (regression-тест на канон событий, ревизия ядра 14.07.2026 п.4);UserPasswordChangedинвалидирует все сессии кроме текущей; - [ ] резолв гео через
geo-provider(канал 3): при 1 реализацииgeoзаполняется, при 0 реализаций — деградация (geo = null, вход не блокируется, health-чек это видит); - [ ] деградация при выключении — вход/выход работают штатно, без списка устройств;
- [ ] права разделяют управление своими и чужими сессиями (матрица ролей выше — тест на каждую строку);
- [ ] нет N+1 при выводе списка сессий с гео-метками;
- [ ] журнал входов ротируется по
login_log_retention_days, не растёт бесконечно; - [ ] смена пароля инвалидирует все сессии кроме текущей (отдельный тест на «выйти отовсюду», включая текущую —
DELETE /api/v1/session/active?include_current=trueзавершает и текущую сессию, обычный вызов без параметра — нет); - [ ]
session_idперевыпускается при логине (regression-тест на session fixation); API/Filament не отдаютsession_idв ответах (тест на состав полей ответа); - [ ]
device_fingerprint_hash— необратимый хеш, не совпадает с сырымUser-Agent/IP в ответах API/Filament и не восстанавливается до исходных клиентских данных (тест на отсутствие ПДн в поле); - [ ]
session.pin_admin_sessions_to_ip(когдаtrue): смена IP посреди сессии админ-роли инвалидирует её; приfalse(дефолт) смена IP не влияет; обычная пользовательская роль не затрагивается настройкой ни при каком значении; - [ ] гонка двух логинов за последний слот
max_concurrentне превышает лимит (параллельный тест сlockForUpdate); - [ ] обе стратегии
max_concurrent_strategy(evict_oldest/reject) покрыты тестами на поведение при превышении лимита; - [ ]
PruneExpiredSessionsидемпотентна — повторный прогон после падения не трогает валидные сессии и не дублирует удаление; - [ ] whitelist входов: неизвестный параметр в
filter[]/форме/query (кромеinclude_current) → 422, не игнор; - [ ] демо-сидер
SessionDemoSeederнаполняет playground сессиями без ручного логина; - [ ] контрактный набор
cms-testingзелёный, пакет протестирован в testbench-изоляции; - [ ] feature-тест на каждый роут API; тестовая БД только
session_test,migrate:fresh/refresh/resetзапрещены.