spb/note-spboucher-ai
Public
1import { generateSecret, generateURI, verifySync } from "otplib";2const totpOk = (token: string, secret: string) => { try { const r: any = verifySync({ secret, token, epochTolerance: 30 }); return typeof r === "boolean" ? r : Boolean(r?.valid); } catch { return false; } };3import { eq } from "drizzle-orm";4import { db, schema } from "@/db";5import { getSession, markMfaPassed, clientIp } from "@/lib/auth/session";6import { assertSameOrigin, jsonError, HttpError } from "@/lib/auth/guard";7import { rateLimit } from "@/lib/auth/ratelimit";8import { audit } from "@/lib/audit";9import { headers } from "next/headers";10import QRCode from "qrcode";1112/** POST { code } → verify TOTP for the pending session. PUT { action: "setup" | "enable" | "disable", code? } → manage 2FA. */13export async function POST(req: Request) {14 try {15 await assertSameOrigin();16 const s = await getSession(); if (!s) throw new HttpError(401, "Unauthenticated");17 const ip = clientIp(await headers());18 if (!rateLimit(`totp:${ip}`, 8, 10 * 60_000)) throw new HttpError(429, "Too many attempts");19 const { code } = await req.json();20 const [u] = await db.select().from(schema.users).where(eq(schema.users.id, s.id));21 if (!u.totpSecret || !totpOk(String(code ?? ""), u.totpSecret)) { await audit(s.id, "auth.totp_failed", undefined, {}, ip); throw new HttpError(401, "Invalid code"); }22 await markMfaPassed(s.sessionId); await audit(s.id, "auth.totp_ok");23 return Response.json({ ok: true });24 } catch (e) { return jsonError(e); }25}26export async function PUT(req: Request) {27 try {28 await assertSameOrigin();29 const s = await getSession(); if (!s) throw new HttpError(401, "Unauthenticated");30 if (s.totpEnabled && !s.mfaPassed) throw new HttpError(403, "MFA required");31 const { action, code } = await req.json();32 if (action === "setup") {33 const secret = generateSecret();34 await db.update(schema.users).set({ totpSecret: secret, totpEnabled: false }).where(eq(schema.users.id, s.id));35 const uri = generateURI({ issuer: "Note SPBoucher AI", label: s.username, secret });36 return Response.json({ secret, qr: await QRCode.toDataURL(uri) });37 }38 const [u] = await db.select().from(schema.users).where(eq(schema.users.id, s.id));39 if (action === "enable") {40 if (!u.totpSecret || !totpOk(String(code ?? ""), u.totpSecret)) throw new HttpError(400, "Invalid code");41 await db.update(schema.users).set({ totpEnabled: true }).where(eq(schema.users.id, s.id)); await markMfaPassed(s.sessionId); await audit(s.id, "auth.totp_enabled");42 return Response.json({ ok: true });43 }44 if (action === "disable") {45 if (!u.totpSecret || !totpOk(String(code ?? ""), u.totpSecret)) throw new HttpError(400, "Invalid code");46 await db.update(schema.users).set({ totpEnabled: false, totpSecret: null }).where(eq(schema.users.id, s.id)); await audit(s.id, "auth.totp_disabled");47 return Response.json({ ok: true });48 }49 throw new HttpError(400, "Unknown action");50 } catch (e) { return jsonError(e); }51}52