# Decision Record: JWT Refresh Token Storage **Дата**: 2026-06-11 **Статус**: Accepted (требует реализации) ## Контекст Текущая реализация в `backend/internal/services/auth.go` валидирует refresh токены **только по подписи JWT**, не сохраняя их в БД. Таблица `refresh_tokens` создана в миграции `000002_create_users.up.sql` но не используется. ## Проблема | Риск | Последствие | |------|-------------| | Утечка refresh токена | Полный доступ к аккаунту на 30 дней | | Нет logout / revoke | Пользователь не может завершить сессию на других устройствах | | Нет ротации | Replay атаки, кража токена не обнаруживается | | Смена пароля не инвалидирует сессии | Скомпрометированный аккаунт остаётся доступным | ## Решение **Хранить хеш refresh токена в таблице `refresh_tokens` с ротацией при каждом использовании.** ### Схема таблицы (уже существует) ```sql CREATE TABLE refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash VARCHAR(255) NOT NULL, -- SHA-256(token) expires_at TIMESTAMPTZ NOT NULL, -- now() + 30 days created_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ -- для явного отзыва ); CREATE UNIQUE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); ``` ### Алгоритм **Login / Register:** 1. Генерируем cryptographically secure random token (32 bytes → base64) 2. Сохраняем в БД: `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, SHA256($2), $3)` 3. Отдаём пользователю: `access_token` (JWT, 15min) + `refresh_token` (plain, в HttpOnly cookie) **Refresh:** 1. Читаем refresh token из HttpOnly cookie 2. `SELECT * FROM refresh_tokens WHERE token_hash = SHA256($1) AND expires_at > now() AND revoked_at IS NULL` 3. Если не найден → 401, возможно токен скомпрометирован → **revoke all user tokens** (security event) 4. **DELETE старый токен** (ротация) 5. Создаём новый refresh token → сохраняем в БД → ставим новую HttpOnly cookie 6. Генерируем новый access token → возвращаем в теле ответа **Logout / Password Change / Ban:** 1. `UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL` **Cleanup (cron job):** 1. `DELETE FROM refresh_tokens WHERE expires_at < now() - interval '1 day'` ## Альтернативы (отклонены) | Вариант | Почему отклонен | |---------|-----------------| | **JWT Blacklist в Redis** | Нужно хранить до истечения (30 дней), нет ротации, сложнее очистка | | **Короткий refresh (1 день) без БД** | Плохой UX — частый релогин, не решает проблему утечки | | **Access token в куке, refresh в localStorage** | localStorage уязвим к XSS, HttpOnly cookie безопаснее | ## Последствия **Положительные:** - Полный контроль сессий - Безопасный logout, смена пароля, бан - Детекция кражи токена (reuse detection) - Соответствие OWASP Auth Cheat Sheet **Отрицательные:** - +1 запрос к БД при каждом refresh (микросекунды, кэшируется) - Нужно мигрировать существующие токены (одноразовый скрипт) - Сложнее горизонтальное масширование (требует shared БД — уже есть) ## Связанные заметки - [[backend-auth-security]] - [[frontend-api-client]] — HttpOnly cookie для refresh - [[architecture-overview]] ## Теги #decision-record #security #jwt #auth #architecture