feat(tradein/support): чат поддержки без входа — экран логина и «доступа нет» #2577

Merged
lekss361 merged 1 commit from feat/tradein-anon-support into main 2026-07-31 13:22:21 +00:00
Owner

Проблема (инцидент 2026-07-31)

После cutover'а на свою авторизацию (#2558) единственным каналом в поддержку остался чат за логином, а самая частая причина писать в поддержку — как раз «не могу войти».

Сегодня это выстрелило: «Практика» весь день билась в форму входа (5 login_failed с трёх разных IP, ни одной успешной попытки), при этом в поле «Логин» вводили старый basic_auth-пароль. Сообщить об этом из продукта было нечем — на /login нет ни чата, ни контакта. В логах ровно этот тупик: GET /support/unread → 401.

Backend — 4 ручки /api/v1/trade-in/support/anon/*

Публичные в rbac_guard (рядом с /auth/login, по той же логике: вызываются до того, как клиент аутентифицирован).

  • Идентичность — opaque-токен (token_urlsafe(18), 144 бита) в httpOnly+Secure+SameSite=Lax куке. Тред живёт в тех же web_support_threads под ключом anon:<token>.
  • Коллизия с реальным логином структурно невозможна: двоеточие. tradein_users допускает только ^[A-Za-z0-9._-]{3,64}$ (CHECK миграции 193 + Pydantic), двоеточия там быть не может — аноним никогда не попадёт в чужой тред и не «станет» существующим юзером.
  • Изоляция та же, что у авторизованной ветки: thread_id снаружи не принимается ни в каком виде, тред резолвится исключительно из куки. Форма куки валидируется regex'ом — мусор из браузера не становится ключом треда и до storage не доезжает вообще.
  • Токен не утекает в Telegram: в топик уходит anon-<6 hex от sha256(токен)>, а не сам bearer — зеркало читают люди и пересылают дальше. Метка [С САЙТА · БЕЗ ВХОДА]: оператору важно, что у обращения нет аккаунта и вероятная причина — невозможность войти.
  • Анти-абуз (публичная ручка записи в общий Telegram-топик): два независимых бюджета — per-token (12/мин, тот же _send_limiter, ключи не пересекаются) и per-IP (10/10 мин). Второй ловит обход ротацией куки; без него бюджет обнуляется сбросом куки.
  • Порядок операций — как в авторизованной ветке (H1): БД и кука трогаются только после успешного sendMessage. Неудачная отправка не закрепляет за посетителем пустой тред.

Ответы оператора маршрутизируются без изменений в bridge.py: реплай резолвится по topic_message_idthread_id, кто автор треда — там неважно.

Frontend

  • SupportScope = "auth" | "anon" в useSupportChat: scope выбирает базовый путь и входит в ключ кэша — иначе после логина в панели висела бы переписка анонима. Дефолт "auth", существующие места монтирования (v2 layout) не меняются.
  • AnonSupportWidget — провайдер + кнопка в анонимном scope. Монтируется на /login и в NoAccessScreen: обе точки — тупики, из которых пользователю больше некуда идти. На /login добавлена подсказка «Не получается войти? …».
  • NoAccessScreen — сознательное расхождение с зеркалом в Site Finder (виджет ходит в trade-in'овый бридж, которого в том бэкенде нет), отмечено в докстринге.

Проверено

  • pytest tests/test_support.py tests/test_rbac.py — 62 passed
  • 13 новых тестов анонимной ветки: отправка без всякой авторизации + кука · переиспользование куки = тот же тред · метка зеркала и отсутствие токена в тексте для Telegram · read-ручки без куки отдают пусто, а не 401 · битая кука игнорируется и не доезжает до storage · per-IP лимит (в т.ч. что ротация куки не спасает) · per-token лимит · неудачная отправка не пишет ничего и не ставит куку · бот не настроен → 503 · пустой текст → 422 · невозможность коллизии ключа с _USERNAME_RE
  • 2 новых теста границы в rbac_guard: аноним проходит; соседний авторизованный /support/unread по-прежнему 401/403
  • client-фикстура переведена на https://testserver — иначе httpx не возвращал бы Secure-куку и тесты «тот же тред» тихо проверяли бы каждый раз нового анонима
  • ruff check + ruff format — чисто; frontend tsc --noEmit + next build — зелёные
## Проблема (инцидент 2026-07-31) После cutover'а на свою авторизацию (#2558) единственным каналом в поддержку остался чат **за логином**, а самая частая причина писать в поддержку — как раз «не могу войти». Сегодня это выстрелило: «Практика» весь день билась в форму входа (5 `login_failed` с трёх разных IP, ни одной успешной попытки), при этом в поле «Логин» вводили старый basic_auth-пароль. Сообщить об этом из продукта было нечем — на `/login` нет ни чата, ни контакта. В логах ровно этот тупик: `GET /support/unread → 401`. ## Backend — 4 ручки `/api/v1/trade-in/support/anon/*` Публичные в `rbac_guard` (рядом с `/auth/login`, по той же логике: вызываются до того, как клиент аутентифицирован). - **Идентичность** — opaque-токен (`token_urlsafe(18)`, 144 бита) в httpOnly+Secure+SameSite=Lax куке. Тред живёт в тех же `web_support_threads` под ключом `anon:<token>`. - **Коллизия с реальным логином структурно невозможна**: двоеточие. `tradein_users` допускает только `^[A-Za-z0-9._-]{3,64}$` (CHECK миграции 193 + Pydantic), двоеточия там быть не может — аноним никогда не попадёт в чужой тред и не «станет» существующим юзером. - **Изоляция та же, что у авторизованной ветки**: `thread_id` снаружи не принимается ни в каком виде, тред резолвится исключительно из куки. Форма куки валидируется regex'ом — мусор из браузера не становится ключом треда и до storage не доезжает вообще. - **Токен не утекает в Telegram**: в топик уходит `anon-<6 hex от sha256(токен)>`, а не сам bearer — зеркало читают люди и пересылают дальше. Метка `[С САЙТА · БЕЗ ВХОДА]`: оператору важно, что у обращения нет аккаунта и вероятная причина — невозможность войти. - **Анти-абуз** (публичная ручка записи в общий Telegram-топик): два независимых бюджета — per-token (12/мин, тот же `_send_limiter`, ключи не пересекаются) и **per-IP (10/10 мин)**. Второй ловит обход ротацией куки; без него бюджет обнуляется сбросом куки. - **Порядок операций** — как в авторизованной ветке (H1): БД и кука трогаются только после успешного `sendMessage`. Неудачная отправка не закрепляет за посетителем пустой тред. Ответы оператора маршрутизируются **без изменений в `bridge.py`**: реплай резолвится по `topic_message_id` → `thread_id`, кто автор треда — там неважно. ## Frontend - `SupportScope = "auth" | "anon"` в `useSupportChat`: scope выбирает базовый путь и **входит в ключ кэша** — иначе после логина в панели висела бы переписка анонима. Дефолт `"auth"`, существующие места монтирования (v2 layout) не меняются. - `AnonSupportWidget` — провайдер + кнопка в анонимном scope. Монтируется на `/login` и в `NoAccessScreen`: обе точки — тупики, из которых пользователю больше некуда идти. На `/login` добавлена подсказка «Не получается войти? …». - `NoAccessScreen` — сознательное расхождение с зеркалом в Site Finder (виджет ходит в trade-in'овый бридж, которого в том бэкенде нет), отмечено в докстринге. ## Проверено - `pytest tests/test_support.py tests/test_rbac.py` — 62 passed - 13 новых тестов анонимной ветки: отправка без всякой авторизации + кука · переиспользование куки = тот же тред · метка зеркала и **отсутствие токена** в тексте для Telegram · read-ручки без куки отдают пусто, а не 401 · битая кука игнорируется и не доезжает до storage · per-IP лимит (в т.ч. что ротация куки не спасает) · per-token лимит · неудачная отправка не пишет ничего и не ставит куку · бот не настроен → 503 · пустой текст → 422 · невозможность коллизии ключа с `_USERNAME_RE` - 2 новых теста границы в `rbac_guard`: аноним проходит; соседний авторизованный `/support/unread` по-прежнему 401/403 - `client`-фикстура переведена на `https://testserver` — иначе httpx не возвращал бы Secure-куку и тесты «тот же тред» тихо проверяли бы каждый раз нового анонима - `ruff check` + `ruff format` — чисто; frontend `tsc --noEmit` + `next build` — зелёные
lekss361 added 1 commit 2026-07-31 13:05:46 +00:00
feat(tradein/support): чат поддержки без входа — экран логина и «доступа нет»
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m42s
CI Trade-In / backend-tests (pull_request) Successful in 2m31s
40fc94ee91
После cutover'а на свою авторизацию (#2558) единственным каналом в поддержку
остался чат ЗА логином, а самая частая причина писать в поддержку — как раз
«не могу войти». 2026-07-31 это выстрелило: «Практика» весь день билась в форму
входа (5 неудачных попыток с трёх разных IP, ни одной успешной) и сообщить об
этом из продукта не могла ничем — на /login не было ни чата, ни контакта.

Backend — 4 ручки /api/v1/trade-in/support/anon/* (public в rbac_guard):
- Идентичность анонима — opaque-токен в httpOnly+Secure куке; тред живёт в тех
  же web_support_threads под ключом `anon:<token>`. Двоеточие делает коллизию с
  реальным логином структурно невозможной (CHECK миграции 193 разрешает только
  `^[A-Za-z0-9._-]{3,64}$`) — аноним не может попасть в чужой тред.
- Изоляция та же, что у авторизованной ветки: thread_id снаружи не принимается
  ни в каком виде, тред резолвится ИСКЛЮЧИТЕЛЬНО из куки.
- Форма куки валидируется — мусор из браузера не становится ключом треда.
- В Telegram-топик уходит не токен (это bearer треда), а `anon-<6 hex sha256>`;
  зеркало помечено «[С САЙТА · БЕЗ ВХОДА]» — оператору важно, что аккаунта нет.
- Анти-абуз: два бюджета — per-token (12/мин) и per-IP (10/10мин). Второй ловит
  обход ротацией куки, без него публичная ручка записи в общий топик беззащитна.
- Кука и запись в БД — только после успешного sendMessage (порядок операций H1),
  неудачная отправка не закрепляет за посетителем пустой тред.

Frontend:
- `SupportScope = "auth" | "anon"` в useSupportChat: scope выбирает базовый путь
  и входит в ключ кэша (иначе после логина в панели висела бы переписка анонима).
  Дефолт "auth" — существующие места монтирования не меняются.
- `AnonSupportWidget` монтируется на /login и в NoAccessScreen — обе точки тупики,
  из которых пользователю больше некуда идти. На /login добавлена подсказка.

Ответы оператора маршрутизируются без изменений в bridge.py: реплай резолвится
по topic_message_id → thread_id, кто автор треда — там неважно.

Тесты: 13 новых на анонимную ветку + 2 на границу public/authed в rbac_guard.
62 passed (test_support + test_rbac).
lekss361 merged commit 103dc22b8c into main 2026-07-31 13:22:21 +00:00
lekss361 deleted branch feat/tradein-anon-support 2026-07-31 13:22:21 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2577
No description provided.