Skip to content

Latest commit

 

History

History
349 lines (236 loc) · 20.1 KB

File metadata and controls

349 lines (236 loc) · 20.1 KB

2FA (email / Google Authenticator) Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Добавить двухфакторную аутентификацию для входа в админку с выбором способа — код по email или TOTP (Google Authenticator). Пользователь без 2FA логинится как сейчас; с включённым 2FA после ввода пароля перенаправляется на ввод кода, затем создаётся сессия.

Architecture: Двухшаговый логин: API login-step1 проверяет пароль и при 2FA создаёт запись TwoFactorPending и cookie; страница /login/2fa отправляет код в verify-2fa; при успехе создаётся одноразовый TwoFactorGrant; клиент вызывает signIn с grantToken; Credentials provider принимает либо email+password (для пользователей без 2FA), либо grantToken (после 2FA). Настройка 2FA (включение/выбор способа/отключение) — в админке, защищённые API с проверкой сессии.

Tech Stack: NextAuth (Credentials), Prisma, otplib (TOTP), Node crypto (AES-256-GCM для секрета TOTP), существующий SMTP/nodemailer.

Design doc: docs/plans/2026-02-24-2fa-design.md


Task 1: Prisma schema — поля 2FA на User и таблицы TwoFactorPending, TwoFactorGrant

Files:

  • Modify: nextjs-project/prisma/schema.prisma (модель User и две новые модели)

Step 1: Добавить поля и модели в schema.prisma

В модели User после updatedAt добавить:

  • twoFactorEnabled Boolean @default(false)
  • twoFactorMethod String? // 'email' | 'totp'
  • totpSecretEncrypted String? В секцию relations добавить: twoFactorPending TwoFactorPending? и twoFactorGrants TwoFactorGrant[].

Добавить модели (по образцу PasswordResetToken / SetInitialPasswordToken):

model TwoFactorPending {
  id                String    @id @default(cuid())
  userId            String    @unique
  user              User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  tokenHash         String
  expiresAt         DateTime
  emailCodeHash     String?
  emailCodeExpiresAt DateTime?
  createdAt         DateTime  @default(now())
  @@index([userId])
  @@index([expiresAt])
}

model TwoFactorGrant {
  id        String    @id @default(cuid())
  userId    String
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  usedAt    DateTime?
  expiresAt DateTime
  createdAt DateTime  @default(now())
  @@index([userId])
  @@index([expiresAt])
}

Step 2: Создать миграцию

Run: cd nextjs-project && npx prisma migrate dev --name add_2fa_tables Expected: миграция создана и применена.

Step 3: Commit

git add nextjs-project/prisma/schema.prisma nextjs-project/prisma/migrations/
git commit -m "feat(2fa): add User 2FA fields and TwoFactorPending, TwoFactorGrant tables"

Task 2: Email — функция отправки кода 2FA

Files:

  • Modify: nextjs-project/src/lib/email.ts

Step 1: Добавить send2FACodeEmail

По образцу sendInitialPasswordCodeEmail: функция send2FACodeEmail(to: string, code: string): Promise<{ ok: boolean; error?: string }>. Тема письма: «Код для входа — Inner Health», текст: «Ваш код для входа: {code}. Действует 5 минут.» Использовать тот же transporter (SMTP_HOST и т.д.) и SUPPORT_FROM, если есть, иначе как в sendPasswordResetEmail.

Step 2: Commit

git add nextjs-project/src/lib/email.ts
git commit -m "feat(2fa): add send2FACodeEmail for email 2FA"

Task 3: TOTP — шифрование секрета и проверка кода

Files:

  • Create: nextjs-project/src/lib/totp.ts
  • Modify: nextjs-project/package.json (добавить зависимость otplib)

Step 1: Установить otplib

Run: cd nextjs-project && npm install otplib (Или authenticator — по предпочтению; в плане используем otplib: authenticator.generateSecret(), authenticator.verifyToken().)

