Files
shop-server/docs/superpowers/specs/2026-05-22-auth-redesign-design.md
T

14 KiB
Raw Blame History

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. Миграции

  1. Удаление колонки avatarType из User
  2. Миграция данных: для всех пользователей с 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 до @

Логика:

  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:

{
  "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.

Логика:

  1. Нормализация email
  2. Поиск пользователя по email
  3. Если пользователь не найден ИЛИ passwordHash === null401 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:

{
  "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_EMAIL403
  2. Если user.passwordHash !== null409 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, ни других OAuthAccount400 Cannot remove last auth method
  4. Возврат 200

Примечание: отвязывание пароля (установка passwordHash = null) пока не делаем — можно добавить позже.

4.6. Админ и связывание

  • POST /api/me/password403 для админа
  • OAuth-привязка через /link403 для админа
  • 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 у всех) — колонка уже есть, миграция БД не нужна, просто начинаем использовать.