Перейти к содержанию

12. Вход пользователей на сайт — JWT и сессии

12.1. Назначение и границы

Покупатель входит на сайт по номеру телефона и одноразовому коду. Код доставляется через SMS, Telegram или MAX. После подтверждения backend выдаёт короткоживущий JWT access token и создаёт отзываемую refresh-сессию.

Этот документ описывает:

  • вход и регистрацию по одноразовому коду;
  • аудируемый вход администратора под пользователем;
  • вход через Telegram WebApp;
  • формат и проверку JWT;
  • обновление, отзыв и хранение сессий;
  • миграцию со случайных legacy-токенов;
  • требования к браузерному клиенту и защищённым API.

Авторизация сотрудников админки, сервисные токены и ключи iiko — отдельные контуры. JWT покупателя не даёт доступ к /api/admin/* и внутренним интеграционным методам.

12.2. Что найдено в legacy user

Go-сервис ../user уже реализует часть целевого входа:

  • POST /api/user/auth/send-code отправляет четырёхзначный код через sms, tg или max;
  • POST /api/user/auth/login входит по телефону и коду;
  • POST /api/user/auth/register создаёт пользователя;
  • GET /api/user/auth/telegram-verification проверяет Telegram WebApp InitData;
  • код живёт 10 минут и удаляется после успешного использования;
  • телефон нормализуется в +7XXXXXXXXXX, проверяются блокировки, captcha и ограничения попыток;
  • login, register и Telegram login возвращают пару JWT;
  • профиль сначала ищет пользователя по sub JWT, а при ошибке — по случайному legacy-токену из users.token.

Текущую реализацию JWT нельзя переносить без изменений:

Проблема Последствие
access token имеет exp в 2100 году украденный токен практически бессрочен
refresh token живёт год, но endpoint refresh отсутствует пара формально выдаётся, но жизненный цикл не реализован
access и refresh отличаются только сроком access можно передать туда, где ожидается refresh, если проверять только подпись
нет iss, aud, jti, typ и версии сессии нельзя надёжно различить назначение, отозвать и расследовать токен
middleware принимает ?token= токен попадает в URL, историю браузера, access-логи и Referer
любой невалидный JWT проверяется как legacy token неявный второй механизм аутентификации усложняет отключение legacy
users.token и users.refresh_token индексируются и хранятся открыто утечка БД даёт готовые bearer credentials
refresh token не ротируется и не имеет серверной сессии нельзя безопасно завершить отдельное устройство или обнаружить повторное применение
код подтверждения хранится открытым текстом чтение БД позволяет войти от имени клиента до истечения кода
Telegram login использует GET с InitData в query персональные данные и подпись могут попасть в access-логи

В локальном ../user/config.yml обнаружены заполненные секреты интеграций. Они не являются частью проектной документации: перед дальнейшим использованием окружения значения нужно ротировать, удалить из отслеживаемых файлов и хранить в secret storage/переменных окружения.

12.3. Целевая схема

Продуктовые требования имеют следующий приоритет:

  1. Сохранение текущих сессий. Миграция пользователей не должна массово разлогинить клиентов. Поддерживать старый frontend и старые API при этом не требуется: новый frontend бесшовно обменивает сохранённый legacy-токен на новую сессию.
  2. Несколько устройств. Один клиент может одновременно работать с телефона, планшета, компьютера и других устройств. Каждое устройство имеет независимую сессию. При этом клиенту доступно явное действие «Выйти на всех устройствах», которое немедленно завершает все новые и legacy-сессии.
  3. Максимально долгий вход. Сессия продлевается при использовании и в нормальном сценарии живёт до явного выхода, блокировки или длительного отсутствия клиента. Удобство бизнеса важнее агрессивного завершения сессий по рисковым сигналам.

Короткий access JWT не противоречит долгой сессии: frontend незаметно обновляет его через refresh cookie. Срок пользовательского входа определяется refresh-сессией, а не exp access.

Публичные адреса API

Все контракты нового сайта и мобильного приложения версионируются единым префиксом /api/v1. Версия относится ко всему публичному API, а не к отдельному микросервису.

Контур Префикс Примеры
Авторизация /api/v1/auth /code, /login, /refresh, /logout, /logout-all
Текущий пользователь /api/v1/users/me профиль, адреса, настройки
Заказы /api/v1/orders история и состояние заказа
Каталог /api/v1 /menu, /products, /categories
Админка /api/admin/v1 отдельная авторизация и RBAC

/api/auth не используется, потому что в нём нет версии. /api/user/auth не используется, потому что user — внутреннее имя legacy-микросервиса, которое не должно становиться частью публичного контракта. Сайт и мобильное приложение используют одинаковые endpoint.

sequenceDiagram
    participant B as Браузер
    participant A as Backend / auth
    participant C as SMS / TG / MAX
    participant DB as PostgreSQL

    B->>A: POST /api/v1/auth/code {phone, channel, captcha}
    A->>DB: сохранить hash кода, TTL, attempts=0
    A->>C: доставить одноразовый код
    A-->>B: 202 {expiresIn, resendAfter}

    B->>A: POST /api/v1/auth/login {phone, code}
    A->>DB: заблокировать challenge, проверить hash и attempts
    A->>DB: найти или создать клиента, погасить challenge
    A->>DB: создать refresh_session
    A-->>B: refresh cookie (HttpOnly и Secure)
    A-->>B: access token и профиль пользователя

    B->>A: GET /api/v1/users/me, Authorization: Bearer access
    A-->>B: профиль

    B->>A: POST /api/v1/auth/refresh + cookie
    A->>DB: проверить и ротировать refresh_session
    A-->>B: новый cookie + новый accessToken

Вход и регистрация для клиента — один сценарий: после правильного кода существующий пользователь входит, отсутствующий создаётся. Отдельный экран «Регистрация» может запросить имя после первого входа, но не требует повторной отправки кода.

12.4. Отправка кода

POST /api/v1/auth/code

{
  "phone": "+79001234567",
  "channel": "sms",
  "captchaToken": "…"
}

channel: sms | telegram | max. Порядок каналов в UI не является частью backend-контракта. Backend отправляет код строго через канал, указанный в запросе, и не выбирает канал самостоятельно. Если выбранный мессенджер не привязан или провайдер недоступен, API возвращает код ошибки CHANNEL_UNAVAILABLE; автоматического переключения на другой канал нет. Для повторной отправки через другой канал клиент должен выполнить новый явный запрос с соответствующим channel.

Успешный ответ одинаков для существующего и нового телефона:

{
  "expiresIn": 600,
  "resendAfter": 120
}

Требования:

  • нормализация телефона до E.164 до поиска и создания challenge;
  • криптографически случайный шестизначный код;
  • в БД хранится HMAC/хеш кода с серверным pepper, не открытый код;
  • один активный challenge на телефон и назначение;
  • TTL — 10 минут;
  • не более 5 попыток ввода, затем challenge сгорает;
  • ограничения отправок одновременно по телефону, IP, устройству и подсети;
  • captcha обязательна для браузерного SMS-запроса и включается по риск-правилам для остальных;
  • ответ не раскрывает, зарегистрирован ли телефон;
  • код, телефон и токены не пишутся в логи.
create table customer.auth_challenge (
    id            uuid primary key,
    phone         text        not null,
    purpose       text        not null default 'LOGIN',
    channel       text        not null,
    code_hash     bytea       not null,
    attempts      int         not null default 0,
    max_attempts  int         not null default 5,
    expires_at    timestamptz not null,
    consumed_at   timestamptz,
    requested_ip  inet,
    created_at    timestamptz not null default now()
);

create unique index auth_challenge_active_phone_uq
    on customer.auth_challenge (phone, purpose)
    where consumed_at is null;

Проверка и погашение challenge выполняются в одной транзакции с блокировкой строки. Удалять код «после успешного входа» отдельным необязательным запросом недостаточно: два параллельных запроса не должны использовать его дважды.

12.5. Вход по коду

POST /api/v1/auth/login

{
  "phone": "+79001234567",
  "code": "123456",
  "device": {
    "id": "5ef774bb-3177-40cc-85b7-7287fb94f73a",
    "type": "WEB",
    "appVersion": "web-2026.08.17"
  }
}

Backend находит единственный активный challenge по нормализованному телефону и purpose=LOGIN. Внутренний UUID challenge не раскрывается в публичном API и используется только для хранения, аудита и трассировки.

device.id — постоянный случайный UUID установки клиента. Это не hardware ID, не fingerprint и не секрет. Web-клиент генерирует его один раз и хранит отдельно от токенов; мобильное приложение создаёт UUID при первом запуске и хранит в локальных настройках приложения. Очистка данных или переустановка создаёт новое устройство. type: WEB | IOS | ANDROID. Отображаемое имя формирует backend из типа, User-Agent и технических сведений приложения, например «Chrome на macOS» или «Панам на iPhone». Пользователь не может переименовать устройство вручную; отдельного endpoint для этого нет.

Ответ:

HTTP/1.1 200 OK
Set-Cookie: refresh_token=<token>; Path=/api/v1/auth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000
Cache-Control: no-store
Pragma: no-cache
{
  "accessToken": "eyJ…",
  "tokenType": "Bearer",
  "expiresIn": 900,
  "user": {
    "id": "6b0c7f0f-…",
    "phone": "+79001234567",
    "name": "Иван",
    "profileComplete": true
  }
}

Новый пользователь создаётся с тем же UUID, который затем используется заказами, компенсациями, адресами и контактами уведомлений. Факт нового аккаунта можно вернуть только после успешного подтверждения телефона.

Если телефон заблокирован, challenge неверен или истёк, refresh-сессия не создаётся. Наружу возвращается RFC 7807 с устойчивым машинным кодом; подробная причина остаётся в защищённом аудите.

Вход администратора под пользователем

Клиентский одноразовый код не хранится открыто и не должен читаться из БД. Для поддержки создаётся отдельный механизм impersonation, который невозможно спутать с подтверждением телефона клиента.

Администратор с отдельным разрешением CUSTOMER_IMPERSONATE запрашивает код:

POST /api/admin/v1/customers/{id}/impersonation-codes
{
  "reason": "проверка ошибки при оформлении заказа"
}

Backend генерирует криптографически случайный одноразовый код, возвращает его только в ответе на этот запрос и сохраняет в БД только его HMAC/хеш. Код связан с конкретными клиентом и администратором, действует 10 минут и сгорает после первого успешного обмена.

create table customer.impersonation_grant (
    id                  uuid primary key,
    customer_id         uuid        not null references customer.customer(id),
    actor_admin_id      bigint      not null,
    code_hash           bytea       not null unique,
    reason              text        not null,
    expires_at          timestamptz not null,
    consumed_at         timestamptz,
    created_at          timestamptz not null default now(),
    created_request_id  uuid,
    consumed_request_id uuid
);

На специальной странице frontend администратор вводит этот код:

POST /api/v1/auth/impersonation-login
{
  "code": "K7M4-P9TX-W2QF",
  "device": {
    "id": "5ef774bb-3177-40cc-85b7-7287fb94f73a",
    "type": "WEB",
    "appVersion": "web-2026.08.17"
  }
}

Созданная сессия помечается auth_method=IMPERSONATION и impersonated_by=<admin_id>, отображается в списке сессий как «Служба поддержки» и живёт не более 60 минут без скользящего продления. JWT содержит claim act с идентификатором администратора. Интерфейс постоянно показывает заметный признак входа под клиентом. Выход завершает только эту сессию.

Создание кода, его обмен, завершение сессии и все изменяющие запросы этой сессии записываются в аудит с customer_id, actor_admin_id, причиной и request_id. Заблокированный пользователь не может быть целью impersonation. Код и его хеш не попадают в логи или аудит.

12.6. Access JWT

Access token подписывается HS256 и действует 15 минут. JWT выпускает и проверяет только монолит через общую инфраструктуру безопасности; отдельные модули не получают секрет и не реализуют собственный парсер. Публичный JWKS endpoint не нужен. Если модуль позднее выделяется в отдельный процесс, выбор алгоритма и распространение ключей пересматриваются.

{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "customer-2026-01"
}
{
  "iss": "https://api.panampizza.ru",
  "aud": "panam-storefront",
  "sub": "6b0c7f0f-…",
  "iat": 1786957200,
  "nbf": 1786957200,
  "exp": 1786958100,
  "jti": "01K2Q7…",
  "typ": "access",
  "sid": "01K2Q6…",
  "ver": 4,
  "act": 42
}

act присутствует только в impersonation-сессии и содержит ID действующего администратора. Обычный пользовательский JWT этого claim не имеет.

Правила проверки:

  1. разрешён только явно настроенный алгоритм HS256, значение из заголовка не выбирает алгоритм;
  2. ключ определяется по известному kid, неизвестный ключ отклоняется;
  3. обязательны и проверяются iss, aud, sub, iat, nbf, exp, jti, typ=access, sid, ver;
  4. sub должен быть UUID существующего незаблокированного клиента;
  5. допустимый clock skew — не более 30 секунд;
  6. access JWT принимается только из Authorization: Bearer <token>;
  7. query-параметр ?token= и JWT в cookie не поддерживаются;
  8. токен никогда не логируется целиком.

В JWT не кладутся телефон, имя, бонусный баланс и разрешения, которые могут измениться. ver — текущая session_version клиента. Backend сравнивает её со значением из БД/короткого кеша; после «Выйти на всех устройствах» старая версия немедленно отклоняется. Блокировка пользователя проверяется тем же способом.

12.7. Refresh-сессия

Refresh token — JWT с typ=refresh, отдельной aud=panam-auth и сроком 365 дней. Срок скользящий: при каждом успешном refresh backend продлевает сессию ещё на 365 дней и обновляет cookie. Поэтому регулярно используемое устройство остаётся авторизованным неограниченно долго. После 365 дней полного отсутствия активности потребуется вход по коду.

Refresh никогда не принимается защищёнными бизнес-endpoint. Одной подписи недостаточно: токен обязательно сверяется с серверной сессией.

create table customer.refresh_session (
    id                  uuid primary key,             -- sid
    user_id             uuid        not null references customer.customer(id) on delete cascade,
    device_id           uuid        not null,          -- стабильный id установки сайта/приложения
    device_type         text        not null,          -- WEB | IOS | ANDROID
    device_name         text        not null,          -- отображается клиенту
    app_version         text,
    auth_method        text        not null,          -- OTP | TELEGRAM | LEGACY | IMPERSONATION
    impersonated_by    bigint,                        -- только для IMPERSONATION
    family_id           uuid        not null,
    current_jti         uuid        not null unique,
    token_hash          bytea       not null unique,   -- SHA-256 полного refresh JWT
    user_agent_hash     bytea,
    created_ip          inet,
    last_used_ip        inet,
    created_at          timestamptz not null default now(),
    last_used_at        timestamptz not null default now(),
    expires_at          timestamptz not null,
    revoked_at          timestamptz,
    revoke_reason       text,
    replaced_by_jti     uuid
);

create index refresh_session_user_active_idx
    on customer.refresh_session (user_id, expires_at)
    where revoked_at is null;

create unique index refresh_session_user_device_active_uq
    on customer.refresh_session (user_id, device_id)
    where revoked_at is null;

create index refresh_session_inactive_cleanup_idx
    on customer.refresh_session (last_used_at);

У пользователя может быть любое количество активных строк refresh_session; уникального ограничения по user_id нет. Новый вход не отзывает существующие устройства. Защита от явной автоматической атаки может ограничить скорость создания сессий, но не вводит продуктовый лимит на число устройств. Повторный вход с тем же (user_id, device_id) заменяет сессию только этого устройства, чтобы список не заполнялся дублями.

device_id нужен для отображения и адресного отзыва, но не доказывает подлинность устройства: клиент может передать любой UUID. Авторизация по-прежнему определяется refresh token и серверной сессией. IP, User-Agent и название устройства не входят в JWT и не используются как жёсткая привязка, чтобы смена сети или обновление браузера не разлогинивали клиента.

Refresh JWT хранится только в cookie:

  • HttpOnly — JavaScript не читает токен;
  • Secure — только HTTPS;
  • SameSite=Lax;
  • узкий Path=/api/v1/auth;
  • без Domain, чтобы cookie была host-only;
  • браузерный frontend хранит access token только в памяти, не в localStorage.

POST /api/v1/auth/refresh

Тело пустое, refresh берётся из cookie. Endpoint:

  1. проверяет подпись и claims с typ=refresh;
  2. считает SHA-256 токена и блокирует соответствующую сессию;
  3. проверяет пользователя, срок и отсутствие отзыва;
  4. ротирует refresh: старый jti/hash становится недействительным, выдаётся новый;
  5. продлевает expires_at текущего устройства ещё на 365 дней;
  6. выдаёт новый access token и заменяет cookie;
  7. возвращает Cache-Control: no-store.

Повторное предъявление уже заменённого refresh token означает возможную кражу. Отзывается вся family_id только этого устройства, остальные устройства продолжают работать. Чтобы сетевой повтор или две вкладки не разлогинивали клиента, предусмотрено короткое окно идемпотентного повтора: тот же старый token с тем же контекстом устройства в течение 60 секунд получает уже выпущенную пару. За пределами окна событие пишется в аудит и завершается только подозрительная сессия.

Frontend всё равно сериализует refresh, чтобы не создавать лишних запросов. Старый и новый refresh не остаются независимо действующими: grace window возвращает тот же результат ротации.

12.8. Выход и управление устройствами

Учёт последней активности

Нужно отвечать на два разных вопроса:

  • customer.customer.last_seen_at — когда авторизованный клиент последний раз пользовался сайтом или мобильным приложением на любом устройстве;
  • customer.refresh_session.last_used_at — когда использовалось конкретное устройство.

Дополнительно у клиента хранится last_seen_channel = WEB | IOS | ANDROID, чтобы бизнес мог отличить последнее посещение сайта от приложения:

alter table customer.customer
    add column last_seen_at timestamptz,
    add column last_seen_channel text;

Активностью считается успешный авторизованный запрос, связанный с действием клиента, включая вход, refresh, просмотр профиля, каталога, корзины и заказа. Health-check, фоновые server-to-server задачи, push-доставка и webhook не обновляют присутствие клиента.

Писать в PostgreSQL на каждый запрос не нужно. Middleware отмечает активность в Redis/буфере, а воркер периодически сохраняет максимум времени по (user_id, session_id). Для активно используемой сессии достаточно записи не чаще одного раза в 15 минут. При login, refresh и logout время сохраняется синхронно. Допустимая задержка отображения lastSeenAt — до 15 минут.

GET /api/v1/auth/sessions возвращает lastUsedAt, а профиль/админка клиента — lastSeenAt и lastSeenChannel. IP не показывается клиенту как точное местоположение; при необходимости можно вывести только приблизительный регион, полученный и сохранённый с соблюдением требований к персональным данным.

Очистка неактивных сессий

Ежедневная задача удаляет refresh-сессии, у которых не было активности 365 дней:

delete from customer.refresh_session
where last_used_at < now() - interval '365 days';

Перед удалением накопленная активность из буфера должна быть сброшена в PostgreSQL, чтобы живая сессия не была удалена из-за задержки воркера. Задача обрабатывает записи небольшими пачками и не держит долгую блокировку таблицы. После удаления refresh token такого устройства получает 401, cookie очищается, клиент входит заново по коду.

Удаление сессии не удаляет пользователя, его заказы, адреса или агрегированное customer.last_seen_at. События создания, отзыва и удаления сессии остаются в отдельном аудите без bearer-токенов. Отозванные сессии разрешено удалять раньше отдельной housekeeping-задачей, если срок хранения аудита соблюдается.

POST /api/v1/auth/logout

Отзывает текущую sid, очищает refresh cookie. Операция идемпотентна. Access token перестаёт работать не позднее чем через 15 минут; при необходимости sid временно попадает в denylist.

POST /api/v1/auth/logout-all

Требует действующий access token. В одной транзакции backend:

  1. отзывает все refresh_session пользователя;
  2. увеличивает customer.customer.session_version — все ранее выданные access JWT с прежним ver немедленно перестают приниматься;
  3. отзывает перенесённый случайный legacy-токен, чтобы его нельзя было снова обменять;
  4. записывает событие в аудит;
  5. очищает refresh cookie текущего браузера.

Ответ не ждёт обращения остальных устройств и всегда идемпотентен. Это явное действие пользователя или администратора; обычный новый вход и ошибка одной сессии не должны завершать остальные устройства. После logout-all повторный вход выполняется по одноразовому коду или через подтверждённый Telegram WebApp — старый legacy token больше не обменивается.

Блокировка пользователя

В legacy блокировка дублируется: Laravel хранит black_lists, а Go-сервис user — отдельную таблицу blocked_phones. Изменение одной таблицы не гарантирует изменение другой. Go-сервис проверяет номер при отправке кода, login и register, но уже выданные токены при блокировке не отзывает. В новой системе так быть не должно.

Единственный владелец блокировки — модуль customer. Для зарегистрированного клиента состояние хранится в его записи:

alter table customer.customer
    add column status text not null default 'ACTIVE', -- ACTIVE | BLOCKED
    add column session_version int not null default 1,
    add column blocked_at timestamptz,
    add column blocked_reason text,
    add column blocked_by bigint references platform.admin_user(id);

-- Номера, которые нужно запретить ещё до регистрации пользователя.
create table customer.blocked_phone (
    phone       text primary key,
    reason      text        not null,
    blocked_at  timestamptz not null default now(),
    blocked_by  bigint      references platform.admin_user(id)
);

-- Append-only аудит блокировок и разблокировок.
create table customer.block_audit (
    id              bigint generated always as identity primary key,
    customer_id     uuid,
    phone           text        not null, -- нормализованный E.164 на момент операции
    action          text        not null check (action in ('BLOCK', 'UNBLOCK')),
    reason          text        not null,
    actor_admin_id  bigint      not null,
    request_id      uuid,
    created_at      timestamptz not null default now()
);

create index block_audit_customer_created_idx
    on customer.block_audit (customer_id, created_at desc);

create index block_audit_phone_created_idx
    on customer.block_audit (phone, created_at desc);

blocked_phone нужен для номера, у которого ещё нет аккаунта, и для запрета повторной регистрации после удаления пользователя. Если клиент существует, основной статус — customer.customer.status=BLOCKED. Телефон всегда хранится в нормализованном E.164, поэтому две формы одного номера не создают разные блокировки.

Админский контракт:

POST /api/admin/v1/customers/{id}/block
{
  "reason": "спам и ложные заказы"
}

POST /api/admin/v1/customers/{id}/unblock
{
  "reason": "блокировка снята после проверки"
}

POST /api/admin/v1/blocked-phones
{
  "phone": "+79001234567",
  "reason": "спам"
}

Блокировка выполняется одной прикладной операцией:

  1. заблокировать строку клиента FOR UPDATE;
  2. установить status=BLOCKED, blocked_at, обязательную причину и автора;
  3. upsert нормализованного телефона в blocked_phone;
  4. увеличить session_version;
  5. отозвать все refresh_session с причиной ACCOUNT_BLOCKED;
  6. отозвать legacy credential, чтобы его нельзя было обменять;
  7. погасить все активные auth_challenge этого телефона;
  8. добавить строку BLOCK в customer.block_audit в той же транзакции;
  9. установить customer:blocked:{userId} в Redis и разослать invalidation всем инстансам;
  10. вернуть успех администратору только после того, как центральный denylist обновлён.

Разблокировка аналогично добавляет строку UNBLOCK с обязательными причиной и автором в той же транзакции, в которой снимаются customer.status и запись blocked_phone. Записи customer.block_audit не обновляются и не удаляются прикладным API. На этом этапе аудит хранится только в PostgreSQL: отправка в отдельную систему аудита или публикация audit-событий не требуется.

Refresh token физически удалять сразу не нужно: запись со статусом отзыва сохраняет аудит и позволяет расследовать попытку повторного использования. Секретный token/hash удаляется housekeeping-задачей после установленного срока.

Access JWT хранится у клиента, поэтому «очистить» его удалённо невозможно. Мгновенная блокировка достигается тремя проверками каждого авторизованного запроса:

  1. customer:blocked:{sub} в Redis отсутствует;
  2. ver JWT равен текущему customer.session_version;
  3. customer.status=ACTIVE.

Быстрый путь проверяет Redis. Событие CustomerBlocked немедленно сбрасывает локальные кеши всех инстансов. При недоступности Redis проверка блокировки идёт в PostgreSQL; для операций изменения профиля, корзины, оформления заказа и компенсаций отказ этой проверки является fail closed, а не разрешением запроса. Периодическая reconciliation-задача восстанавливает denylist из БД после рестарта или сбоя Redis.

JWT остаётся подписанным и формально может иметь неистёкший exp, но после увеличения session_version и установки denylist любой следующий запрос получает:

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
  "type": "https://panam.ru/errors/account-blocked",
  "status": 403,
  "code": "ACCOUNT_BLOCKED",
  "title": "Доступ ограничен"
}

Frontend очищает состояние пользователя и refresh cookie через auth endpoint, но отсутствие этой очистки не влияет на защиту — backend уже отклоняет токены.

Разблокировка устанавливает status=ACTIVE, удаляет телефон из blocked_phone, удаляет запись из denylist и снова увеличивает session_version. Старые access, refresh и legacy-токены не восстанавливаются: клиент входит заново по одноразовому коду. Причина, автор и обе операции остаются в аудите.

Блокировка запрещает выдачу кода, login, refresh, legacy exchange, изменение профиля и создание нового заказа. Уже принятый и оплаченный заказ автоматически не отменяется — решение об отмене и возврате принимает сотрудник по отдельному процессу.

GET /api/v1/auth/sessions

Возвращает устройства без токенов:

{
  "items": [
    {
      "sessionId": "01K2Q6M7R2QH0J4D8A1F",
      "deviceId": "5ef774bb-3177-40cc-85b7-7287fb94f73a",
      "deviceType": "WEB",
      "deviceName": "Chrome на MacBook",
      "appVersion": "web-2026.08.17",
      "createdAt": "2026-08-17T12:30:00Z",
      "lastUsedAt": "2026-08-18T08:45:00Z",
      "current": true
    }
  ]
}

sessionId — серверная сессия, deviceId — установка клиента. Пользователь отзывает конкретную сессию через DELETE /api/v1/auth/sessions/{session_id}. Нельзя принимать deviceId как цель удаления без проверки владельца: значение создаётся клиентом и не является доверенным.

12.9. Telegram WebApp

Telegram InitData передаётся в POST /api/v1/auth/telegram, а не в URL. Backend:

  1. проверяет HMAC по официальному алгоритму Telegram;
  2. проверяет auth_date и отклоняет старые данные;
  3. извлекает телефон только из подтверждённого контакта;
  4. нормализует телефон и проверяет блокировку;
  5. находит или создаёт пользователя;
  6. создаёт ту же refresh_session и выдаёт ту же пару access/cookie.

Запрос содержит объект device того же формата, что и вход по коду. Telegram user_id не используется как device_id: один Telegram-аккаунт может открывать приложение на нескольких физических устройствах.

Вход через Telegram не образует отдельный тип сессии и не выдаёт JWT с другими правилами. В аудите сохраняется auth_method=TELEGRAM, но InitData и bot token не логируются.

Прямой вход через MAX WebApp не реализуется. MAX используется только как канал доставки одноразового кода; после получения кода клиент входит через обычный POST /api/v1/auth/login.

12.10. Использование JWT сайтом

загрузка приложения
  └─ POST /api/v1/auth/refresh (cookie отправляется браузером)
       ├─ 200 → access token хранится в памяти → GET /api/v1/users/me
       └─ 401 → пользователь считается гостем

API вернул 401 из-за exp
  └─ один общий refresh-запрос
       ├─ успех → повторить исходные запросы один раз
       └─ ошибка → очистить локальное состояние и показать вход

Требования к frontend:

  • не хранить access/refresh в localStorage, sessionStorage, IndexedDB или URL;
  • не декодировать JWT как источник правды — claims используются только для UX, решение принимает backend;
  • не запускать несколько refresh одновременно;
  • не повторять запрос бесконечно после второго 401;
  • очищать пользовательские кеши TanStack Query при logout и смене пользователя;
  • использовать credentials: include только для auth endpoint и настроенного API origin;
  • CSP и отсутствие XSS остаются обязательными: access token находится в памяти страницы.

12.11. Миграция случайных legacy-токенов

Массовый повторный вход запрещён продуктовым требованием. Существующие clients.token переносятся во временное хранилище обмена в виде SHA-256/защищённого хеша. Сам случайный токен не становится JWT и не принимается бизнес-endpoint нового API: новый frontend бесшовно обменивает его на отдельную JWT-сессию.

Новая версия frontend при первом запуске проверяет сохранённый legacy token и вызывает:

POST /api/v1/auth/legacy-exchange
Authorization: Legacy <random-token>

Он:

  1. ищет хеш токена в migration-таблице и определяет пользователя;
  2. проверяет блокировку, но не требует повторного SMS-кода;
  3. создаёт независимую refresh-сессию текущего устройства;
  4. устанавливает долгую refresh cookie и возвращает JWT access;
  5. после успеха frontend удаляет legacy token из своего хранилища;
  6. допускает повторный обмен того же legacy token с другого устройства, поскольку раньше один токен пользователя мог использоваться на нескольких устройствах;
  7. применяет rate limit и аудит, но не помечает токен использованным после первого обмена.

legacy-exchange принимает тот же объект device, что и обычный login. Благодаря этому обмены одного старого токена на телефоне и компьютере создают две отображаемые независимые сессии.

Старые endpoint /api/user/*, query-параметр ?token= и общий legacy middleware в новой системе не реализуются. Единственная точка использования случайного токена — POST /api/v1/auth/legacy-exchange. Благодаря этому новый API с первого дня имеет один основной механизм авторизации, но существующая сессия пользователя не теряется.

Frontend выполняет миграцию до обычной загрузки профиля:

есть refresh cookie
  └─ refresh → продолжить работу

нет refresh cookie, но найден legacy token в старом хранилище
  └─ legacy-exchange → получить refresh cookie и access JWT
       ├─ успех → удалить локальную копию legacy token
       └─ 401 → показать обычный вход по телефону

Хранилище обмена сохраняется 365 дней с даты production-запуска новой системы, чтобы вернувшийся через длительное время клиент также вошёл без SMS. После этого legacy-exchange отключается, migration credentials удаляются по регламенту, а оставшиеся клиенты входят по коду. Отозванные через logout-all legacy-токены удаляются или помечаются недействительными немедленно.

План переключения:

  1. перенести пользователей и хеши существующих токенов в migration-таблицу;
  2. выпустить новый backend с JWT login/refresh/logout и legacy-exchange;
  3. одновременно выпустить новый frontend, который сначала использует refresh cookie, а при её отсутствии обменивает сохранённый legacy token;
  4. мобильное приложение как новый клиент сразу использует только JWT;
  5. старые endpoint не переносить и новые случайные токены не выдавать;
  6. наблюдать успешность обмена и хранить migration-таблицу 365 дней после production-запуска;
  7. по окончании 365 дней удалить migration credentials и код legacy-exchange;
  8. ротировать ключи и секреты, использованные в старых окружениях.

12.12. Ключи и ротация

  • секреты HS256 хранятся в secret storage и не попадают в Git, образ, логи или frontend;
  • access и refresh используют разные секреты и разные aud/typ;
  • каждый секрет имеет kid, монолит знает активный и предыдущие ключи периода миграции;
  • при штатной ротации новый kid сначала добавляется в набор проверки, затем им подписываются новые токены;
  • старый access-секрет хранится не меньше 15 минут, старый refresh-секрет — до истечения или ротации выпущенных им сессий;
  • аварийная ротация отзывает refresh-сессии и увеличивает session_version клиентов, если старому секрету больше нельзя доверять;
  • production, staging и development используют разные iss, aud и секреты;
  • публичный и внутренний JWKS endpoint не создаются, пока система остаётся монолитом.

12.13. Аудит, метрики и персональные данные

События общего аудита:

  • код запрошен/доставлен/отклонён без сохранения самого кода;
  • успешный и неуспешный вход;
  • создание, обновление, отзыв и reuse refresh-сессии;
  • logout/logout-all;
  • legacy exchange;
  • вход через Telegram/MAX.
  • создание и использование impersonation-кода, завершение impersonation-сессии и выполненные в ней изменяющие операции.

Блокировки и разблокировки сохраняются отдельно в customer.block_audit, включая целевой customer_id/телефон, действие, причину, администратора, request_id и время операции.

Телефон маскируется в обычных логах. JWT, refresh cookie, одноразовый код, captcha token и Telegram InitData не логируются. Для расследования достаточно userId, sid, jti, метода входа, результата, IP/подсети, user-agent hash, времени и requestId.

Метрики:

  • количество запросов и успешных доставок кода по каналам;
  • ошибки и latency провайдеров;
  • успешные/неуспешные входы и блокировки;
  • refresh success/failure/reuse;
  • активные сессии и logout-all;
  • заблокированные аккаунты и отклонённые запросы с их токенами;
  • задержка от команды block до появления записи в центральном denylist;
  • последнее посещение по каналам WEB | IOS | ANDROID;
  • количество сессий, удалённых после 365 дней неактивности;
  • возраст самой старой активной сессии и задержка записи активности;
  • доля legacy exchange;
  • аномальное число входов по телефону/IP/устройству.

12.14. Контракты ошибок

Все ошибки — RFC 7807. Машинные коды стабильны:

HTTP Код Когда
400 INVALID_REQUEST формат телефона, кода или тела
401 INVALID_CODE неверный/истёкший/использованный challenge
401 INVALID_TOKEN access или refresh не прошёл проверку
403 ACCOUNT_BLOCKED телефон или пользователь заблокирован
409 REFRESH_REUSED повторно предъявлен ротированный refresh
422 CHANNEL_UNAVAILABLE нет контакта в выбранном мессенджере
429 RATE_LIMITED превышен лимит, ответ содержит Retry-After
503 DELIVERY_UNAVAILABLE провайдер не доставил код

Ответ INVALID_CODE не уточняет, существует ли пользователь. Ошибки подписи JWT и причина блокировки не раскрываются клиенту детально.

12.15. Проверки перед приёмкой

  • access JWT истекает через 15 минут и не принимается как refresh;
  • refresh JWT не принимается бизнес-endpoint;
  • обязательные claims и фиксированный алгоритм проверяются;
  • просроченный, отозванный и заменённый refresh отклоняются;
  • reuse старого refresh отзывает семейство;
  • один код нельзя применить двумя параллельными запросами;
  • клиентские OTP и impersonation-коды хранятся только как HMAC/хеш;
  • impersonation доступен только администратору с отдельным разрешением, отражён в JWT и аудите, виден в UI и завершается не позднее 60 минут;
  • превышение попыток сжигает challenge;
  • logout одного устройства не завершает остальные, logout-all завершает все;
  • после logout-all старые access JWT, refresh cookie и legacy token немедленно отклоняются;
  • блокировка устанавливает customer.status=BLOCKED, увеличивает session_version, отзывает все refresh- и legacy-сессии и погашает коды входа;
  • после успешного ответа admin API заблокированный access JWT отклоняется на всех инстансах;
  • при недоступности Redis критичные авторизованные операции проверяют блокировку в PostgreSQL и не продолжаются при невозможности проверки;
  • разблокировка не восстанавливает старые токены и требует нового входа;
  • новый вход на другом устройстве не отзывает существующие сессии;
  • каждая сессия содержит device_id, тип, отображаемое имя и версию приложения;
  • повторный вход с тем же device_id заменяет только сессию этого устройства;
  • список сессий показывает устройства и позволяет отозвать одно по sessionId;
  • авторизованная активность обновляет session.last_used_at и агрегированный customer.last_seen_at не позднее чем через 15 минут;
  • успешный refresh продлевает сессию ещё на 365 дней;
  • ежедневная задача удаляет сессии без активности более 365 дней, не удаляя клиента и его данные;
  • перед очисткой накопленный буфер активности сбрасывается в PostgreSQL;
  • заблокированный клиент не получает новую сессию;
  • существующий legacy token бесшовно обменивается без SMS и сохраняет пользовательскую сессию;
  • legacy token нескольких устройств создаёт независимые JWT-сессии;
  • token из query и старые /api/user/* endpoint отсутствуют в новом backend;
  • секреты и персональные данные отсутствуют в логах;
  • смена kid проходит без простоя;
  • frontend корректно переживает истечение access и единожды повторяет запрос;
  • регулярно активная refresh-сессия не имеет предельной календарной даты завершения.

12.16. Принятые продуктовые уточнения

  1. Остаются три канала одноразового кода: telegram, max, sms. Backend использует строго переданный клиентом канал; порядок, выбор и отображение каналов определяет клиентское приложение.
  2. Прямого входа через MAX WebApp нет; MAX только доставляет код.
  3. Пользователь не меняет отображаемое имя устройства — его формирует backend.
  4. Возможность обмена legacy-токена хранится 365 дней после production-запуска.
  5. Заблокированный пользователь немедленно теряет все access, refresh и legacy-сессии.
  6. JWT выпускает и проверяет только монолит; JWKS endpoint пока не нужен.

Открыто только техническое уточнение перед миграцией: под каким origin и ключом браузерного хранилища текущий frontend держит legacy token и сможет ли новый frontend прочитать его перед обменом.