import { createHash, randomInt } from 'node:crypto'; import { auth } from '@/lib/auth'; import { type IpGeoInfo, fetchGeoInfo } from '@/lib/geoip'; import { sendMail } from '@/lib/mailer'; import { db } from '@/lib/prisma'; import type { PasswordResetResendInput, PasswordResetStartInput, PasswordResetVerifyInput, } from '@repo/api-contract'; import { v7 as uuidv7 } from 'uuid'; const OTP_LENGTH = 6; const OTP_TTL_MINUTES = Number(Bun.env.PASSWORD_RESET_OTP_TTL_MINUTES ?? 10); const OTP_MAX_ATTEMPTS = Number(Bun.env.PASSWORD_RESET_OTP_MAX_ATTEMPTS ?? 5); const OTP_RESEND_COOLDOWN_SECONDS = Number( Bun.env.PASSWORD_RESET_OTP_RESEND_COOLDOWN_SECONDS ?? 30 ); export type PasswordResetStartResult = { message: string; requestId: string; email: string; expiresAt: string; cooldownSeconds: number; }; export type PasswordResetVerifyResult = { message: string; }; export type PasswordResetResendResult = { message: string; requestId: string; email: string; expiresAt: string; cooldownSeconds: number; }; export class PasswordResetError extends Error { status: 400 | 404 | 409 | 429; constructor(message: string, status: 400 | 404 | 409 | 429 = 400) { super(message); this.name = 'PasswordResetError'; this.status = status; } } function nowPlusMinutes(minutes: number): Date { const date = new Date(); date.setMinutes(date.getMinutes() + minutes); return date; } function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } function createOtpCode(): string { return randomInt(0, 10 ** OTP_LENGTH) .toString() .padStart(OTP_LENGTH, '0'); } function hashValue(value: string): string { return createHash('sha256').update(value).digest('hex'); } function getRemainingAttempts(otpAttempts: number): number { return Math.max(0, OTP_MAX_ATTEMPTS - otpAttempts); } function getCooldownSeconds(otpLastSentAt: Date): number { const elapsedMs = Date.now() - otpLastSentAt.getTime(); const remainingMs = OTP_RESEND_COOLDOWN_SECONDS * 1000 - elapsedMs; return Math.max(0, Math.ceil(remainingMs / 1000)); } type UserAgentInfo = { browser: string; os: string; device: string; }; function parseUserAgent(ua: string | undefined): UserAgentInfo { if (!ua) { return { browser: 'Desconocido', os: 'Desconocido', device: 'Desconocido' }; } const uaLower = ua.toLowerCase(); let browser = 'Desconocido'; if (uaLower.includes('chrome')) browser = 'Chrome'; else if (uaLower.includes('firefox')) browser = 'Firefox'; else if (uaLower.includes('safari')) browser = 'Safari'; else if (uaLower.includes('edge')) browser = 'Edge'; else if (uaLower.includes('opera')) browser = 'Opera'; let os = 'Desconocido'; if (uaLower.includes('windows')) os = 'Windows'; else if (uaLower.includes('mac')) os = 'macOS'; else if (uaLower.includes('linux')) os = 'Linux'; else if (uaLower.includes('android')) os = 'Android'; else if (uaLower.includes('ios') || uaLower.includes('iphone') || uaLower.includes('ipad')) os = 'iOS'; let device = 'Desktop'; if (uaLower.includes('mobile') || uaLower.includes('android')) device = 'Móvil'; else if (uaLower.includes('tablet') || uaLower.includes('ipad')) device = 'Tablet'; return { browser, os, device }; } async function sendResetOtpEmail(email: string, otpCode: string) { await sendMail({ to: email, subject: 'Código para restablecer tu contraseña', text: `Tu código OTP es ${otpCode}. Vence en ${OTP_TTL_MINUTES} minutos.`, html: `

Tu código OTP es ${otpCode}.

Vence en ${OTP_TTL_MINUTES} minutos.

`, }); } async function sendPasswordChangedEmail( email: string, ipInfo: IpGeoInfo | null, userAgent: string | undefined ) { const deviceInfo = parseUserAgent(userAgent); const now = new Date(); const formattedDate = now.toLocaleString('es-AR', { timeZone: 'America/Argentina/Buenos_Aires', dateStyle: 'full', timeStyle: 'short', }); const location = ipInfo ? `${ipInfo.city}${ipInfo.city !== 'Red local' ? `, ${ipInfo.country}` : ''}` : 'No disponible'; const ip = ipInfo?.ip || 'No disponible'; await sendMail({ to: email, subject: 'Tu contraseña ha sido cambiada', text: `Tu contraseña fue cambiada el ${formattedDate}. Si no fuiste vos, contactanos inmediatamente.`, html: `

Tu contraseña ha sido cambiada

Se ha modificado la contraseña de tu cuenta.

Detalles del cambio:

Si no fuiste vos, contactanos inmediatamente.

`, }); } async function findBetterAuthUserByEmail(email: string): Promise<{ id: string } | null> { const user = await db.user.findUnique({ where: { email }, select: { id: true }, }); if (!user) { return null; } return { id: user.id }; } export async function startPasswordReset( input: PasswordResetStartInput ): Promise { const email = normalizeEmail(input.email); const existingUser = await findBetterAuthUserByEmail(email); if (!existingUser) { return { message: 'Si el email existe, recibirás un código de verificación.', requestId: uuidv7(), email, expiresAt: new Date().toISOString(), cooldownSeconds: OTP_RESEND_COOLDOWN_SECONDS, }; } const existingRequest = await db.passwordResetRequest.findFirst({ where: { email, usedAt: null }, orderBy: { createdAt: 'desc' }, }); let requestId: string; let otpCode: string; let otpHash: string; let otpExpiresAt: Date; let cooldownSeconds = 0; if (existingRequest) { requestId = existingRequest.id; otpExpiresAt = new Date(existingRequest.otpExpiresAt); if (new Date() > otpExpiresAt) { otpCode = createOtpCode(); otpHash = hashValue(otpCode); otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); await db.passwordResetRequest.update({ where: { id: requestId }, data: { otpHash, otpExpiresAt, otpAttempts: 0, otpLastSentAt: new Date(), otpResendCount: { increment: 1 }, }, }); } else { cooldownSeconds = getCooldownSeconds(new Date(existingRequest.otpLastSentAt)); if (cooldownSeconds > 0) { return { message: `Debes esperar ${cooldownSeconds} segundos antes de reenviar.`, requestId, email, expiresAt: otpExpiresAt.toISOString(), cooldownSeconds, }; } otpCode = createOtpCode(); otpHash = hashValue(otpCode); otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); await db.passwordResetRequest.update({ where: { id: requestId }, data: { otpHash, otpExpiresAt, otpAttempts: 0, otpLastSentAt: new Date(), otpResendCount: { increment: 1 }, }, }); await sendResetOtpEmail(email, otpCode); return { message: 'Código reenviado.', requestId, email, expiresAt: otpExpiresAt.toISOString(), cooldownSeconds: 0, }; } } else { requestId = uuidv7(); otpCode = createOtpCode(); otpHash = hashValue(otpCode); otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); await db.passwordResetRequest.create({ data: { id: requestId, email, otpHash, otpExpiresAt, otpLastSentAt: new Date(), }, }); } await sendResetOtpEmail(email, otpCode); return { message: 'Código enviado successfully.', requestId, email, expiresAt: otpExpiresAt.toISOString(), cooldownSeconds: 0, }; } export async function verifyOtpAndResetPassword( input: PasswordResetVerifyInput, ip: string | undefined, userAgent: string | undefined ): Promise { const { requestId, otp, newPassword } = input; const request = await db.passwordResetRequest.findUnique({ where: { id: requestId }, }); if (!request) { throw new PasswordResetError('Solicitud inválida.', 404); } if (request.usedAt) { throw new PasswordResetError('Esta solicitud ya fue utilizada.', 400); } if (new Date() > new Date(request.otpExpiresAt)) { throw new PasswordResetError('El código ha vencido.', 400); } const remainingAttempts = getRemainingAttempts(request.otpAttempts); if (remainingAttempts <= 0) { throw new PasswordResetError('Superaste la cantidad maxima de intentos.', 429); } const otpHash = hashValue(otp); if (otpHash !== request.otpHash) { await db.passwordResetRequest.update({ where: { id: requestId }, data: { otpAttempts: { increment: 1 } }, }); throw new PasswordResetError('Código incorrecto.', 400); } const existingUser = await findBetterAuthUserByEmail(request.email); if (!existingUser) { throw new PasswordResetError('Usuario no encontrado.', 404); } const resetToken = uuidv7(); const identifier = `reset-password:${resetToken}`; const expiresAt = nowPlusMinutes(OTP_TTL_MINUTES); await db.verification.create({ data: { id: uuidv7(), identifier, value: existingUser.id, expiresAt, }, }); await auth.api.resetPassword({ asResponse: false, body: { token: resetToken, newPassword, }, }); await db.passwordResetRequest.update({ where: { id: requestId }, data: { usedAt: new Date() }, }); const geoInfo = await fetchGeoInfo(ip || 'unknown'); await sendPasswordChangedEmail(request.email, geoInfo, userAgent); return { message: 'Contraseña restablecida correctamente.', }; } export async function resendPasswordResetOtp( input: PasswordResetResendInput ): Promise { const { requestId } = input; const request = await db.passwordResetRequest.findUnique({ where: { id: requestId }, }); if (!request) { throw new PasswordResetError('Solicitud inválida.', 404); } if (request.usedAt) { throw new PasswordResetError('Esta solicitud ya fue utilizada.', 400); } const cooldownSeconds = getCooldownSeconds(new Date(request.otpLastSentAt)); if (cooldownSeconds > 0) { throw new PasswordResetError( `Debes esperar ${cooldownSeconds} segundos antes de reenviar.`, 429 ); } const otpCode = createOtpCode(); const otpHash = hashValue(otpCode); const otpExpiresAt = nowPlusMinutes(OTP_TTL_MINUTES); await db.passwordResetRequest.update({ where: { id: requestId }, data: { otpHash, otpExpiresAt, otpAttempts: 0, otpLastSentAt: new Date(), otpResendCount: { increment: 1 }, }, }); await sendResetOtpEmail(request.email, otpCode); return { message: 'Código reenviado.', requestId, email: request.email, expiresAt: otpExpiresAt.toISOString(), cooldownSeconds: 0, }; }