Step 2: Создать nextjs-project/src/lib/totp.ts

  • Функция generateTotpSecret(): string — генерирует секрет (otplib).
  • Функция encryptTotpSecret(plainSecret: string): string — шифрует AES-256-GCM, ключ из process.env.TOTP_SECRET_ENCRYPTION_KEY (32 байта base64 или hex). Возвращать base64 ciphertext+iv+authTag.
  • Функция decryptTotpSecret(encrypted: string): string — расшифровка.
  • Функция verifyTotpCode(encryptedSecret: string, code: string): boolean — расшифровать секрет, проверить code через otplib (окно ±1 при необходимости).
  • Функция getTotpUri(secret: string, email: string, issuer: string): string — для QR (otpauth://...).

Если TOTP_SECRET_ENCRYPTION_KEY не задан — в encrypt/decrypt бросать понятную ошибку или возвращать null и проверять в вызывающем коде.

Step 3: Commit

git add nextjs-project/package.json nextjs-project/package-lock.json nextjs-project/src/lib/totp.ts
git commit -m "feat(2fa): add TOTP secret encryption and verification (otplib)"

Task 4: Cookie и хелперы для pending/grant токенов

Files:

  • Create: nextjs-project/src/lib/two-factor.ts

Step 1: Реализовать хелперы

  • Константы: TWO_FACTOR_PENDING_COOKIE = 'two_factor_pending', TTL pending 10 мин, TTL grant 2 мин.
  • createPendingToken(): { token: string; hash: string } — случайный token (crypto.randomBytes(32).toString('hex')), hash = bcrypt.hash(token).
  • verifyPendingTokenHash(plainToken: string, hash: string): Promise<boolean> — bcrypt.compare.
  • Подпись cookie: хранить в cookie значение { id: pendingRecordId } или сам token; на сервере ищем pending по userId из cookie или по id. Проще: в cookie кладём только подписанный payload { pendingId } (jwt или HMAC). Либо хранить в БД tokenHash и отдавать клиенту в cookie неподписанный id записи + подпись (HMAC). Рекомендация: cookie = base64url({ id: string }) + '.' + HMAC(id, NEXTAUTH_SECRET); при чтении проверяем подпись и ищем TwoFactorPending по id.
  • Функции: setPendingCookie(id: string, res: NextResponse) — установить httpOnly, secure, sameSite, path=/, maxAge=600. getPendingIdFromCookie(cookieHeader: string | null): string | null — разобрать cookie, проверить подпись, вернуть id.
  • createGrant(userId: string): Promise<{ grantId: string }> — создать TwoFactorGrant, вернуть id. consumeGrant(grantId: string): Promise<{ userId: string } | null> — найти по id, проверить usedAt === null и expiresAt > now, обновить usedAt, вернуть userId или null.

Step 2: Commit

git add nextjs-project/src/lib/two-factor.ts
git commit -m "feat(2fa): add two-factor cookie and grant helpers"

Task 5: API POST /api/auth/login-step1

Files:

  • Create: nextjs-project/src/app/api/auth/login-step1/route.ts

Step 1: Реализовать route

  • POST, body: { email: string, password: string } (Zod).
  • Найти user по email; проверить пароль (как в auth.ts: isBcryptHash + verifyPassword или plain).
  • Если неверно — return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }).
  • Если верно и !user.twoFactorEnabled — return NextResponse.json({ success: true }).
  • Если верно и user.twoFactorEnabled: создать или обновить TwoFactorPending (upsert по userId: tokenHash, expiresAt = now + 10 min). Если twoFactorMethod === 'email' — сгенерировать 6-значный код, сохранить bcrypt hash в emailCodeHash, emailCodeExpiresAt = now + 5 min, вызвать send2FACodeEmail(user.email, code). Установить cookie two_factor_pending с подписанным pendingId (через setPendingCookie). Вернуть NextResponse.json({ need2FA: true, method: user.twoFactorMethod }, { status: 200 }) и в заголовках Set-Cookie от cookie.
  • Использовать NextResponse с cookies: после создания response вызвать setPendingCookie(pending.id, response) и вернуть response.

Step 2: Commit

git add nextjs-project/src/app/api/auth/login-step1/route.ts
git commit -m "feat(2fa): add login-step1 API (password check, create pending if 2FA)"

Task 6: API POST /api/auth/2fa/send-code

Files:

  • Create: nextjs-project/src/app/api/auth/2fa/send-code/route.ts

