14 KiB
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 — без изменений
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. Миграции
- Удаление колонки
avatarTypeизUser - Миграция данных: для всех пользователей с
avatarType = 'oauth'установитьavatar = null(внешние URL больше не используются, аватар перегенерируется DiceBear)
2. Авторизация по email+паролю
2.1. Регистрация
POST /api/auth/register (новый, без аутентификации)
Request:
{
"email": "user@example.com",
"password": "Abcdef1!",
"displayName": "Иван" // optional
}
Валидация:
email: валидный email, нормализация (trim + lowercase), уникальностьpassword: минимум 8 символов, минимум 1 буква, 1 цифра, 1 спецсимволdisplayName: опционально, строка до 100 символов. Если не передан — берётся часть email до@
Логика:
- Проверка, что email не занят → 409 если занят
passwordHash = await bcrypt.hash(password, 10)- Создание пользователя:
email,passwordHash,displayName,avatar = null,avatarStyle = 'avataaars' - Создание
NotificationPreference(как сейчас в verify-code) - Возврат JWT + user
Response 201:
{
"token": "jwt...",
"user": { "id", "email", "displayName", "avatar", "avatarStyle", "isAdmin": false }
}
2.2. Вход
POST /api/auth/login (новый, без аутентификации)
Request:
{
"email": "user@example.com",
"password": "Abcdef1!"
}
Rate limit: максимум 5 попыток в минуту с одного IP (использовать @fastify/rate-limit). При превышении — 429 Too Many Requests.
Логика:
- Нормализация email
- Поиск пользователя по email
- Если пользователь не найден ИЛИ
passwordHash === null→401 Invalid email or password(одинаковый ответ для безопасности) await bcrypt.compare(password, user.passwordHash)→ если не совпадает →401- Возврат 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:
{
"methods": [
{ "type": "password", "active": true },
{ "type": "vk", "active": false },
{ "type": "yandex", "active": true }
]
}
Логика:
type: "password"—active: user.passwordHash !== nulltype: "vk"/type: "yandex"—active: exists(OAuthAccount)
4.3. Привязка OAuth
GET /api/auth/oauth/{provider}/link (новый, требует authenticate)
- Генерирует state-JWT с
{ userId, provider, action: 'link' },expiresIn: 15m - Редиректит на страницу авторизации провайдера
- После callback проверяет
action: 'link'в state - Создаёт
OAuthAccountдля указанногоuserId(нормальный upsert) - Редиректит на
/me?linked={provider}
4.4. Привязка пароля
POST /api/me/password (новый, требует authenticate)
Request: { "password": "Abcdef1!" }
Логика:
- Если
user.email === ADMIN_EMAIL→403 - Если
user.passwordHash !== null→409 Password already set(для смены использовать отдельный метод) - Валидация пароля (8+, буква+цифра+спецсимвол)
user.passwordHash = await bcrypt.hash(password, 10)- Сохранение пользователя
4.5. Отвязывание
DELETE /api/me/oauth/{provider} (новый, требует authenticate)
Логика:
- Удаление
OAuthAccountпоuserId + provider - Если
OAuthAccountне найден →404 - Проверка последнего метода: после удаления, если у пользователя нет ни
passwordHash, ни другихOAuthAccount→400 Cannot remove last auth method - Возврат
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. Миграция существующих пользователей
- Все пользователи с
avatarType = 'oauth':avatar = null,avatarType = null. Аватар перегенерируется DiceBear при следующем отображении. avatarTypeколонка удаляется из БД.- Существующие OAuth-аккаунты работают как раньше, но при следующем входе через OAuth обновляется логика (не запрашиваем профиль).
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 у всех) — колонка уже есть, миграция БД не нужна, просто начинаем использовать.