SPB Git
3.2 KB · 47 lines typescript
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
Files · main
Menu

Theme