baton/docs/adr/ADR-003-auth-pattern.md

8.4 KiB
Raw Blame History

ADR-003: Паттерн аутентификации и хранения идентификатора

Дата: 2026-03-20 Статус: Accepted Автор: Architect Agent (Kin pipeline, BATON-003) Решения: #1013, #1015, #1024, #1025


Контекст

Baton — PWA экстренного сигнала для 300400 пользователей из разных стран. Требования к аутентификации:

  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 → новая регистрация

Плюсы:

  • Автоматическая отправка с каждым запросом
  • 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):

    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)

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).