feat: add authorization
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-12
|
||||
@@ -0,0 +1,79 @@
|
||||
## 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 содержит только новые таблицы без существующих пользовательских данных.
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
После baseline приложение не различает пользователей и не может безопасно предоставить личные данные или подключение к T-Bank. Авторизация нужна как первая пользовательская возможность и как граница владения всеми последующими данными.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Добавить свободную регистрацию и вход по нормализованному email и паролю не короче 12 символов.
|
||||
- Добавить access JWT в ответе API и rotating refresh JWT в `httpOnly`, `SameSite=Lax` cookie, а также refresh, logout и получение текущего профиля.
|
||||
- Добавить защищенные backend-маршруты и frontend-маршрутизацию с восстановлением сессии.
|
||||
- Добавить страницы входа и регистрации без восстановления или смены пароля.
|
||||
- Зафиксировать фактический пользовательский поток авторизации в feature-документации.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `user-authentication`: Регистрация, вход, refresh-сессия, logout и текущий профиль пользователя.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
Нет.
|
||||
|
||||
## Impact
|
||||
|
||||
- Затрагивает SQLite schema и Prisma migrations, NestJS auth API/OpenAPI, frontend API-клиент, маршруты и session state.
|
||||
- Добавляет зависимости для JWT, bcrypt и cookie parsing.
|
||||
- Создает user-scoped контекст для будущих T-Bank токенов, счетов и портфелей.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
## Purpose
|
||||
|
||||
Позволяет пользователю создать и восстановить личную сессию, чтобы его данные и будущие подключения к брокеру были доступны только ему.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Регистрация пользователя
|
||||
Система SHALL позволять зарегистрировать User по уникальному нормализованному email и паролю. Email MUST нормализоваться удалением внешних пробелов и приведением к нижнему регистру; он MUST содержать не более 254 символов, ровно один `@`, непустые local и domain части и не содержать пробельных или управляющих символов. Пароль MUST передаваться без изменения, содержать от 12 до 72 UTF-8 байтов и не иметь требований к составу символов. Успешная регистрация MUST создать первую Session и вернуть профиль `{ id, email }` и access token.
|
||||
|
||||
#### Scenario: Успешная регистрация
|
||||
- **WHEN** пользователь отправляет новый email и валидный пароль
|
||||
- **THEN** система создаёт User и Session, устанавливает refresh-cookie и возвращает access token с нормализованным профилем
|
||||
|
||||
#### Scenario: Нормализация email при регистрации
|
||||
- **WHEN** пользователь регистрирует ` Anna@Example.com `
|
||||
- **THEN** система сохраняет и возвращает email `anna@example.com`
|
||||
|
||||
#### Scenario: Повторная регистрация
|
||||
- **WHEN** пользователь регистрирует email, уже принадлежащий User после нормализации
|
||||
- **THEN** система отвечает `409` Problem Details с code `EMAIL_ALREADY_REGISTERED`
|
||||
|
||||
#### Scenario: Невалидные регистрационные данные
|
||||
- **WHEN** пользователь отправляет email или пароль, не соответствующий правилам регистрации
|
||||
- **THEN** система отвечает `400` Problem Details с code `VALIDATION_ERROR` и указателями на невалидные поля
|
||||
|
||||
### Requirement: Вход пользователя
|
||||
Система SHALL позволять User начать новую независимую Session по нормализованному email и паролю. Успешный вход MUST вернуть `{ id, email }`, access token и установить refresh-cookie. Неверный пароль и неизвестный email MUST иметь одинаковый ответ, не раскрывающий наличие User.
|
||||
|
||||
#### Scenario: Успешный вход
|
||||
- **WHEN** пользователь отправляет зарегистрированный email и правильный пароль
|
||||
- **THEN** система создаёт новую Session, устанавливает refresh-cookie и возвращает access token с профилем
|
||||
|
||||
#### Scenario: Неверные учётные данные
|
||||
- **WHEN** пользователь отправляет неизвестный email или неверный пароль
|
||||
- **THEN** система отвечает `401` Problem Details с code `INVALID_CREDENTIALS`
|
||||
|
||||
### Requirement: Access и refresh сессии
|
||||
Система SHALL выдавать access token сроком 15 минут и refresh token сроком 7 дней. Access token MUST передаваться клиенту только в ответе API и использоваться как Bearer token. Refresh token MUST передаваться только в `httpOnly`, `SameSite=Lax`, host-only cookie `refresh_token` с path `/api/v1/auth`; в HTTPS-среде cookie MUST иметь атрибут `Secure`. Каждый новый refresh token MUST иметь уникальный JWT ID и ротироваться при успешном обновлении Session; Session MUST оставаться активной 7 дней после последнего успешного обновления. Одновременные Session одного User MUST быть независимы и не иметь числового лимита.
|
||||
|
||||
#### Scenario: Успешное восстановление сессии
|
||||
- **WHEN** клиент отправляет действительный refresh-cookie
|
||||
- **THEN** система заменяет refresh-cookie и возвращает новый access token и профиль Session
|
||||
|
||||
#### Scenario: Истёкшая сессия
|
||||
- **WHEN** клиент отправляет refresh-cookie Session, не обновлявшейся более 7 дней
|
||||
- **THEN** система удаляет refresh-cookie и отвечает `401` Problem Details с code `UNAUTHORIZED`
|
||||
|
||||
#### Scenario: Повторное использование refresh token
|
||||
- **WHEN** клиент отправляет refresh token, заменённый предыдущим успешным refresh
|
||||
- **THEN** система отзывает только связанную Session, удаляет refresh-cookie и отвечает `401` Problem Details с code `UNAUTHORIZED`
|
||||
|
||||
### Requirement: Завершение сессии
|
||||
Система SHALL позволять завершить текущую Session через logout. Logout MUST отозвать Session, если refresh-cookie соответствует активной Session, и MUST удалить refresh-cookie. Logout MUST быть идемпотентным и не раскрывать существование Session.
|
||||
|
||||
#### Scenario: Logout действующей сессии
|
||||
- **WHEN** клиент вызывает logout с действительным refresh-cookie
|
||||
- **THEN** система отзывает текущую Session, удаляет refresh-cookie и отвечает `204`
|
||||
|
||||
#### Scenario: Logout без refresh-cookie
|
||||
- **WHEN** клиент вызывает logout без refresh-cookie или с невалидным refresh-cookie
|
||||
- **THEN** система удаляет refresh-cookie и отвечает `204`
|
||||
|
||||
### Requirement: Доступ к текущему пользователю и защищённым API
|
||||
Система SHALL предоставлять `GET /api/v1/auth/me`, возвращающий `{ id, email }` текущего User. Этот маршрут и все прикладные API вне `/api/v1/auth` MUST требовать действительный неистёкший access token. Refresh token MUST NOT предоставлять доступ к защищённому API. Logout и refresh MUST NOT требовать access token.
|
||||
|
||||
#### Scenario: Получение текущего профиля
|
||||
- **WHEN** User вызывает `GET /api/v1/auth/me` с действительным access token
|
||||
- **THEN** система возвращает `{ id, email }` этого User
|
||||
|
||||
#### Scenario: Запрос без access token
|
||||
- **WHEN** клиент вызывает защищённый API без действительного access token
|
||||
- **THEN** система отвечает `401` Problem Details с code `UNAUTHORIZED`
|
||||
|
||||
#### Scenario: Refresh token как Bearer token
|
||||
- **WHEN** клиент передаёт refresh token в Authorization header защищённого API
|
||||
- **THEN** система отвечает `401` Problem Details с code `UNAUTHORIZED`
|
||||
|
||||
### Requirement: Стандартные ответы об ошибках
|
||||
Система SHALL возвращать все API-ошибки как `application/problem+json`. Каждый Problem Details MUST содержать поле `status` и extension `code`. Ошибки валидации MUST использовать `400` и code `VALIDATION_ERROR`, а также extension `errors` как массив объектов `{ pointer, detail }`, где pointer является JSON Pointer поля запроса. Ошибки, не требующие специальной обработки клиентом, MUST использовать один из code `BAD_REQUEST`, `NOT_FOUND`, `UNSUPPORTED_MEDIA_TYPE` или `INTERNAL_SERVER_ERROR` согласно HTTP status.
|
||||
|
||||
#### Scenario: Ошибка DTO-валидации
|
||||
- **WHEN** API получает тело запроса, не проходящее валидацию
|
||||
- **THEN** API возвращает `application/problem+json` с `status` 400, code `VALIDATION_ERROR` и `errors` для невалидных полей
|
||||
|
||||
#### Scenario: Внутренняя ошибка
|
||||
- **WHEN** API сталкивается с необработанной внутренней ошибкой
|
||||
- **THEN** API возвращает `application/problem+json` с `status` 500 и code `INTERNAL_SERVER_ERROR` без внутренних деталей
|
||||
|
||||
### Requirement: Клиентский поток аутентификации
|
||||
Клиент SHALL предоставлять формы входа и регистрации. Обе формы MUST валидировать email и пароль до отправки; форма регистрации MUST дополнительно требовать совпадающее подтверждение пароля, не передавая его API. Все поля, ссылки и submit MUST быть отключены во время отправки. Клиент MUST хранить access token и профиль только в памяти, восстанавливать их вызовом refresh при старте и передавать cookie при API-вызовах.
|
||||
|
||||
#### Scenario: Несовпадающее подтверждение пароля
|
||||
- **WHEN** пользователь отправляет регистрацию с несовпадающим подтверждением пароля
|
||||
- **THEN** клиент показывает ошибку поля и не вызывает API
|
||||
|
||||
#### Scenario: Восстановление после перезагрузки
|
||||
- **WHEN** приложение запускается с действительным refresh-cookie
|
||||
- **THEN** клиент получает новый access token и профиль до отображения защищённого маршрута
|
||||
|
||||
#### Scenario: Сбой отправки формы
|
||||
- **WHEN** login или registration API недоступен или возвращает неожиданную ошибку
|
||||
- **THEN** клиент завершает pending-состояние, сохраняет введённые поля и показывает локализованную общую ошибку
|
||||
|
||||
### Requirement: Защищённая клиентская навигация
|
||||
Клиент SHALL ограничивать все маршруты, кроме `/login` и `/register`, активной access-сессией. Неавторизованного пользователя MUST перенаправлять на login и после успешной аутентификации MUST возвращать на исходный внутренний pathname и search либо на `/accounts`. Авторизованного пользователя на auth-маршруте MUST перенаправлять на `/accounts`. При `UNAUTHORIZED` от защищённого API клиент MUST выполнить один сериализованный refresh, повторить исходный запрос один раз и при неуспехе очистить сессию и cache перед переходом на login.
|
||||
|
||||
#### Scenario: Возврат на исходный маршрут
|
||||
- **WHEN** неавторизованный пользователь открывает `/accounts/123?tab=positions` и затем успешно входит
|
||||
- **THEN** клиент направляет его на `/accounts/123?tab=positions`
|
||||
|
||||
#### Scenario: Потеря сессии при защищённом запросе
|
||||
- **WHEN** защищённый API отвечает `401` с code `UNAUTHORIZED` и refresh не восстанавливает сессию
|
||||
- **THEN** клиент очищает access token, профиль и cache, затем направляет пользователя на `/login`
|
||||
|
||||
#### Scenario: Выход из настроек
|
||||
- **WHEN** пользователь нажимает logout на `/settings`
|
||||
- **THEN** клиент очищает локальную сессию и cache и направляет пользователя на `/login` независимо от результата сетевого запроса
|
||||
@@ -0,0 +1,29 @@
|
||||
## 1. Backend foundation
|
||||
|
||||
- [x] 1.1 Добавить зависимости bcrypt, JWT, Passport JWT и cookie parsing; валидировать `JWT_SECRET`; настроить credentialed CORS, cookie parser и глобальную валидацию.
|
||||
- [x] 1.2 Добавить Prisma-модели User и Session с миграцией для нормализованного email, password hash и состояния refresh-сессии.
|
||||
- [x] 1.3 Заменить глобальный error filter и OpenAPI-схемы ошибок минимальным контрактом `application/problem+json`; удалить baseline hello-world API.
|
||||
|
||||
## 2. Authentication API
|
||||
|
||||
- [x] 2.1 Реализовать в AuthModule DTO-валидацию, нормализацию email, bcrypt-обработку пароля и регистрацию/login с созданием независимой Session.
|
||||
- [x] 2.2 Реализовать выпуск HS256 access/refresh JWT, refresh rotation/reuse revocation, жизненный цикл cookie и идемпотентный logout.
|
||||
- [x] 2.3 Добавить Passport JWT strategy и guard для access token, затем задокументировать endpoints `/auth/register`, `/auth/login`, `/auth/refresh`, `/auth/logout` и `/auth/me`.
|
||||
- [x] 2.4 Добавить минимальный backend auth-flow test: конфликт регистрации, неверные учётные данные, refresh reuse, logout и защищённый current profile.
|
||||
|
||||
## 3. Generated API contract
|
||||
|
||||
- [x] 3.1 Запустить backend и перегенерировать коммитируемые frontend OpenAPI-типы из `/api/docs-json`.
|
||||
|
||||
## 4. Frontend authentication
|
||||
|
||||
- [x] 4.1 Добавить зависимости react-hook-form, Zod и resolver; создать shared custom email validator и схемы auth-форм.
|
||||
- [x] 4.2 Реализовать auth feature с in-memory session state, credentialed typed requests, startup refresh, сериализованным refresh/retry и очисткой cache.
|
||||
- [x] 4.3 Заменить login и registration placeholders формами, отключаемыми во время pending, с client-only подтверждением пароля и локализованными ошибками.
|
||||
- [x] 4.4 Добавить учитывающую сессию public/protected маршрутизацию с безопасными return paths и заменить settings placeholder на logout.
|
||||
- [x] 4.5 Добавить минимальные frontend-тесты для подтверждения пароля при регистрации и redirect защищённого маршрута.
|
||||
|
||||
## 5. Documentation and verification
|
||||
|
||||
- [x] 5.1 Задокументировать реализованный пользовательский поток authentication и MVP-ограничения в `docs/features/authentication.md`.
|
||||
- [x] 5.2 Запустить backend lint, tests и build; frontend lint, typecheck, tests, build и no-todo check; провалидировать OpenSpec change.
|
||||
Reference in New Issue
Block a user