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

163 lines
8.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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