0003. Хеш пароля и стратегия refresh-токенов
Копия ADR из репозитория:
backend/docs/adr/0003-password-hash-and-refresh-rotation.md. Канонический текст — в репозитории; портал обновляется командойnpm run sync:adr.
- Статус: accepted
- Дата: 2026-05-29
- Связанные задачи: OP-28 (F2 · Auth)
Контекст и проблема
OP-28 закладывает базовую аутентификацию: регистрация, вход, refresh, logout. Два рано фиксируемых решения определят безопасность всех будущих auth-зависимых модулей:
- Алгоритм хеширования паролей at-rest.
- Жизненный цикл refresh-токенов: одноразовый/ротация/отзыв.
Рассмотренные варианты
Хеш пароля
- bcrypt — де-факто стандарт, широкая поддержка, давно проверен. Нативная зависимость (node-gyp), не учитывает GPU-стойкость, фиксированная стоимость только по CPU.
- scrypt — встроенный в Node.js (
crypto.scrypt), без сторонних бинарников. Менее популярен в новых проектах, опции по умолчанию не оптимальны под текущее железо. - argon2id — победитель Password Hashing Competition (2015), рекомендован
OWASP с 2024 г. Учитывает память (memory-hard) — устойчив к GPU/ASIC атакам.
Зависимость
argon2использует prebuilt-биндинги (без node-gyp при наличии платформы в matrix), на macOS/linux/win устанавливается из коробки.
Refresh-токены
- Stateless JWT с длинным TTL, без отзыва — простота, но потеря refresh = потеря контроля до истечения. Невозможно «выйти со всех устройств».
- JWT + чёрный список (jti) в Redis — отзыв есть, но требует Redis-доступа на каждой проверке refresh. Большой blacklist при массовых ротациях.
- Opaque refresh-токен, хеш в БД, ротация при каждом use — токен в БД
(хеш через argon2/SHA-256), при
/auth/refreshстарый помечаетсяrevoked_at, выдаётся новый. «Выход со всех устройств» = revoke всех refresh-токенов пользователя. Compromised-detection при reuse отозванного.
Решение
Пароль — argon2id
- Параметры:
memoryCost: 2^16 (64 MiB),timeCost: 3,parallelism: 1,type: argon2id(значения из OWASP Password Storage Cheat Sheet, 2024). - Зависимость
argon2(v0.44+) — prebuilt-биндинги для всех платформ CI/CD. - Soft-migration на bcrypt НЕ предусмотрена (проект новый, нет легаси-хешей).
Refresh — rotate с отзывом в PostgreSQL
- Refresh-токен — opaque random (32 байта, base64url), хеш хранится в
refresh_tokens(sha-256, без соли — токен сам random, коллизии исключены). - Поля:
id,user_id,token_hash unique,expires_at,revoked_at NULL,created_at. Индексы:user_id,token_hash. /auth/refresh: проверяемtoken_hash+revoked_at IS NULL+expires_at > now(). При успехе —revoked_at = now()старому, выдаём новый./auth/logout:revoked_at = now()текущему refresh-токену.- «Выход со всех устройств» — отдельная Story в будущем (revoke по
user_id). - TTL по умолчанию: access
15m, refresh30d(env:JWT_ACCESS_TTL,JWT_REFRESH_TTL).
Access — JWT в стиле stateless
HS256, payload{ sub: userId, jti: uuid }, без отзыва на per-request пути (валидируется по подписи + exp). Окно компрометации — TTL access.
Последствия
- Положительные:
- argon2id — современный baseline; не нужно повышать «cost factor» в ближайшие годы под рост железа.
- Refresh в БД даёт контроль: revoke, аудит, reuse-detection (если повторно использован отозванный — алерт, отозвать все пользователю).
- Stateless access — нет нагрузки на БД/Redis на горячем пути.
- Отрицательные / компромиссы:
- Доступ к
argon2требует prebuilt-биндингов или нативной сборки; в alpine Docker может потребоватьсяpython3 make g++на этапе билда. - Каждый
/auth/refresh— write в БД (revoke + insert). Не горячий путь, ок.
- Доступ к
- Дальше (вне scope OP-28):
- OP-29 (RBAC) — встанет поверх
UserEntityиз этого тикета. - «Выход со всех устройств» — отдельная Story (revoke by user_id).
- Email-верификация, reset password — отдельные Story.
- Rate-limit на /auth/login — отдельный Task (под F8 или F2).
- OP-29 (RBAC) — встанет поверх