|
|
|
|
@@ -0,0 +1,443 @@
|
|
|
|
|
import { createHash, randomInt } from 'node:crypto';
|
|
|
|
|
import { sendMail } from '@/lib/mailer';
|
|
|
|
|
import { db } from '@/lib/prisma';
|
|
|
|
|
import { getSupabaseAdminClient } from '@/lib/supabase-admin';
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type IpGeoInfo = {
|
|
|
|
|
ip: string;
|
|
|
|
|
city: string;
|
|
|
|
|
country: string;
|
|
|
|
|
countryCode: 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 fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
|
|
|
|
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
|
|
|
|
return { ip, city: 'Red local', country: 'Red local', countryCode: 'LOCAL' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,query`
|
|
|
|
|
);
|
|
|
|
|
if (!response.ok) return null;
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (data.status !== 'success') return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
ip: data.query,
|
|
|
|
|
city: data.city || 'Desconocida',
|
|
|
|
|
country: data.country || 'Desconocido',
|
|
|
|
|
countryCode: data.countryCode || '',
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: `<p>Tu código OTP es <strong>${otpCode}</strong>.</p><p>Vence en ${OTP_TTL_MINUTES} minutos.</p>`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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: `
|
|
|
|
|
<h2>Tu contraseña ha sido cambiada</h2>
|
|
|
|
|
<p>Se ha modificado la contraseña de tu cuenta.</p>
|
|
|
|
|
<h3>Detalles del cambio:</h3>
|
|
|
|
|
<ul>
|
|
|
|
|
<li><strong>Fecha y hora:</strong> ${formattedDate}</li>
|
|
|
|
|
<li><strong>Dirección IP:</strong> ${ip}</li>
|
|
|
|
|
<li><strong>Ubicación:</strong> ${location}</li>
|
|
|
|
|
<li><strong>Dispositivo:</strong> ${deviceInfo.device} (${deviceInfo.os} - ${deviceInfo.browser})</li>
|
|
|
|
|
</ul>
|
|
|
|
|
<p>Si no fuiste vos, contactanos inmediatamente.</p>
|
|
|
|
|
`,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function findSupabaseUserByEmail(email: string): Promise<{ id: string } | null> {
|
|
|
|
|
let page = 1;
|
|
|
|
|
const perPage = 100;
|
|
|
|
|
|
|
|
|
|
while (page <= 10) {
|
|
|
|
|
const { data, error } = await getSupabaseAdminClient().auth.admin.listUsers({
|
|
|
|
|
page,
|
|
|
|
|
perPage,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
throw new Error(`No se pudo consultar usuarios en Supabase: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const found = data.users.find((user) => user.email?.toLowerCase() === email.toLowerCase());
|
|
|
|
|
|
|
|
|
|
if (found) {
|
|
|
|
|
return { id: found.id };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.users.length < perPage) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
page += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function startPasswordReset(
|
|
|
|
|
input: PasswordResetStartInput
|
|
|
|
|
): Promise<PasswordResetStartResult> {
|
|
|
|
|
const email = normalizeEmail(input.email);
|
|
|
|
|
|
|
|
|
|
const existingUser = await findSupabaseUserByEmail(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<PasswordResetVerifyResult> {
|
|
|
|
|
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 findSupabaseUserByEmail(request.email);
|
|
|
|
|
if (!existingUser) {
|
|
|
|
|
throw new PasswordResetError('Usuario no encontrado.', 404);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await getSupabaseAdminClient().auth.admin.updateUserById(existingUser.id, {
|
|
|
|
|
password: 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<PasswordResetResendResult> {
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|