Skip to content

ТЗ — 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_sessionsuser_id, session_id, device, device_fingerprint_hash, ip, geo (json), last_active_atРасширение над драйвером сессий Laravel
cms_login_loguser_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/activeinclude_current (bool, query, дефолт false) — false: все сессии кроме текущей; true: включая текущую («выйти отовсюду»)ownership по user_id из RequestContext; whitelist единственного query-параметра — прочие → 422
GET /api/v1/admin/session/logfilter[user_id], filter[status], filter[ip], sortFormRequest, whitelist фильтров/сортировки (§7 стандарта), неизвестный параметр → 422
Событие входа/выхода/смены пароля ядра (UserLoggedIn/UserLoggedOut/UserPasswordChanged, канал 1)user_id, IP, User-Agent запросаканонические события ядра (ревизия ядра 14.07.2026, п.4) — ядро само транслирует framework-события в свои DTO, доверенный источник
Гео-провайдер (geo-provider, provides-контракт, канал 3)ipcountry, 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событие NewDeviceLoginDetectedprovides-контракт notification-bus (канал 3)
Внешние подписчики (cms/audit и т.п., если включены)события SessionStarted/SessionTerminatedсобытие (канал 1)
Health/диагностика (cms/health, cms:session:doctor --json)доступность резолвленной реализации geo-provider (если есть хоть одна), отставание очереди sessionJSON

Настройки (группа session)

КлючТипДефолтaffectsPageCacheОписание
session.idle_timeout_minutesint120Истечение сессии по неактивности
session.max_concurrentint5Максимум одновременных активных сессий
session.max_concurrent_strategystringevict_oldestПоведение при превышении лимита: evict_oldest (вытеснить старейшую) либо reject (отклонить новый вход)
session.notify_new_devicebooltrueУведомлять о входе с нового устройства
session.geo_lookup_enabledbooltrueKill-switch: определять гео по IP через geo-provider (требует реализации контракта; аварийно выключается без выключения модуля)
session.geo_lookup_timeout_msint800Таймаут синхронного вызова geo-provider при логине
session.login_log_retention_daysint180Ретеншн cms_login_log, старше — удаляется PruneExpiredSessions
session.pin_admin_sessions_to_ipboolfalseПривязка сессии админ-роли к 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/logsession.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)inuser_id/session_id текущего запроса для скоупа «свои сессии»
Ядро — драйвер сессий Laravel (native)сервис-вызовoutпринудительное завершение обязано инвалидировать сессию на уровне драйвера немедленно
cms/notifications-busprovides-контракт 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), недавняя смена настройки группы sessioncms: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 fixationsession_id обязан перевыпускаться при смене привилегий (логин, повышение роли); переиспользование чужого/старого session_id после логина запрещено. Регенерацию делает штатный auth-стек ядра, модуль её не переопределяет, только фиксирует новый session_id в cms_user_sessions.
  • Session hijacking через утечку session_id — API и Filament никогда не отдают cms_user_sessions.session_id (внутренний ключ драйвера) в ответах; список сессий оперирует собственным id записи. Утечка списка сессий (например, через XSS в другом модуле) не даёт возможности перехватить чужую сессию напрямую.
  • Подделка device-fingerprintdevice/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.ownmanage.ownsession.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 запрещены.

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