Step 1: Реализовать route

  • POST, без body (или пустой). Читать cookie two_factor_pending, получить pendingId через getPendingIdFromCookie.
  • Найти TwoFactorPending по id, проверить expiresAt > now. Загрузить user; проверить user.twoFactorMethod === 'email' (иначе 400).
  • Rate limit: если emailCodeExpiresAt не null и прошло меньше 60 сек с предыдущей отправки — 429 Too Many Requests.
  • Сгенерировать новый 6-значный код, обновить emailCodeHash и emailCodeExpiresAt в записи, отправить send2FACodeEmail(user.email, code). Вернуть 200 { ok: true }.

Step 2: Commit

git add nextjs-project/src/app/api/auth/2fa/send-code/route.ts
git commit -m "feat(2fa): add 2fa send-code API for email method"

Task 7: API POST /api/auth/verify-2fa

Files:

  • Create: nextjs-project/src/app/api/auth/verify-2fa/route.ts

Step 1: Реализовать route

  • POST, body: { code: string } (Zod, строка 6 цифр для email или 6 цифр для TOTP).
  • Из cookie two_factor_pending получить pendingId, найти TwoFactorPending, проверить expiresAt.
  • Загрузить user с полями twoFactorMethod, totpSecretEncrypted.
  • Если method === 'email': сравнить code с emailCodeHash (bcrypt). Если method === 'totp': verifyTotpCode(user.totpSecretEncrypted!, code).
  • При неверном коде — 401 { error: 'Invalid code' }. При верном: создать TwoFactorGrant (createGrant(user.id)), удалить или обновить TwoFactorPending (удалить запись), очистить cookie two_factor_pending (maxAge=0), вернуть 200 { grantToken: grantId }.
  • В ответе установить Set-Cookie на two_factor_pending с пустым значением и maxAge=0 для очистки.

Step 2: Commit

git add nextjs-project/src/app/api/auth/verify-2fa/route.ts
git commit -m "feat(2fa): add verify-2fa API and grant token issue"

Task 8: NextAuth Credentials — приём grantToken и проверка 2FA при пароле

Files:

  • Modify: nextjs-project/src/lib/auth.ts

Step 1: Расширить credentials и authorize

  • В credentials добавить: grantToken: { label: "Grant Token", type: "text" } (опционально).
  • В authorize: если передан credentials.grantToken: вызвать consumeGrant(credentials.grantToken); если вернулся userId — загрузить user по id, вернуть объект { id, email, name, role, mustChangePassword }. Если не вернулся — return null.
  • Если grantToken не передан: текущая логика email+password. После успешной проверки пароля: если user.twoFactorEnabled === true — return null (чтобы нельзя было обойти 2FA прямым вызовом signIn с паролем). Иначе вернуть user как сейчас.

Step 2: Commit

git add nextjs-project/src/lib/auth.ts
git commit -m "feat(2fa): Credentials provider accept grantToken and reject password-only when 2FA on"

Task 9: Страница логина — вызов login-step1 и редирект на 2FA

Files:

  • Modify: nextjs-project/src/app/login/page.tsx

Step 1: Изменить поток входа

  • Вместо прямого signIn('credentials', { email, password }): сначала fetch POST /api/auth/login-step1 с { email, password } (credentials: 'include' для cookie).
  • Если ответ 401 — показать «Неверные учетные данные».
  • Если ответ 200 и body.success === true — вызвать signIn('credentials', { email, password }), затем редирект на change-password или /admin/catalog как сейчас.
  • Если ответ 200 и body.need2FA === true — редирект на /login/2fa?method=${body.method} (или сохранить method в cookie/state). Не вызывать signIn.

Step 2: Commit

git add nextjs-project/src/app/login/page.tsx
git commit -m "feat(2fa): login page use login-step1 and redirect to 2fa when needed"

Task 10: Страница /login/2fa — ввод кода и завершение входа

Files:

  • Create: nextjs-project/src/app/login/2fa/page.tsx

Step 1: Реализовать страницу

  • Client component. Читать method из searchParams (email | totp). Поле ввода кода (6 цифр), кнопка «Войти». При method === 'email' — кнопка «Отправить код повторно» (вызов POST /api/auth/2fa/send-code с credentials: 'include'), disable на 60 сек после отправки.
  • Submit: POST /api/auth/verify-2fa с { code }, credentials: 'include'. При 401 — показать ошибку. При 200 — вызвать signIn('credentials', { grantToken: body.grantToken }), затем редирект на /login/change-password если mustChangePassword, иначе /admin/catalog; router.refresh().
  • Оформление: в духе текущей страницы логина (фон, форма), заголовок «Введите код из письма» или «Введите код из приложения».

