Files
specs/openspec/changes/archive/2026-08-13-add-authentication/design.md
T
2026-08-13 15:04:12 +03:00

11 KiB
Raw Blame History

Context

См. мотивацию в proposal.md и поведение в specs/user-authentication/spec.md. Baseline содержит пустую Prisma schema, NestJS с глобальным prefix /api/v1, OpenAPI и общим error filter, а также React Router, React Query, Mantine и typed OpenAPI client. Текущие auth-страницы и /settings являются placeholder-экранами.

Goals / Non-Goals

Goals:

  • Создать user-scoped SQLite-модель, отзываемые refresh-сессии и API-контракт авторизации.
  • Обеспечить короткоживущий access JWT без долгоживущего секрета в JavaScript.
  • Сделать frontend-сессию восстанавливаемой, маршруты защищёнными и формы доступными во время обычной и ошибочной отправки.
  • Заменить baseline error envelope единым минимальным RFC 9457 Problem Details контрактом.

Non-Goals:

  • Email verification, password recovery, смена email или пароля, удаление User, список устройств и logout всех устройств.
  • Server-side отзыв access JWT, rate limiting login, ключевая rotation JWT, refresh grace-period и серверная защита от параллельных refresh-запросов.
  • Production deployment, публичный docs endpoint для Problem Details или отдельный cross-site CSRF механизм.

Decisions

Модель User и Session

Prisma добавляет User и Session с UUID string primary keys, генерируемыми приложением. User содержит уникальный нормализованный email, bcrypt password hash и relation к Session. Session содержит user id, SHA-256 hash текущего refresh JWT, expiresAt, revokedAt, timestamps. Истёкшие записи удаляются лениво при обработке refresh; ограничение числа активных Session отсутствует.

Session хранит hash, а не refresh JWT: JWT остаётся bearer-secret только в cookie. SHA-256 достаточен для высокоэнтропийного JWT и сохраняет простое сравнение. Пароль хешируется асинхронным bcrypt cost 12; DTO ограничивает исходный пароль 12--72 UTF-8 байт, исключая тихое bcrypt-усечение. Неизвестный email проходит сравнение с заранее подготовленным bcrypt dummy hash, чтобы вход не раскрывал существование User по времени.

Альтернативы: один refresh hash у User запрещает независимые устройства; хранение raw refresh token раскрывает секрет БД; bcrypt для refresh token не нужен; sequence IDs допускают угадывание публичного идентификатора.

JWT_SECRET обязателен, имеет минимум 32 символа и используется для HS256 access и refresh JWT. Access payload содержит sub, sid, type, iat, exp; refresh добавляет уникальный jti, чтобы два refresh в одну секунду не получили идентичный token. Access живёт 15 минут, refresh 7 дней. Refresh включает Session.id; при refresh backend загружает active Session, сверяет hash, подписывает новый refresh JWT и перезаписывает hash и expiry. Если JWT, Session или hash невалидны, backend удаляет cookie и отвечает 401 UNAUTHORIZED; если hash не совпал у ещё активной Session, она отзывается как reuse.

Cookie называется refresh_token, имеет httpOnly, sameSite: 'lax', path /api/v1/auth, без Domain; secure включён вне development. Logout идемпотентно очищает cookie и отзывает Session только при валидном cookie. Access JWT не сверяется с Session, поэтому после logout остаётся допустимым до 15 минут согласно принятой policy.

Альтернативы: refresh JWT в localStorage раскрывает его XSS; один access-only JWT не даёт бесшовного восстановления; DB lookup access token на каждом запросе снимает 15-минутное окно, но добавляет SQLite-зависимость всем защищённым запросам.

NestJS auth boundary

AuthModule инкапсулирует registration, login, refresh, logout, текущего пользователя, DTO и cookie lifecycle. @nestjs/passport с passport-jwt используется только для access JWT: strategy извлекает RFC 6750 Bearer token, ограничивает алгоритм HS256, проверяет expiry и принимает только claim type: 'access'. JwtAuthGuard защищает GET /auth/me и будущие прикладные controllers; refresh/logout читают cookie без access guard. Passport local strategy не нужна, поскольку credential verification является одной операцией AuthService и не применяется к нескольким endpoints.

