import { db } from "@pram/db"; import { verification, user, account } from "@pram/db/schema/auth"; import { TRPCError } from "@trpc/server"; import bcrypt from "bcryptjs"; import crypto from "node:crypto"; import { eq, and, gt, sql } from "drizzle-orm"; import { z } from "zod"; import { sendOtpEmail } from "../lib/email"; import { autoRefillIfEligible } from "../lib/credit"; import { publicProcedure, router } from "../index"; import { checkRateLimit } from "../lib/rate-limit"; import { validateTurnstileToken } from "../lib/turnstile"; const OTP_LENGTH = 6; const OTP_EXPIRY_MS = 5 * 60 * 1000; function generateOtp(): string { return crypto.randomInt(10 ** (OTP_LENGTH - 1), 10 ** OTP_LENGTH - 1).toString(); } function genericOtpResponse() { return { success: true, message: "If the email is registered, an OTP has been sent." }; } export const verificationRouter = router({ sendVerificationOtp: publicProcedure .input(z.object({ email: z.string().email(), turnstileToken: z.string().optional() })) .mutation(async ({ input, ctx }) => { const ip = ctx.ip ?? "unknown"; await checkRateLimit({ key: `otp-send:email:${input.email}`, limit: 3, windowMs: 300_000, strict: true }); await checkRateLimit({ key: `otp-send:ip:${ip}:email`, limit: 20, windowMs: 900_000, strict: true }); await validateTurnstileToken(input.turnstileToken); const [existingUser] = await db .select({ id: user.id, emailVerified: user.emailVerified }) .from(user) .where(eq(user.email, input.email)) .limit(1); if (!existingUser || existingUser.emailVerified) { return genericOtpResponse(); } const otp = generateOtp(); const identifier = `email-verification:${input.email}`; await db.delete(verification).where(eq(verification.identifier, identifier)); await db.insert(verification).values({ id: crypto.randomUUID(), identifier, value: otp, expiresAt: new Date(Date.now() + OTP_EXPIRY_MS), }); await sendOtpEmail({ to: input.email, otp, type: "email-verification" }); return genericOtpResponse(); }), verifyEmailOtp: publicProcedure .input(z.object({ email: z.string().email(), otp: z.string().length(6) })) .mutation(async ({ input, ctx }) => { const ip = ctx.ip ?? "unknown"; await checkRateLimit({ key: `otp-verify:email:${input.email}`, limit: 10, windowMs: 300_000, strict: true }); await checkRateLimit({ key: `otp-verify:ip:${ip}`, limit: 30, windowMs: 300_000, strict: true }); const identifier = `email-verification:${input.email}`; const [record] = await db .select() .from(verification) .where( and( eq(verification.identifier, identifier), eq(verification.value, input.otp), gt(verification.expiresAt, sql`now()`), ), ) .limit(1); if (!record) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid or expired OTP" }); } await db .update(user) .set({ emailVerified: true }) .where(eq(user.email, input.email)); await db.delete(verification).where(eq(verification.id, record.id)); const [verifiedUser] = await db .select({ id: user.id }) .from(user) .where(eq(user.email, input.email)) .limit(1); if (verifiedUser) { await autoRefillIfEligible(verifiedUser.id); } return { success: true }; }), sendPasswordResetOtp: publicProcedure .input(z.object({ email: z.string().email(), turnstileToken: z.string().optional() })) .mutation(async ({ input, ctx }) => { const ip = ctx.ip ?? "unknown"; await checkRateLimit({ key: `otp-send:email:${input.email}`, limit: 3, windowMs: 300_000, strict: true }); await checkRateLimit({ key: `otp-send:ip:${ip}:reset`, limit: 20, windowMs: 900_000, strict: true }); await validateTurnstileToken(input.turnstileToken); const [existingUser] = await db .select({ id: user.id }) .from(user) .where(eq(user.email, input.email)) .limit(1); if (!existingUser) { return genericOtpResponse(); } const otp = generateOtp(); const identifier = `forget-password:${input.email}`; await db.delete(verification).where(eq(verification.identifier, identifier)); await db.insert(verification).values({ id: crypto.randomUUID(), identifier, value: otp, expiresAt: new Date(Date.now() + OTP_EXPIRY_MS), }); await sendOtpEmail({ to: input.email, otp, type: "forget-password" }); return genericOtpResponse(); }), resetPassword: publicProcedure .input( z.object({ email: z.string().email(), otp: z.string().length(6), newPassword: z.string().regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/, "Password must be at least 8 characters with uppercase, lowercase, and number"), }), ) .mutation(async ({ input, ctx }) => { const ip = ctx.ip ?? "unknown"; await checkRateLimit({ key: `otp-reset:email:${input.email}`, limit: 5, windowMs: 300_000, strict: true }); await checkRateLimit({ key: `otp-reset:ip:${ip}`, limit: 15, windowMs: 300_000, strict: true }); const identifier = `forget-password:${input.email}`; const [record] = await db .select() .from(verification) .where( and( eq(verification.identifier, identifier), eq(verification.value, input.otp), gt(verification.expiresAt, sql`now()`), ), ) .limit(1); if (!record) { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid or expired OTP" }); } const passwordHash = await bcrypt.hash(input.newPassword, 10); const [userRecord] = await db .select({ id: user.id, name: user.name }) .from(user) .where(eq(user.email, input.email)) .limit(1); if (!userRecord) { throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); } await db .update(account) .set({ password: passwordHash }) .where( and(eq(account.providerId, "credential"), eq(account.userId, userRecord.id)), ); await db.delete(verification).where(eq(verification.id, record.id)); return { success: true }; }), });