Step 2: Commit

git add nextjs-project/src/app/login/2fa/page.tsx
git commit -m "feat(2fa): add /login/2fa page for code entry and grant sign-in"

Task 11: API настройки 2FA — включение (email и TOTP), отключение

Files:

  • Create: nextjs-project/src/app/api/auth/2fa/setup/route.ts

Step 1: Реализовать POST /api/auth/2fa/setup

  • Получить сессию (getServerSession(authOptions)); без сессии — 401.
  • Body: { action: 'enable' | 'disable', method?: 'email' | 'totp', code?: string }. Для enable при method 'totp' при первом запросе можно вернуть { secret, uri } без установки в БД до проверки code.
  • enable + method email: установить user.twoFactorEnabled = true, twoFactorMethod = 'email', totpSecretEncrypted = null. Вернуть 200.
  • enable + method totp: если code не передан — сгенерировать секрет, зашифровать, сохранить во временное поле или вернуть uri для QR и сохранить секрет в сессии/времени; при следующем запросе с code — проверить TOTP, сохранить totpSecretEncrypted, twoFactorEnabled = true, twoFactorMethod = 'totp'. Упрощение: один запрос с code — генерируем секрет, показываем uri в ответе, сохраняем зашифрованный секрет, проверяем code и только тогда twoFactorEnabled = true (иначе злоумышленник не сможет завершить). Либо два шага: POST с action=enable, method=totp без code → 200 { secret, uri }; фронт показывает QR; POST с action=enable, method=totp, code → проверка, сохранение.
  • disable: проверить текущий пароль или код 2FA (body.password или body.code). Если верно — twoFactorEnabled = false, twoFactorMethod = null, totpSecretEncrypted = null. Вернуть 200.

Step 2: Commit

git add nextjs-project/src/app/api/auth/2fa/setup/route.ts
git commit -m "feat(2fa): add 2fa setup API (enable email/totp, disable)"

Task 12: Админка — UI настройки 2FA (включение, выбор способа, отключение)

Files:

  • Create or modify: страница настроек/профиля в админке (например nextjs-project/src/app/admin/settings/page.tsx или раздел в существующей странице профиля).

Step 1: Добавить блок «Двухфакторная аутентификация»

  • Показать текущий статус: выключено / email / приложение.
  • Если выключено: кнопки «Включить по email» и «Включить через приложение». По клику — вызов setup с action=enable, method=email или totp (для totp сначала запрос без code для получения uri, отобразить QR и поле ввода кода, затем запрос с code).
  • Если включено: кнопка «Отключить 2FA» — модалка с вводом пароля или кода, затем POST setup action=disable.
  • Расположение: по существующей структуре админки (например ссылка «Настройки» в сайдбаре, страница с формой).

Step 2: Commit

git add nextjs-project/src/app/admin/settings/page.tsx  # или изменённые файлы
git commit -m "feat(2fa): admin UI for enable/disable and method selection"

Task 13: Документация и env

Files:

  • Create or modify: nextjs-project/docs/password-reset-env.md или отдельный nextjs-project/docs/2fa-env.md
  • Modify: .env.example (если есть) — добавить TOTP_SECRET_ENCRYPTION_KEY= (32 bytes base64).

Step 1: Описать переменные

  • TOTP_SECRET_ENCRYPTION_KEY — ключ для шифрования TOTP-секрета (32 байта, base64). Генерация: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))".
  • Для email 2FA используется существующий SMTP (SMTP_HOST и т.д.).

Step 2: Commit

git add nextjs-project/docs/2fa-env.md nextjs-project/.env.example
git commit -m "docs(2fa): env and 2FA setup documentation"

Execution summary

  • Задачи 1–10 обеспечивают полный поток входа с 2FA (email и TOTP).
  • Задачи 11–12 — настройка 2FA в админке.
  • Задача 13 — документация.

После выполнения плана: проверить сценарии «логин без 2FA», «логин с 2FA email», «логин с 2FA TOTP», «включение/отключение 2FA в настройках».


Plan complete and saved to docs/plans/2026-02-24-2fa.md.

Two execution options:

  1. Subagent-Driven (this session) — dispatch a fresh subagent per task, review between tasks, fast iteration.
  2. Parallel Session (separate) — open a new session with executing-plans, batch execution with checkpoints.

Which approach?