11 KiB
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 и cookie lifecycle
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
- Добавить зависимости, environment validation и Prisma migration для User/Session.
- Добавить AuthModule, Passport strategy, global validation/Problem Details и сгенерировать OpenAPI.
- Запустить backend, regenerate typed frontend API client из
/api/docs-json. - Добавить frontend auth feature, формы, router guards, startup restore и settings logout.
- Выполнить минимальные backend flow и frontend form/router tests, затем lint, typecheck, build и OpenSpec validation.
Rollback до release заключается в откате приложения и Prisma migration; MVP содержит только новые таблицы без существующих пользовательских данных.