feat(auth): шаг 0.6 — TTL access 15 минут + хэширование refresh/reset-токенов в БД
Some checks are pending
Branch Check / Type Check & Build (push) Waiting to run

- server/utils/jwt.ts: дефолт JWT_ACCESS_EXPIRES 30d → 15m (env сохранено), hashToken() (sha256 hex)
- migrations/0082_token_hashes.sql: *_hash колонки в user_sessions/users, DELETE FROM user_sessions (глобальный разлогин), legacy plain-колонки сохранены, но не пишутся
- storage: lookup/отзыв сессий и reset-токенов по хэшу; markSessionReplaced по id сессии
- auth.service: ротация в rotateFamilySession(), grace-period ротирует активную сессию вместо возврата plain-токена
- auth.core.routes: forgot/reset-password пишут/ищут sha256-хэш, plain только в письме
- tests/token-security.test.ts: 12 тестов (выпуск по хэшу, ротация, reuse detection, grace, reset)
- .env.example, swagger, IMPLEMENTATION_LOG.md обновлены

Проверки: npm run check чисто, vitest 118/118, lint 0 errors, build собирается
This commit is contained in:
2026-09-08 11:35:32 +03:00
parent 639036c87e
commit 06bfd2aa75
11 changed files with 506 additions and 60 deletions

View File

@@ -442,3 +442,26 @@
- Как проверялось: на проде подтверждено включение (pg_class relrowsecurity+relforcerowsecurity=31), smoke через API; локально — полный цикл на копии прод-БД.
- Влияние на поиск/UX: нет.
- Подводные камни: при переносе на новый сервер — сначала ENABLE_RLS=true в .env, иначе app не поднимется (это и есть цель шага).
---
## [0.6] TTL access-токена 15 минут + хэширование refresh/reset-токенов в БД
- Статус: ⏸ частично — код готов, ждёт окно деплоя (разлогин всех)
- Зачем: украденный access-токен жил до 30 дней; refresh/reset-токены лежали в БД в открытом виде — при утечке БД атакующий получал вечный доступ (аудит, Фаза 0).
- Что изменено:
- `server/utils/jwt.ts` — дефолт `JWT_ACCESS_EXPIRES` '30d' → '15m' (env-переопределение сохранено), добавлен `hashToken()` (sha256 hex) и экспорт `ACCESS_TOKEN_EXPIRY`.
- Миграция `migrations/0082_token_hashes.sql` — колонки `refresh_token_hash` (NOT NULL, UNIQUE), `parent_refresh_token_hash`, `replaced_by_token_hash` в `user_sessions`; `reset_password_token_hash` в `users`. **`DELETE FROM user_sessions` + очистка reset-токенов — глобальный разлогин при деплое.** Legacy plain-колонки (`refresh_token`, `parent_refresh_token`, `replaced_by_token`, `reset_password_token`) НЕ удалены (отдельная миграция позже), но перестали писаться; у `refresh_token` снят NOT NULL.
- `shared/schema.ts` — новые колонки в `userSessions`/`users`; `resetPasswordTokenHash` добавлен в исключения `SafeUser`/`safeUserColumns`.
- `server/storage/users.storage.ts` + интерфейс `server/storage.ts` — все lookup/отзыв сессий (`getUserSessionByToken`, `getUserSessionByParentToken`, `revokeSession`, `deleteUserSession`) и `getUserByResetPasswordToken` ищут по `hashToken(plain)`; `createUserSession` принудительно обнуляет plain-колонки; `markSessionReplaced(sessionId, newToken)` — по id сессии (plain старого токена больше недоступен).
- `server/services/auth.service.ts` — `createSession` и ротация пишут только хэши; ротация вынесена в `rotateFamilySession()`. Grace-period (гонка вкладок, 5 мин): раньше клиенту возвращался plain-токен активной сессии из БД — теперь он не хранится, поэтому активная сессия ротируется и клиент получает новую пару (поведение сходится, сессий чуть больше). Cookie access Max-Age берётся из `ACCESS_TOKEN_EXPIRY`.
- `server/routes/auth.core.routes.ts` — forgot-password пишет `resetPasswordTokenHash` (+ `resetPasswordToken: null`), plain только в письме; reset-password очищает обе колонки.
- `.env.example`, `server/swagger.ts` — дефолты/описания приведены к факту (access 15m, refresh 30d/90d).
- `tests/token-security.test.ts` — 12 тестов: hashToken, выпуск по хэшу (plain не уходит в storage), ротация с parent-хэшем, невалидный токен, reuse detection → revokeSessionFamily, grace-ротация, logout, reset по хэшу/очистка/истёкший/невалидный.
- Как проверялось: `npm run check` чисто (оба tsc); `npx vitest run` — 118/118; `npm run lint` — 0 errors; `npm run build` — собирается. Клиент не тронут: refresh реактивный по 401 (singleton `refreshSession` в queryClient + useOfflineSync), таймеров, завязанных на 30d, нет.
- Влияние на поиск/UX: нет (только auth-контур).
- Подводные камни:
- **Деплой разлогинит всех** (миграция чистит `user_sessions`) — деплоить в окно согласованного простоя; после деплоя проверить re-login и refresh-ротацию вручную.
- JWT детерминирован по (payload, iat в секундах): две ротации одной и той же сессии в пределах одной секунды выдадут одинаковый токен — безвредно (хэш и семантика совпадают).
- `bot_sessions` (refresh-токены bot_login) сознательно не тронуты — отдельная модель audience `workflow-bots`; кандидат на аналогичное хэширование отдельным шагом. In-memory `resetTokenStore` суперадмина — не в БД, вне скоупа.
- Удаление legacy plain-колонок — отдельная миграция после подтверждения стабильности на проде.