spb/tools-spboucher-ai
Public
1import { z } from "zod";2import { eq } from "drizzle-orm";3import { headers } from "next/headers";4import { generateSecret, generateURI, verifySync } from "otplib";5import QRCode from "qrcode";6import { db, schema } from "@/db";7import { getSession, markMfaPassed, rotateSession, setSessionCookie, clientIp, revokeOtherSessions } from "@/lib/auth/session";8import { assertSameOrigin, jsonError, HttpError, requireUser } from "@/lib/auth/guard";9import { rateLimit } from "@/lib/auth/ratelimit";10import { audit } from "@/lib/audit";1112/** POST {step:"verify", code} — complete login MFA. POST {step:"setup"} → secret+QR. POST {step:"enable", code, secret}. POST {step:"disable", code}. */13export async function POST(req: Request) {14 try {15 await assertSameOrigin();16 const ip = clientIp(await headers());17 if (!rateLimit(`totp:${ip}`, 10, 10 * 60_000)) throw new HttpError(429, "Too many attempts");18 const b = z.object({ step: z.enum(["verify", "setup", "enable", "disable"]), code: z.string().regex(/^\d{6}$/).optional(), secret: z.string().max(100).optional() }).parse(await req.json());19 if (b.step === "verify") {20 const s = await getSession(); if (!s) throw new HttpError(401, "Unauthenticated");21 const [u] = await db.select().from(schema.users).where(eq(schema.users.id, s.id));22 if (!u.totpEnabled || !u.totpSecret) throw new HttpError(400, "MFA not enabled");23 if (!b.code || !verifySync({ token: b.code, secret: u.totpSecret }).valid) { await audit({ id: u.id, name: u.email }, "auth.mfa_failed", undefined, {}, ip); throw new HttpError(401, "Invalid code"); }24 await markMfaPassed(s.sessionId); const t = await rotateSession(s.sessionId); await setSessionCookie(t);25 await audit({ id: u.id, name: u.email }, "auth.mfa_passed", undefined, {}, ip);26 return Response.json({ ok: true, next: u.mustChangePassword ? "change_password" : "done" });27 }28 const s = await requireUser(req);29 if (b.step === "setup") {30 const secret = generateSecret(); const uri = generateURI({ issuer: "tools.spboucher.ai", label: s.email, secret });31 return Response.json({ secret, uri, qr: await QRCode.toDataURL(uri, { margin: 1, width: 220 }) });32 }33 if (b.step === "enable") {34 if (!b.code || !b.secret || !verifySync({ token: b.code, secret: b.secret }).valid) throw new HttpError(400, "Code does not match the secret");35 await db.update(schema.users).set({ totpSecret: b.secret, totpEnabled: true, updatedAt: new Date() }).where(eq(schema.users.id, s.id));36 await markMfaPassed(s.sessionId); await revokeOtherSessions(s.id, s.sessionId);37 await audit({ id: s.id, name: s.email }, "auth.totp_enabled", undefined, {}, ip);38 return Response.json({ ok: true });39 }40 const [u] = await db.select().from(schema.users).where(eq(schema.users.id, s.id));41 if (!b.code || !u.totpSecret || !verifySync({ token: b.code, secret: u.totpSecret }).valid) throw new HttpError(400, "Invalid code");42 await db.update(schema.users).set({ totpSecret: null, totpEnabled: false, updatedAt: new Date() }).where(eq(schema.users.id, s.id));43 await audit({ id: s.id, name: s.email }, "auth.totp_disabled", undefined, {}, ip);44 return Response.json({ ok: true });45 } catch (e) { return jsonError(e); }46}47