0043. Модель сессий: reuse-detection refresh-токенов, отзыв семьи и локаут учётной записи
Копия ADR из репозитория:
backend/docs/adr/0043-session-security-model.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-08-05
- Связанные задачи: аудит безопасности 2026-08-05 (находки 3, 4, 8); развивает ADR-0003, ADR-0004
Контекст и проблема
Аудит безопасности от 2026-08-05 (docs/reports/audit-2026-08-05.md, часть II) зафиксировал
три дефекта модели сессий:
- Нет самообнаружения компрометации (находка 4, CWE-613). Refresh-токен живёт 30 дней,
POST /auth/refreshанонимен, привязки к устройству/IP нет. При предъявлении уже отозванного токена сервис отвечал 401 и больше ничего не делал: украденный токен и легальный клиент могли ротировать цепочку параллельно неограниченно долго, и система об этом не узнавала. - Нет принудительного завершения сессий (находка 4). «Выйти со всех устройств» отсутствовало
осознанно (комментарий в
auth.service.ts: «отдельная Story»). Следствие: смена роли (assignRole) не обрывала старые сессии — понижённый в правах пользователь бесконечно продлевал доступ по refresh-токену, а компрометацию учётной записи нечем было закрыть. - Неудачные входы не детектируются вовсе (находка 3, CWE-307). Счётчика попыток нет, в
audit_logне было ни одного события про login. Credential stuffing по списку email селлеров упирался только в отсутствовавший на тот момент rate-limit. Парольная политика —@MinLength(8).
Дополнительно найден дефект robustness: у мягко удалённого пользователя TypeORM дописывает
user.deleted_at IS NULL в ON-условие джойна, поэтому stored.user приходит null и refresh
падал с TypeError → 500 вместо 401.
Рассмотренные варианты
- Оставить как есть, положиться на короткий TTL. Дёшево, но 30-дневный refresh и отсутствие отзыва делают любую утечку токена бессрочной. Отклонено.
- Привязать refresh к устройству/IP (fingerprint). Ловит часть краж, но ломает мобильные сети и NAT, а против кражи вместе с окружением браузера не помогает. Отклонено как основной механизм.
- Заменить opaque refresh на JWT с revocation-list в Redis. Требует нового инфраструктурного слоя и синхронизации списка; выигрыша по сравнению с БД-таблицей, которая уже есть, нет. Отклонено.
- Reuse-detection на «семьях» токенов + явный отзыв всех сессий + локаут по учётной записи.
Стандарт де-факто (OAuth 2.0 Security BCP, §4.14.2; OWASP ASVS 3.х/2.2.х). Использует уже
существующую таблицу
refresh_tokensи журнал аудита F6. Выбрано.
Отдельно по локауту рассматривались альтернативы: только rate-limit по IP (не спасает от
распределённого перебора — один IP на аккаунт), CAPTCHA (нет UI-контура и внешней зависимости
на данном этапе), экспоненциальная задержка ответа (удерживает соединения, помогает атакующему
исчерпать пул). Выбран счётчик + временная блокировка, дополняющий IP-лимитер @ThrottleAuth.
Решение
Семьи токенов. В refresh_tokens добавлена колонка family_id (uuid, NOT NULL, индекс).
Логин открывает новую семью; каждая ротация наследует family_id предъявленного токена. Одна
семья = одна сессия = одна цепочка ротаций.
Reuse-detection. AuthService.refresh ищет токен без фильтра по активности. Если запись
найдена, но токен уже неактивен (revoked_at проставлен либо срок истёк), это трактуется как
признак компрометации: одним UPDATE отзывается вся семья, в аудит пишется
auth.refresh-reuse-detected (outcome = failure, в metadata — причина, familyId, число
отозванных). Клиенту в любом случае уходит обезличенный 401 — тот же текст, что и на неизвестный
токен, чтобы ответ не был оракулом состояния сессии.
Отзыв всех сессий. AuthService.revokeAllForUser(userId, reason, actor) отзывает все активные
токены пользователя и пишет auth.sessions-revoked. Точки вызова:
POST /auth/logout-all (свои сессии, нужен access-токен),
POST /auth/users/:userId/revoke-sessions (@Roles(Admin)) и assignRole (reason=role-change).
Локаут учётной записи. В users добавлены failed_login_attempts (int, default 0) и
locked_until (timestamptz, nullable). Неверный пароль инкрементит счётчик; по достижении
AUTH_MAX_FAILED_ATTEMPTS ставится locked_until = now + AUTH_LOCKOUT_MS. Пока блокировка активна,
пароль не проверяется вовсе. Успешный вход обнуляет счётчик и снимает блокировку. Все три исхода
(нет email / неверный пароль / блокировка) дают снаружи одинаковый 401 и одинаковую латентность:
там, где argon2-верификация не выполняется, сервис «сжигает» сопоставимое время фиктивным хешем —
иначе блокировка и существование email становятся наблюдаемыми (сохранена защита из OP-28).
События auth.login-failed, auth.login-blocked, auth.account-locked пишутся в журнал аудита.
Парольная политика. Регистрация требует не менее 12 символов (NIST SP 800-63B: длина важнее
состава символов) и отвергает пароли из короткого встроенного списка самых распространённых
(modules/auth/validators/common-passwords.ts). Верхняя граница — 128 символов, чтобы argon2 не
превращался в вектор нагрузки на CPU. LoginDto сохраняет минимум 8: политика действует на новые
пароли, ранее заведённые учётные записи должны продолжать входить.
Граница гарантии. Отзыв закрывает продление сессии. Уже выданный access-JWT остаётся валидным до конца своего TTL (≈15 мин) — он self-contained и по БД не проверяется. Проверка access по БД на каждом запросе обесценила бы stateless-модель ADR-0004; окно в 15 минут принято осознанно и здесь, и при смене роли.
Последствия
- Положительные:
- украденный refresh-токен даёт атакующему максимум один цикл ротации: первое же обращение легального клиента (или второе обращение атакующего) закрывает всю семью;
- появился рабочий отклик на инцидент — админ завершает сессии пользователя одним запросом;
- смена роли больше не оставляет «хвост» сессий со старыми правами;
- перебор пароля по одной учётной записи упирается в блокировку независимо от числа IP;
- в
audit_logвпервые появились события аутентификации — атака стала наблюдаемой.
- Отрицательные / компромиссы:
- гонка ротаций рвёт легальную сессию: два параллельных
/auth/refreshот честного клиента (дубль запроса, гонка вкладок) выглядят для сервера ровно как reuse. Отличить их по серверным данным невозможно; это принятая цена механизма. Клиенту следует сериализовать refresh; - локаут — вектор DoS против конкретного пользователя: зная email, можно намеренно
заблокировать вход на
AUTH_LOCKOUT_MS. Смягчается коротким окном блокировки (минуты, не часы) и тем, что счётчик обнуляется успешным входом; - счётчик обновляется отдельным
UPDATEбез блокировки строки: при строго параллельных попытках возможен недоучёт на 1–2 — для порога в единицы попыток это несущественно; - события неудачного входа пишутся в
audit_log, включая попытки с неизвестным email, — объём таблицы растёт под перебором. Ограничивается@ThrottleAuthна auth-роутах; при необходимости следующим шагом — ретенция/партиционирование журнала.
- гонка ротаций рвёт легальную сессию: два параллельных
- Что нужно сделать дальше:
- фронтенд: сериализовать конкурентные вызовы
/auth/refreshи обрабатывать 401 как полный выход (иначе reuse-detection будет срабатывать на гонках вкладок); - фронтенд: подключить
POST /auth/logout-allк настройкам безопасности пользователя; - опционально — проверка пароля по утечкам (HIBP k-anonymity) вместо встроенного списка;
- опционально — уведомление пользователя (F-нотификации) о событиях
auth.refresh-reuse-detectedиauth.account-locked.
- фронтенд: сериализовать конкурентные вызовы