8.4 KiB
ADR-003: Паттерн аутентификации и хранения идентификатора
Дата: 2026-03-20 Статус: Accepted Автор: Architect Agent (Kin pipeline, BATON-003) Решения: #1013, #1015, #1024, #1025
Контекст
Baton — PWA экстренного сигнала для 300–400 пользователей из разных стран. Требования к аутентификации:
- Минимальное трение: пользователь регистрируется один раз (имя), далее никаких логинов
- Stateless на сервере: нет серверных сессий, JWT или token refresh
- Персистентность: UUID сохраняется между перезапусками браузера (желательно бессрочно)
- Кроссплатформенность: Android Chrome, iOS Safari (включая приватный режим), Desktop
- Нет чувствительных данных: идентификатор не даёт доступа ни к чему — утечка 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
Обоснование
-
UUID v4 достаточен для идентификации (#1013). В Baton нет protected resources. UUID — это просто "кто нажал кнопку". Утечка UUID безвредна — атакующий может отправить ложный сигнал, но это решается на уровне модерации Telegram-группы, а не криптографии.
-
localStorage проверяется реальной записью, не
typeof(#1024):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. Только реальная запись выявляет проблему. -
Цепочка fallback (#1025) гарантирует работу на любой платформе:
- localStorage (нормальный режим): UUID живёт бессрочно*
- sessionStorage (iOS private mode fallback): UUID живёт до закрытия вкладки
- in-memory (крайний случай, CSP/iframe restrictions): UUID живёт до перезагрузки страницы
*На iOS — до 7 дней неактивности или ручной очистки.
-
JWT отклонён — избыточная сложность. Baton не имеет ролей, permissions, или данных требующих авторизации.
run_in_executorдля bcrypt (#1004) — это overhead ради несуществующей потребности. -
Session cookies отклонены — серверное состояние противоречит stateless архитектуре. SameSite и iOS cookie policies создают edge cases без пользы.
Реализация (спецификация для dev-агента)
Клиентская сторона (app.js)
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 уже есть, форма не показывается
Последствия
-
UUID = устройство, не человек. Один пользователь на двух устройствах = два UUID. Это приемлемо: цель — знать ИМЯ нажавшего, а не объединять профили.
-
Потеря UUID → повторная регистрация. При очистке данных или 7-дневной iOS-очистке пользователь получит новый UUID и должен будет заново ввести имя. Частота: низкая (пользователи экстренного приложения открывают его при установке и при экстренных ситуациях).
-
Приватный режим = временный UUID. Это ожидаемое поведение, не баг. Пользователь в private mode осознанно отказывается от персистентности.
-
Нет миграции данных. UUID не переносится между устройствами. Если потребуется в будущем — добавить Telegram user ID как secondary identifier (уже есть endpoint
/api/webhook/telegramдля/start). -
crypto.randomUUID() требует Secure Context. HTTPS обязателен. На HTTP
crypto.randomUUIDвернётundefinedв некоторых браузерах → дополнительный fallback черезURL.createObjectURL(new Blob()).slice(-36)НЕ нужен, т.к. HTTPS обязателен для PWA (#1011).