fix(server): remove all avatarType references after DB column drop

This commit is contained in:
Kirill
2026-05-22 11:36:11 +05:00
parent c3e4f5bdd2
commit bb7b40ac45
7 changed files with 2117 additions and 20 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,300 @@
# 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 у всех) — колонка уже есть, миграция БД не нужна, просто начинаем использовать.