Все baseline non-auth API удаляются, чтобы /auth оставался единственной публичной API boundary до будущих защищённых capabilities. cookie-parser подключается до controllers. CORS включает credentials: true только для валидированного FRONTEND_ORIGIN.

Problem Details contract

Глобальный filter заменяет baseline envelope на application/problem+json. Каждый ответ содержит { status, code }; custom auth exceptions добавляют code EMAIL_ALREADY_REGISTERED, INVALID_CREDENTIALS или UNAUTHORIZED. ValidationPipe({ transform: true, whitelist: true }) запускает class-validator DTO; filter превращает его ошибки в 400 VALIDATION_ERROR и errors: [{ pointer, detail }]. Остальные HTTP ошибки получают status-derived code BAD_REQUEST, NOT_FOUND, UNSUPPORTED_MEDIA_TYPE или INTERNAL_SERVER_ERROR и не раскрывают internal detail.

RFC 9457 допускает не передавать type, title, detail и instance; это оставляет контракт минимальным. Поле code и errors являются extension members, которые frontend использует без парсинга текста.

Frontend session and transport

Новая FSD feature auth владеет in-memory access token и profile. При старте она вызывает POST /auth/refresh: 401 UNAUTHORIZED означает anonymous state, network/5xx показывает retryable startup error. Typed client всегда использует credentials: 'include'; authenticated request добавляет Bearer access token. При UNAUTHORIZED защищённый request запускает один refresh, повторяет request ровно раз и при неуспехе очищает feature state и QueryClient cache.

Refresh в одной вкладке и между вкладками сериализуется single-flight promise и navigator.locks (Web Locks API). Это предотвращает штатное повторное использование cookie при одновременном истечении access token. Backend намеренно не имеет conditional update или grace-period: гонки нескольких независимых API-клиентов остаются ограничением MVP.

Альтернативы: localStorage переживает reload, но нарушает границу доступа JS к refresh secret; отдельный GET /me на startup дублирует payload refresh; server grace-period усложняет обнаружение reuse.

Forms, routing and UI

Auth forms следуют паттерну references/frontend-ai-template: react-hook-form, zod, zodResolver, FormProvider и отдельное представление. Shared customEmail() возвращает декларативный Zod string schema с согласованным email правилом; не расширяет mutable namespace z. Register включает client-only passwordConfirmation с object-level проверкой; API получает только email и password. Во время submit view получает isPending и отключает <fieldset>, submit и навигационные ссылки, сохраняя значения на network error.

Router ждёт startup session restore до public/protected branching. Все маршруты, кроме /login и /register, требуют authenticated state. Original internal pathname + search сохраняется как return path; auth-path и невалидный путь заменяются /accounts. Authenticated user с /login или /register перенаправляется на /accounts. /settings заменяет placeholder на logout action.

Risks / Trade-offs

  • Refresh races из независимых клиентов могут отозвать Session → Web Locks покрывает вкладки; conditional update и grace-period добавляются только при появлении такого клиента.
  • Украденный access JWT действует до 15 минут после logout → короткий TTL является принятой policy; DB-backed access revocation потребует отдельного change.
  • Нет rate limit и email verification → локальный MVP не является готовым для публичного доступа; добавить их до deployment.
  • wealthledger.local и публичная документация Problem Details не добавляются → code остаётся единственным стабильным client discriminator до deployment.

Migration Plan

  1. Добавить зависимости, environment validation и Prisma migration для User/Session.
  2. Добавить AuthModule, Passport strategy, global validation/Problem Details и сгенерировать OpenAPI.
  3. Запустить backend, regenerate typed frontend API client из /api/docs-json.
  4. Добавить frontend auth feature, формы, router guards, startup restore и settings logout.
  5. Выполнить минимальные backend flow и frontend form/router tests, затем lint, typecheck, build и OpenSpec validation.

Rollback до release заключается в откате приложения и Prisma migration; MVP содержит только новые таблицы без существующих пользовательских данных.