# Auth Redesign — Spec **Date:** 2026-05-22 **Goal:** Переработать систему аутентификации: OAuth запрашивает только email, убрать внешние аватары, добавить вход по email+паролю, дать пользователям связывать методы входа в ЛК. --- ## 1. Data Model (Prisma) ### 1.1. Модель `User` — изменения | Поле | Было | Стало | |------|------|-------| | `passwordHash` | `String?` (не использовалось) | Задействуем. Хранит bcrypt-хеш. `null` если пароль не установлен. | | `avatarType` | `String?` (`'oauth'` / `'generated'`) | **Удалить.** Все аватары внутренние (DiceBear). | | `avatar` | `String?` (URL или data:uri) | Только DiceBear URL или `null` (генерируется на лету) | | `avatarStyle` | `String?` | Без изменений. | Остальные поля (`id`, `email`, `displayName`, `firstName`, `lastName`, `gender`, `createdAt`, `updatedAt`) — без изменений. `firstName`, `lastName`, `gender` больше не заполняются при OAuth, но остаются в БД (могут быть заполнены пользователем вручную позже). ### 1.2. Модель `OAuthAccount` — без изменений ```prisma model OAuthAccount { id String @id @default(cuid()) provider String // 'vk' | 'yandex' providerUserId String userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) accessToken String? refreshToken String? // зарезервировано, не используется сейчас createdAt DateTime @default(now()) @@unique([provider, providerUserId]) } ``` ### 1.3. Модель `AuthCode` — без изменений ### 1.4. Миграции 1. Удаление колонки `avatarType` из `User` 2. Миграция данных: для всех пользователей с `avatarType = 'oauth'` установить `avatar = null` (внешние URL больше не используются, аватар перегенерируется DiceBear) --- ## 2. Авторизация по email+паролю ### 2.1. Регистрация `POST /api/auth/register` (новый, без аутентификации) **Request:** ```json { "email": "user@example.com", "password": "Abcdef1!", "displayName": "Иван" // optional } ``` **Валидация:** - `email`: валидный email, нормализация (trim + lowercase), уникальность - `password`: минимум 8 символов, минимум 1 буква, 1 цифра, 1 спецсимвол - `displayName`: опционально, строка до 100 символов. Если не передан — берётся часть email до `@` **Логика:** 1. Проверка, что email не занят → 409 если занят 2. `passwordHash = await bcrypt.hash(password, 10)` 3. Создание пользователя: `email`, `passwordHash`, `displayName`, `avatar = null`, `avatarStyle = 'avataaars'` 4. Создание `NotificationPreference` (как сейчас в verify-code) 5. Возврат JWT + user **Response 201:** ```json { "token": "jwt...", "user": { "id", "email", "displayName", "avatar", "avatarStyle", "isAdmin": false } } ``` ### 2.2. Вход `POST /api/auth/login` (новый, без аутентификации) **Request:** ```json { "email": "user@example.com", "password": "Abcdef1!" } ``` **Rate limit:** максимум 5 попыток в минуту с одного IP (использовать `@fastify/rate-limit`). При превышении — `429 Too Many Requests`. **Логика:** 1. Нормализация email 2. Поиск пользователя по email 3. Если пользователь не найден ИЛИ `passwordHash === null` → `401 Invalid email or password` (одинаковый ответ для безопасности) 4. `await bcrypt.compare(password, user.passwordHash)` → если не совпадает → `401` 5. Возврат JWT + user ### 2.3. Админ и пароль - Админ (`email === ADMIN_EMAIL`) **не может** зарегистрироваться или войти по паролю - `POST /api/auth/register` и `POST /api/auth/login` возвращают `403` для админского email - Админ также **не может** установить пароль через `POST /api/me/password` --- ## 3. OAuth (только email) ### 3.1. Scope | Провайдер | Было | Стало | |-----------|------|-------| | VK | `email` | `email` (без изменений, но больше не запрашиваем профиль) | | Яндекс | `login:email login:info` | `login:email` | ### 3.2. Callback — что убираем **VK:** - Больше не делаем `users.get` после получения токена - Не сохраняем: `first_name`, `last_name`, `photo_200`, `sex` **Яндекс:** - Всё ещё вызываем `GET https://login.yandex.ru/info` (нужен для получения email) - Из ответа берём только `default_email` или первый из `emails` - **Не сохраняем:** `first_name`, `last_name`, `display_name`, `sex`, `default_avatar_id` ### 3.3. Callback — новая логика ``` 1. Обмен code на access_token (как сейчас) 2. Извлечение email из ответа провайдера: - VK: поле `email` в ответе access_token - Яндекс: вызываем `/info`, из ответа берём `default_email` или первый из `emails` 3. Если email отсутствует → редирект с ?oauthError=no_email 4. Нормализация email 5. Поиск пользователя по email: a) Найден → привязываем OAuthAccount (если ещё не привязан), возвращаем JWT b) Не найден → создаём нового: - email - displayName = часть email до @ - avatar = null - avatarStyle = 'avataaars' - Создаём OAuthAccount - Создаём NotificationPreference - Возвращаем JWT 6. Редирект на CLIENT_PUBLIC_URL/auth/callback?token=... ``` **Fallback-email `{provider}_{id}@oauth.craftshop.local` — убираем.** Если провайдер не дал email — ошибка. ### 3.4. State-параметр Без изменений. JWT с `expiresIn: 15m` для CSRF-защиты. --- ## 4. Связывание аккаунтов ### 4.1. Авто-связывание При OAuth-входе: если email из OAuth совпадает с email существующего пользователя — автоматически создаётся `OAuthAccount`, связывающий провайдера с пользователем. Вход происходит мгновенно. ### 4.2. Ручное связывание — страница настроек `/me` **Получение статуса методов:** `GET /api/me/auth-methods` (новый, требует `authenticate`) **Response:** ```json { "methods": [ { "type": "password", "active": true }, { "type": "vk", "active": false }, { "type": "yandex", "active": true } ] } ``` Логика: - `type: "password"` — `active: user.passwordHash !== null` - `type: "vk"` / `type: "yandex"` — `active: exists(OAuthAccount)` ### 4.3. Привязка OAuth `GET /api/auth/oauth/{provider}/link` (новый, требует `authenticate`) 1. Генерирует state-JWT с `{ userId, provider, action: 'link' }`, `expiresIn: 15m` 2. Редиректит на страницу авторизации провайдера 3. После callback проверяет `action: 'link'` в state 4. Создаёт `OAuthAccount` для указанного `userId` (нормальный upsert) 5. Редиректит на `/me?linked={provider}` ### 4.4. Привязка пароля `POST /api/me/password` (новый, требует `authenticate`) **Request:** `{ "password": "Abcdef1!" }` **Логика:** 1. Если `user.email === ADMIN_EMAIL` → `403` 2. Если `user.passwordHash !== null` → `409 Password already set` (для смены использовать отдельный метод) 3. Валидация пароля (8+, буква+цифра+спецсимвол) 4. `user.passwordHash = await bcrypt.hash(password, 10)` 5. Сохранение пользователя ### 4.5. Отвязывание `DELETE /api/me/oauth/{provider}` (новый, требует `authenticate`) **Логика:** 1. Удаление `OAuthAccount` по `userId + provider` 2. Если `OAuthAccount` не найден → `404` 3. **Проверка последнего метода:** после удаления, если у пользователя нет ни `passwordHash`, ни других `OAuthAccount` → `400 Cannot remove last auth method` 4. Возврат `200` **Примечание:** отвязывание пароля (установка `passwordHash = null`) пока не делаем — можно добавить позже. ### 4.6. Админ и связывание - `POST /api/me/password` → `403` для админа - OAuth-привязка через `/link` → `403` для админа - OAuth-отвязывание → `403` для админа --- ## 5. Email-код (без изменений логики) `POST /api/auth/request-code` и `POST /api/auth/verify-code` работают как раньше, изменений нет. Админ входит только этим способом. --- ## 6. Изменения на клиенте ### 6.1. Страница `/auth` **3 вкладки:** - **«Пароль»** — переключатель Вход/Регистрация. Вход: email + пароль. Регистрация: email + пароль + подтверждение пароля + имя (опционально). - **«Код»** — как сейчас: email → отправить код → ввести код. - **«Другой способ»** — кнопки Войти через VK / Яндекс. ### 6.2. Страница `/me` (настройки) Новая секция «Методы входа»: - Список методов с индикаторами «привязан» / «не привязан» - Кнопки «Привязать» (редирект на OAuth или форма пароля) - Кнопки «Отвязать» (disabled если это последний метод) - Для админа — секция скрыта ### 6.3. Effector-стейт - `$token`, `$user`, `tokenSet`, `logout` — без изменений - Добавить эффекты: `loginFx`, `registerFx`, `linkOAuthFx`, `setPasswordFx`, `unlinkOAuthFx` ### 6.4. Компоненты - `UserAvatar` — убрать проверку `avatarType`, всегда использовать DiceBear (сохранённый `avatar` или генерация на лету) - `OAuthButtons` — без изменений (URL те же) --- ## 7. Тестирование ### 7.1. Серверные тесты - `POST /api/auth/register` — успешная регистрация, дубликат email, слабый пароль - `POST /api/auth/login` — успешный вход, неверный пароль, несуществующий email, превышение rate limit - OAuth callback — создание нового пользователя с email, авто-связывание по email, ошибка при отсутствии email от провайдера - `POST /api/me/password` — установка, повторная установка (409), админ (403) - `GET /api/me/auth-methods` — корректный список методов - `DELETE /api/me/oauth/{provider}` — отвязывание, последний метод (400), админ (403) - Админ не может войти через `/login` (403) ### 7.2. Клиентские тесты - Страница `/auth` — наличие трёх вкладок, переключение - Форма регистрации — валидация пароля, подтверждение - Форма входа — обработка ошибок - `/me` — отображение методов, кнопки привязки/отвязки --- ## 8. Миграция существующих пользователей 1. Все пользователи с `avatarType = 'oauth'`: `avatar = null`, `avatarType = null`. Аватар перегенерируется DiceBear при следующем отображении. 2. `avatarType` колонка удаляется из БД. 3. Существующие OAuth-аккаунты работают как раньше, но при следующем входе через OAuth обновляется логика (не запрашиваем профиль). 4. `firstName`, `lastName`, `gender` у существующих OAuth-пользователей остаются в БД (не удаляем, просто больше не пополняем из OAuth). --- ## 9. Заметки - **bcrypt** — не установлен, нужно добавить `npm install bcrypt` в server. - **Rate limit** — `@fastify/rate-limit` не установлен. Добавить или реализовать самодельный in-memory rate limiter (5 попыток/мин/IP). - Все новые эндпоинты должны валидироваться через JSON Schema (как существующие). - Пароль никогда не возвращается в ответах API и не логируется. - Существующий `User.passwordHash` (null у всех) — колонка уже есть, миграция БД не нужна, просто начинаем использовать.