# ADR-003: Паттерн аутентификации и хранения идентификатора **Дата:** 2026-03-20 **Статус:** Accepted **Автор:** Architect Agent (Kin pipeline, BATON-003) **Решения:** #1013, #1015, #1024, #1025 --- ## Контекст Baton — PWA экстренного сигнала для 300–400 пользователей из разных стран. Требования к аутентификации: 1. **Минимальное трение:** пользователь регистрируется один раз (имя), далее никаких логинов 2. **Stateless на сервере:** нет серверных сессий, JWT или token refresh 3. **Персистентность:** UUID сохраняется между перезапусками браузера (желательно бессрочно) 4. **Кроссплатформенность:** Android Chrome, iOS Safari (включая приватный режим), Desktop 5. **Нет чувствительных данных:** идентификатор не даёт доступа ни к чему — утечка UUID безвредна --- ## Варианты ### Вариант A: JWT + refresh token **Плюсы:** - Стандартный подход, проверенный в enterprise - Поддержка ролей и permissions **Минусы:** - Требует серверную логику refresh (endpoint, хранение revoked tokens) - Избыточен: в Baton нет ролей, нет protected resources - bcrypt блокирует asyncio event loop (#1004) — нужен `run_in_executor` - Добавляет ~200 строк серверного кода без пользы для задачи ### Вариант B: UUID v4 в localStorage (stateless) **Плюсы:** - `crypto.randomUUID()` — криптографически стойкий, 122 бита энтропии - Поддержка: Chrome 92+, Firefox 95+, Safari 15.4+ (97%+ глобально) - Нет серверного состояния — UUID генерируется клиентом - Один endpoint `/api/register` — idempotent, UUID как уникальный ключ - ~15 строк клиентского кода **Минусы:** - UUID привязан к устройству, не к человеку (переход на другое устройство = новый UUID) - iOS Safari приватный режим: `localStorage.setItem()` бросает `SecurityError` (#1015) - Очистка кеша / 7-дневная очистка iOS = потеря UUID → новая регистрация ### Вариант C: Session cookie (httpOnly) **Плюсы:** - Автоматическая отправка с каждым запросом - httpOnly защищает от XSS **Минусы:** - Требует серверную сессию или signed cookie - Cookie подчиняются SameSite ограничениям - iOS Safari aggressively очищает 3rd-party cookies - Добавляет серверную логику без выгоды для stateless модели --- ## Решение **Выбран Вариант B: UUID v4 в localStorage с цепочкой fallback** Цепочка хранения (#1025): ``` localStorage → sessionStorage → in-memory object ``` --- ## Обоснование 1. **UUID v4 достаточен для идентификации (#1013).** В Baton нет protected resources. UUID — это просто "кто нажал кнопку". Утечка UUID безвредна — атакующий может отправить ложный сигнал, но это решается на уровне модерации Telegram-группы, а не криптографии. 2. **localStorage проверяется реальной записью, не `typeof` (#1024):** ```javascript function isStorageAvailable(storage) { try { const key = '__baton_test__'; storage.setItem(key, '1'); storage.removeItem(key); return true; } catch (e) { return false; } } ``` `typeof localStorage !== 'undefined'` вернёт `true` в iOS private mode, но `.setItem()` бросит `SecurityError`. Только реальная запись выявляет проблему. 3. **Цепочка fallback (#1025) гарантирует работу на любой платформе:** - **localStorage** (нормальный режим): UUID живёт бессрочно* - **sessionStorage** (iOS private mode fallback): UUID живёт до закрытия вкладки - **in-memory** (крайний случай, CSP/iframe restrictions): UUID живёт до перезагрузки страницы *На iOS — до 7 дней неактивности или ручной очистки. 4. **JWT отклонён** — избыточная сложность. Baton не имеет ролей, permissions, или данных требующих авторизации. `run_in_executor` для bcrypt (#1004) — это overhead ради несуществующей потребности. 5. **Session cookies отклонены** — серверное состояние противоречит stateless архитектуре. SameSite и iOS cookie policies создают edge cases без пользы. --- ## Реализация (спецификация для dev-агента) ### Клиентская сторона (app.js) ```javascript let _memoryStore = {}; function getStorage() { if (isStorageAvailable(localStorage)) return localStorage; if (isStorageAvailable(sessionStorage)) return sessionStorage; return { getItem: (k) => _memoryStore[k] || null, setItem: (k, v) => { _memoryStore[k] = v; }, removeItem: (k) => { delete _memoryStore[k]; } }; } function getOrCreateUserId() { const storage = getStorage(); let id = storage.getItem('baton_user_id'); if (!id) { id = crypto.randomUUID(); storage.setItem('baton_user_id', id); } return id; } ``` ### Серверная сторона (backend) - `POST /api/register` принимает `{uuid, name}`, возвращает `{user_id, uuid}` - Idempotent: `INSERT OR IGNORE INTO users (uuid, name)` — повторный вызов с тем же UUID возвращает существующую запись - UUID в таблице `users` — UNIQUE constraint ### Поток первого визита ``` 1. PWA открыт → getOrCreateUserId() → UUID сгенерирован 2. Показать форму ввода имени 3. POST /api/register {uuid, name} → 200 OK 4. UUID сохранён в storage → следующий визит: UUID уже есть, форма не показывается ``` --- ## Последствия 1. **UUID = устройство, не человек.** Один пользователь на двух устройствах = два UUID. Это приемлемо: цель — знать ИМЯ нажавшего, а не объединять профили. 2. **Потеря UUID → повторная регистрация.** При очистке данных или 7-дневной iOS-очистке пользователь получит новый UUID и должен будет заново ввести имя. Частота: низкая (пользователи экстренного приложения открывают его при установке и при экстренных ситуациях). 3. **Приватный режим = временный UUID.** Это ожидаемое поведение, не баг. Пользователь в private mode осознанно отказывается от персистентности. 4. **Нет миграции данных.** UUID не переносится между устройствами. Если потребуется в будущем — добавить Telegram user ID как secondary identifier (уже есть endpoint `/api/webhook/telegram` для `/start`). 5. **crypto.randomUUID() требует Secure Context.** HTTPS обязателен. На HTTP `crypto.randomUUID` вернёт `undefined` в некоторых браузерах → дополнительный fallback через `URL.createObjectURL(new Blob()).slice(-36)` НЕ нужен, т.к. HTTPS обязателен для PWA (#1011).