Перейти к основному содержимому

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) зафиксировал три дефекта модели сессий:

  1. Нет самообнаружения компрометации (находка 4, CWE-613). Refresh-токен живёт 30 дней, POST /auth/refresh анонимен, привязки к устройству/IP нет. При предъявлении уже отозванного токена сервис отвечал 401 и больше ничего не делал: украденный токен и легальный клиент могли ротировать цепочку параллельно неограниченно долго, и система об этом не узнавала.
  2. Нет принудительного завершения сессий (находка 4). «Выйти со всех устройств» отсутствовало осознанно (комментарий в auth.service.ts: «отдельная Story»). Следствие: смена роли (assignRole) не обрывала старые сессии — понижённый в правах пользователь бесконечно продлевал доступ по refresh-токену, а компрометацию учётной записи нечем было закрыть.
  3. Неудачные входы не детектируются вовсе (находка 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.

Рассмотренные варианты​

  1. Оставить как есть, положиться на короткий TTL. Дёшево, но 30-дневный refresh и отсутствие отзыва делают любую утечку токена бессрочной. Отклонено.
  2. Привязать refresh к устройству/IP (fingerprint). Ловит часть краж, но ломает мобильные сети и NAT, а против кражи вместе с окружением браузера не помогает. Отклонено как основной механизм.
  3. Заменить opaque refresh на JWT с revocation-list в Redis. Требует нового инфраструктурного слоя и синхронизации списка; выигрыша по сравнению с БД-таблицей, которая уже есть, нет. Отклонено.
  4. 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.