Files
community-rule/app/api/auth/otp/verify/route.ts
T
2026-04-04 22:20:02 -06:00

109 lines
2.8 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../../../lib/server/db";
import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../../../lib/server/env";
import { hashOtpCode } from "../../../../../lib/server/hash";
import {
createSessionForUser,
setSessionCookie,
} from "../../../../../lib/server/session";
import { dbUnavailable } from "../../../../../lib/server/responses";
const MAX_ATTEMPTS = 5;
function normalizeEmail(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const email = raw.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
return email;
}
export async function POST(request: NextRequest) {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const { email: rawEmail, code: rawCode } = body as {
email?: unknown;
code?: unknown;
};
const email = normalizeEmail(rawEmail);
const code =
typeof rawCode === "string"
? rawCode.replace(/\s/g, "")
: String(rawCode ?? "");
if (!email || !/^\d{6}$/.test(code)) {
return NextResponse.json(
{ error: "Valid email and 6-digit code required" },
{ status: 400 },
);
}
let pepper: string;
try {
pepper = getSessionPepper();
} catch {
return NextResponse.json(
{ error: "Server misconfiguration" },
{ status: 500 },
);
}
const challenge = await prisma.otpChallenge.findFirst({
where: {
email,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: "desc" },
});
if (!challenge) {
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
}
if (challenge.attempts >= MAX_ATTEMPTS) {
await prisma.otpChallenge.delete({ where: { id: challenge.id } });
return NextResponse.json({ error: "Too many attempts" }, { status: 429 });
}
const expectedHash = hashOtpCode(code, pepper);
if (expectedHash !== challenge.codeHash) {
await prisma.otpChallenge.update({
where: { id: challenge.id },
data: { attempts: { increment: 1 } },
});
return NextResponse.json({ error: "Invalid or expired code" }, { status: 400 });
}
await prisma.otpChallenge.deleteMany({ where: { email } });
const user = await prisma.user.upsert({
where: { email },
create: { email },
update: {},
});
const { token, expiresAt } = await createSessionForUser(user.id);
await setSessionCookie(token, expiresAt);
return NextResponse.json({
ok: true,
user: { id: user.id, email: user.email },
});
}