export const runtime = "nodejs"; import { NextResponse } from "next/server"; import { z } from "zod"; import { eq, and, isNull } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { users, totpRecoveryCodes } from "@/lib/db/schema"; import { createSession, setSessionCookie } from "@/lib/auth/session"; import { consumeLoginChallengeAttempt, deleteLoginChallenge, clearLoginChallengeCookie } from "@/lib/auth/login-challenge"; import { decryptTotpSecret, verifyTotpCode, hashRecoveryCode } from "@/lib/auth/totp"; const schema = z.object({ code: z.string().min(6).max(16) }); // Second step of login for a 2FA account — reads the pending-login cookie // set by /api/auth/login, so the client never has to carry the challenge // token itself. Accepts either a live 6-digit TOTP code or a recovery code. export async function POST(request: Request) { const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { return NextResponse.json({ error: "Invalid input" }, { status: 400 }); } // Burns one attempt regardless of whether the code below turns out to be // right — the whole point is capping brute-force *tries*, not just wrong ones. const challenge = await consumeLoginChallengeAttempt(); if (!challenge) { return NextResponse.json({ error: "Сессия входа истекла — войдите заново" }, { status: 401 }); } const user = await db.query.users.findFirst({ where: eq(users.id, challenge.userId) }); if (!user || !user.totpEnabled || !user.totpSecret) { await deleteLoginChallenge(challenge.tokenHash); return NextResponse.json({ error: "2FA не настроена для этого аккаунта" }, { status: 400 }); } const code = parsed.data.code.trim(); let verified = /^\d{6}$/.test(code) && (await verifyTotpCode(decryptTotpSecret(user.totpSecret), code)); if (!verified) { const codeHash = hashRecoveryCode(code); const match = await db.query.totpRecoveryCodes.findFirst({ where: and(eq(totpRecoveryCodes.userId, user.id), eq(totpRecoveryCodes.codeHash, codeHash), isNull(totpRecoveryCodes.usedAt)), }); if (match) { await db.update(totpRecoveryCodes).set({ usedAt: new Date() }).where(eq(totpRecoveryCodes.id, match.id)); verified = true; } } if (!verified) { return NextResponse.json({ error: "Неверный код" }, { status: 401 }); } await deleteLoginChallenge(challenge.tokenHash); await clearLoginChallengeCookie(); const token = await createSession(user.id); await setSessionCookie(token); return NextResponse.json({ ok: true, user: { id: user.id, name: user.name, role: user.role } }); }