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

80 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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
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 содержит только новые таблицы без существующих пользовательских данных.