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;
- профиль сначала ищет пользователя по
subJWT, а при ошибке — по случайному 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. Целевая схема¶
Продуктовые требования имеют следующий приоритет:
- Сохранение текущих сессий. Миграция пользователей не должна массово разлогинить клиентов. Поддерживать старый frontend и старые API при этом не требуется: новый frontend бесшовно обменивает сохранённый legacy-токен на новую сессию.
- Несколько устройств. Один клиент может одновременно работать с телефона, планшета, компьютера и других устройств. Каждое устройство имеет независимую сессию. При этом клиенту доступно явное действие «Выйти на всех устройствах», которое немедленно завершает все новые и legacy-сессии.
- Максимально долгий вход. Сессия продлевается при использовании и в нормальном сценарии живёт до явного выхода, блокировки или длительного отсутствия клиента. Удобство бизнеса важнее агрессивного завершения сессий по рисковым сигналам.
Короткий 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¶
channel: sms | telegram | max. Порядок каналов в UI не является частью backend-контракта.
Backend отправляет код строго через канал, указанный в запросе, и не выбирает канал самостоятельно.
Если выбранный мессенджер не привязан или провайдер недоступен, API возвращает код ошибки
CHANNEL_UNAVAILABLE; автоматического переключения на другой канал нет. Для повторной отправки
через другой канал клиент должен выполнить новый явный запрос с соответствующим channel.
Успешный ответ одинаков для существующего и нового телефона:
Требования:
- нормализация телефона до 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 не нужен. Если модуль позднее выделяется в
отдельный процесс, выбор алгоритма и распространение ключей пересматриваются.
{
"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 не имеет.
Правила проверки:
- разрешён только явно настроенный алгоритм
HS256, значение из заголовка не выбирает алгоритм; - ключ определяется по известному
kid, неизвестный ключ отклоняется; - обязательны и проверяются
iss,aud,sub,iat,nbf,exp,jti,typ=access,sid,ver; subдолжен быть UUID существующего незаблокированного клиента;- допустимый clock skew — не более 30 секунд;
- access JWT принимается только из
Authorization: Bearer <token>; - query-параметр
?token=и JWT в cookie не поддерживаются; - токен никогда не логируется целиком.
В 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:
- проверяет подпись и claims с
typ=refresh; - считает SHA-256 токена и блокирует соответствующую сессию;
- проверяет пользователя, срок и отсутствие отзыва;
- ротирует refresh: старый
jti/hashстановится недействительным, выдаётся новый; - продлевает
expires_atтекущего устройства ещё на 365 дней; - выдаёт новый access token и заменяет cookie;
- возвращает
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 дней:
Перед удалением накопленная активность из буфера должна быть сброшена в 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:
- отзывает все
refresh_sessionпользователя; - увеличивает
customer.customer.session_version— все ранее выданные access JWT с прежнимverнемедленно перестают приниматься; - отзывает перенесённый случайный legacy-токен, чтобы его нельзя было снова обменять;
- записывает событие в аудит;
- очищает 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": "спам"
}
Блокировка выполняется одной прикладной операцией:
- заблокировать строку клиента
FOR UPDATE; - установить
status=BLOCKED,blocked_at, обязательную причину и автора; - upsert нормализованного телефона в
blocked_phone; - увеличить
session_version; - отозвать все
refresh_sessionс причинойACCOUNT_BLOCKED; - отозвать legacy credential, чтобы его нельзя было обменять;
- погасить все активные
auth_challengeэтого телефона; - добавить строку
BLOCKвcustomer.block_auditв той же транзакции; - установить
customer:blocked:{userId}в Redis и разослать invalidation всем инстансам; - вернуть успех администратору только после того, как центральный denylist обновлён.
Разблокировка аналогично добавляет строку UNBLOCK с обязательными причиной и автором в той же
транзакции, в которой снимаются customer.status и запись blocked_phone. Записи
customer.block_audit не обновляются и не удаляются прикладным API. На этом этапе аудит хранится
только в PostgreSQL: отправка в отдельную систему аудита или публикация audit-событий не требуется.
Refresh token физически удалять сразу не нужно: запись со статусом отзыва сохраняет аудит и позволяет расследовать попытку повторного использования. Секретный token/hash удаляется housekeeping-задачей после установленного срока.
Access JWT хранится у клиента, поэтому «очистить» его удалённо невозможно. Мгновенная блокировка достигается тремя проверками каждого авторизованного запроса:
customer:blocked:{sub}в Redis отсутствует;verJWT равен текущемуcustomer.session_version;customer.status=ACTIVE.
Быстрый путь проверяет Redis. Событие CustomerBlocked немедленно сбрасывает локальные кеши всех
инстансов. При недоступности Redis проверка блокировки идёт в PostgreSQL; для операций изменения
профиля, корзины, оформления заказа и компенсаций отказ этой проверки является fail closed, а
не разрешением запроса. Периодическая reconciliation-задача восстанавливает denylist из БД после
рестарта или сбоя Redis.
JWT остаётся подписанным и формально может иметь неистёкший exp, но после увеличения
session_version и установки denylist любой следующий запрос получает:
{
"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:
- проверяет HMAC по официальному алгоритму Telegram;
- проверяет
auth_dateи отклоняет старые данные; - извлекает телефон только из подтверждённого контакта;
- нормализует телефон и проверяет блокировку;
- находит или создаёт пользователя;
- создаёт ту же
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 и вызывает:
Он:
- ищет хеш токена в migration-таблице и определяет пользователя;
- проверяет блокировку, но не требует повторного SMS-кода;
- создаёт независимую refresh-сессию текущего устройства;
- устанавливает долгую refresh cookie и возвращает JWT access;
- после успеха frontend удаляет legacy token из своего хранилища;
- допускает повторный обмен того же legacy token с другого устройства, поскольку раньше один токен пользователя мог использоваться на нескольких устройствах;
- применяет 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-токены удаляются или помечаются недействительными немедленно.
План переключения:
- перенести пользователей и хеши существующих токенов в migration-таблицу;
- выпустить новый backend с JWT login/refresh/logout и
legacy-exchange; - одновременно выпустить новый frontend, который сначала использует refresh cookie, а при её отсутствии обменивает сохранённый legacy token;
- мобильное приложение как новый клиент сразу использует только JWT;
- старые endpoint не переносить и новые случайные токены не выдавать;
- наблюдать успешность обмена и хранить migration-таблицу 365 дней после production-запуска;
- по окончании 365 дней удалить migration credentials и код
legacy-exchange; - ротировать ключи и секреты, использованные в старых окружениях.
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. Принятые продуктовые уточнения¶
- Остаются три канала одноразового кода:
telegram,max,sms. Backend использует строго переданный клиентом канал; порядок, выбор и отображение каналов определяет клиентское приложение. - Прямого входа через MAX WebApp нет; MAX только доставляет код.
- Пользователь не меняет отображаемое имя устройства — его формирует backend.
- Возможность обмена legacy-токена хранится 365 дней после production-запуска.
- Заблокированный пользователь немедленно теряет все access, refresh и legacy-сессии.
- JWT выпускает и проверяет только монолит; JWKS endpoint пока не нужен.
Открыто только техническое уточнение перед миграцией: под каким origin и ключом браузерного хранилища текущий frontend держит legacy token и сможет ли новый frontend прочитать его перед обменом.