diff --git a/app/controllers/login.php b/app/controllers/login.php index 35968f6..8402cd7 100644 --- a/app/controllers/login.php +++ b/app/controllers/login.php @@ -145,9 +145,7 @@ function validar() header('Location: /IMPORTADORES/login'); } -/** - * Función auxiliar para redirección por rol - */ +/** Función auxiliar para redirección por rol **/ function redirectByRole($tipoUsuario) { switch ($tipoUsuario) { @@ -498,7 +496,6 @@ function verificarCodigoVista() function verificarCodigo() { - // ❌ REMOVER session_start() - ya se maneja en session.php header('Content-Type: application/json'); $conn = getConnection(); @@ -507,8 +504,24 @@ function verificarCodigo() $codigo = isset($_POST['codigo']) ? trim($_POST['codigo']) : ''; $email = $_SESSION['email_recuperacion'] ?? ''; - if (!$conn || empty($codigo) || empty($email)) { - echo json_encode(['success' => false, 'message' => '❌ Código o correo faltante.']); + if (!$conn) { + echo json_encode(['success' => false, 'message' => '❌ Error de conexión a la base de datos.']); + exit; + } + + if (empty($codigo)) { + echo json_encode(['success' => false, 'message' => '❌ Código requerido.']); + exit; + } + + if (empty($email)) { + echo json_encode(['success' => false, 'message' => '❌ Sesión expirada. Solicita un nuevo código.']); + exit; + } + + // Validar formato del código + if (!preg_match('/^\d{6}$/', $codigo)) { + echo json_encode(['success' => false, 'message' => '❌ Formato de código inválido. Debe ser de 6 dígitos.']); exit; } @@ -521,11 +534,14 @@ function verificarCodigo() $stmt = sqlsrv_query($conn, $sql, [$emailEncrypted]); if ($stmt === false) { + $errors = sqlsrv_errors(); + error_log("Error SQL en verificarCodigo: " . print_r($errors, true)); echo json_encode(['success' => false, 'message' => '❌ Error al consultar el código.']); exit; } $row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC); + sqlsrv_free_stmt($stmt); if (!$row) { echo json_encode(['success' => false, 'message' => '❌ Código no encontrado o ya usado.']); @@ -539,18 +555,43 @@ function verificarCodigo() $_SESSION['intentos_codigo']++; - if ($_SESSION['intentos_codigo'] > 5) { - // Bloquea el código en la base y resetea intentos - sqlsrv_query($conn, "UPDATE recuperacion_password SET estatus = 1 WHERE id = ?", [$row['id']]); + // Verificar si se han excedido los intentos + if ($_SESSION['intentos_codigo'] >= 5) { + // Bloquear el código en la base de datos + $sqlUpdate = "UPDATE recuperacion_password SET estatus = 1 WHERE id = ?"; + $stmtUpdate = sqlsrv_query($conn, $sqlUpdate, [$row['id']]); + + if ($stmtUpdate) { + sqlsrv_free_stmt($stmtUpdate); + } + + // 🚨 ENVIAR NOTIFICACIÓN DE SEGURIDAD + try { + enviarNotificacionIntentosExcedidos($email); + } catch (Exception $e) { + error_log("❌ Error al enviar notificación de intentos excedidos: " . $e->getMessage()); + } + + // Limpiar sesión unset($_SESSION['intentos_codigo']); - - echo json_encode(['success' => false, 'message' => '🚫 Has excedido el número de intentos. Solicita un nuevo código.']); + unset($_SESSION['email_recuperacion']); + + echo json_encode([ + 'success' => false, + 'message' => '🚫 Has excedido el número de intentos permitidos. Por seguridad, se ha bloqueado el código. Solicita uno nuevo.', + 'blocked' => true + ]); exit; } // Compara códigos - asegurando que no haya problemas con mayúsculas o espacios if (strcasecmp($codigo, $row['codigo']) !== 0) { - echo json_encode(['success' => false, 'message' => "❌ Código incorrecto. Intento {$_SESSION['intentos_codigo']} de 5."]); + $intentosRestantes = 5 - $_SESSION['intentos_codigo']; + echo json_encode([ + 'success' => false, + 'message' => "❌ Código incorrecto. Te quedan {$intentosRestantes} intentos.", + 'intentos_restantes' => $intentosRestantes + ]); exit; } @@ -566,21 +607,218 @@ function verificarCodigo() if ($expiracionTimestamp === false || $expiracionTimestamp < time()) { // Código expirado: marcar como usado y enviar mensaje - sqlsrv_query($conn, "UPDATE recuperacion_password SET estatus = 1 WHERE id = ?", [$row['id']]); - echo json_encode(['success' => false, 'message' => '⏰ El código ha expirado.']); + $sqlExpired = "UPDATE recuperacion_password SET estatus = 1 WHERE id = ?"; + $stmtExpired = sqlsrv_query($conn, $sqlExpired, [$row['id']]); + + if ($stmtExpired) { + sqlsrv_free_stmt($stmtExpired); + } + + echo json_encode(['success' => false, 'message' => '⏰ El código ha expirado. Solicita uno nuevo.']); exit; } // Código correcto: actualizar estado y permitir cambio de contraseña - sqlsrv_query($conn, "UPDATE recuperacion_password SET estatus = 1 WHERE id = ?", [$row['id']]); + $sqlSuccess = "UPDATE recuperacion_password SET estatus = 1 WHERE id = ?"; + $stmtSuccess = sqlsrv_query($conn, $sqlSuccess, [$row['id']]); + + if ($stmtSuccess) { + sqlsrv_free_stmt($stmtSuccess); + } $_SESSION['recuperacion_autorizada'] = true; unset($_SESSION['intentos_codigo']); - echo json_encode(['success' => true, 'message' => '✅ Código verificado. Redirigiendo...']); + echo json_encode(['success' => true, 'message' => '✅ Código verificado correctamente. Redirigiendo...']); exit; } +/** Envía notificación cuando se exceden los intentos de verificación **/ +function enviarNotificacionIntentosExcedidos($email) +{ + $conn = getConnection(); + if (!$conn) { + throw new Exception("No se pudo conectar a la base de datos"); + } + + // Buscar información del usuario por email + $emailEncrypted = encrypt($email); + $sqlNotif = " + SELECT + u.nombre, + u.email, + u.notificaciones, + u.notificaciones_extra, + COALESCE(p.intentos_fallidos, 0) as intentos_fallidos, + ce.correo as correo_extra + FROM usuarios_sistema u + LEFT JOIN preferencias_notificaciones_usuario p ON u.id_usuario = p.id_usuario + LEFT JOIN correo_extra ce ON u.id_usuario = ce.id_usuario + WHERE u.email = ?"; + + $stmtUsuario = sqlsrv_query($conn, $sqlNotif, [$emailEncrypted]); + + if ($stmtUsuario === false) { + throw new Exception("Error al consultar información del usuario"); + } + + $usuario = sqlsrv_fetch_array($stmtUsuario, SQLSRV_FETCH_ASSOC); + sqlsrv_free_stmt($stmtUsuario); + + if (!$usuario) { + error_log("⚠️ No se encontró usuario para email: $email"); + return false; + } + + // 🔐 Desencriptar datos sensibles + $usuario['email'] = decrypt($usuario['email']); + $usuario['nombre'] = decrypt($usuario['nombre']); + + // Verificar si el usuario tiene habilitadas las notificaciones de seguridad + if ($usuario['notificaciones'] != 1 || $usuario['intentos_fallidos'] != 1) { + error_log("ℹ️ Usuario {$usuario['email']} no tiene habilitadas las notificaciones de intentos fallidos"); + return false; + } + + // Preparar datos para la notificación + $datosNotificacion = [ + 'email' => $usuario['email'], + 'nombre' => $usuario['nombre'], + 'fecha_hora' => date('Y-m-d H:i:s'), + 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'Desconocida', + 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'Desconocido' + ]; + + $resultadoEnvio = false; + + // Enviar al correo principal + $resultadoPrincipal = enviarNotificacionSeguridadIntentos( + $usuario['email'], + $usuario['nombre'], + $datosNotificacion + ); + + if ($resultadoPrincipal) { + $resultadoEnvio = true; + error_log("✅ Notificación de seguridad enviada a: {$usuario['email']}"); + } + + // Enviar al correo adicional si está configurado + if ($usuario['notificaciones_extra'] == 1 && !empty($usuario['correo_extra'])) { + $resultadoExtra = enviarNotificacionSeguridadIntentos( + $usuario['correo_extra'], + $usuario['nombre'], + $datosNotificacion, + true // Indicar que es correo adicional + ); + + if ($resultadoExtra) { + $resultadoEnvio = true; + error_log("✅ Notificación de seguridad enviada a correo adicional: {$usuario['correo_extra']}"); + } + } + + // Registrar el evento de seguridad en logs del sistema + registrarEventoSeguridad($usuario['id_usuario'], 'INTENTOS_CODIGO_EXCEDIDOS', $datosNotificacion); + + return $resultadoEnvio; +} + +/** Función para enviar el email de notificación de seguridad **/ +function enviarNotificacionSeguridadIntentos($emailDestino, $nombreUsuario, $datos, $esCorreoAdicional = false) +{ + try { + $mail = new PHPMailer(true); + + // Configuración de servidor SMTP + $mail->isSMTP(); + $mail->Host = 'secure.emailsrvr.com'; + $mail->SMTPAuth = true; + $mail->Username = 'noreply@aduanasoft.com.mx'; + $mail->Password = $_ENV['SMTP_PASS']; + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->Port = 587; + + // Configuración del correo + $mail->setFrom('noreply@aduanasoft.com.mx', 'SIIH | AduanaSoft'); + $mail->addAddress($emailDestino); + $mail->CharSet = 'UTF-8'; + $mail->isHTML(true); + + $asunto = "🚨 Alerta de Seguridad - Intentos de Verificación Excedidos"; + $mail->Subject = $asunto; + + $tipoCorreo = $esCorreoAdicional ? "(Correo Adicional)" : ""; + + $mail->Body = " + + + + Alerta de Seguridad + + +
+
+

🚨 Alerta de Seguridad

+

$tipoCorreo

+
+ +
+

Intentos de Verificación Excedidos

+

Hola " . htmlspecialchars($nombreUsuario) . ",

+

Se han detectado múltiples intentos fallidos para verificar el código de recuperación de contraseña en tu cuenta.

+
+ +
+

📋 Detalles del Evento:

+ +
+ +
+

🔒 Medidas de Seguridad Aplicadas:

+ +
+ +
+

⚠️ ¿No fuiste tú?

+

Si no intentaste recuperar tu contraseña, esto podría indicar que alguien está tratando de acceder a tu cuenta.

+

Te recomendamos:

+ +
+ +
+

+ Esta es una notificación automática de seguridad.
+ Si tienes preguntas, contacta a nuestro equipo de soporte. +

+
+
+ + "; + + // Enviar el correo + $mail->send(); + return true; + + } catch (Exception $e) { + error_log("❌ Error al enviar notificación de seguridad: " . $e->getMessage()); + return false; + } +} + function cambiarPasswordVista() { if (!isset($_SESSION['recuperacion_autorizada'])) { diff --git a/views/login/verificar_codigo.php b/views/login/verificar_codigo.php index 1631935..16f25fe 100644 --- a/views/login/verificar_codigo.php +++ b/views/login/verificar_codigo.php @@ -54,6 +54,18 @@ $mensajeMantenimiento = $config['mensaje_mantenimiento'] ?? 'El sistema se encue .form-control { border-radius: 6px; + transition: all 0.3s ease; + } + + .form-control.error { + border-color: #dc3545; + background-color: #fff5f5; + animation: shake 0.5s ease-in-out; + } + + .form-control.success { + border-color: #28a745; + background-color: #f8fff9; } footer { @@ -75,14 +87,111 @@ $mensajeMantenimiento = $config['mensaje_mantenimiento'] ?? 'El sistema se encue background: rgba(0, 0, 0, 0.5); z-index: 5; } + #mensaje { - transition: opacity 0.3s ease; + transition: all 0.3s ease; + min-height: 24px; } + + #btnReenviarRespaldo { + background: none; + border: none; + color: ; + text-decoration: underline; + cursor: pointer; + font-size: 14px; + padding: 0; + transition: color 0.3s ease; + } + + #btnReenviarRespaldo:hover { + color: ; + } + + #btnReenviarRespaldo:disabled { + color: #ccc; + cursor: not-allowed; + text-decoration: none; + } + + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .loading { + position: relative; + pointer-events: none; + } + + .loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + border: 2px solid transparent; + border-top: 2px solid #fff; + border-radius: 50%; + animation: spin 1s linear infinite; + transform: translate(-50%, -50%); + } + + @keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } + } + + @keyframes spin { + 0% { transform: translate(-50%, -50%) rotate(0deg); } + 100% { transform: translate(-50%, -50%) rotate(360deg); } + } + + @keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } + } + + .fade-in { + animation: fadeIn 0.3s ease-out; + } + @media (max-height: 600px) { footer { position: static !important; } } + + /* Estilos para input de código */ + #codigo { + font-size: 18px; + letter-spacing: 3px; + text-align: center; + font-weight: bold; + } + + .input-group { + position: relative; + } + + .clear-input { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #ccc; + cursor: pointer; + z-index: 5; + display: none; + } + + .clear-input:hover { + color: #666; + } @@ -128,6 +237,10 @@ $mensajeMantenimiento = $config['mensaje_mantenimiento'] ?? 'El sistema se encue

Verifica tu código

+

+ + Hemos enviado un código de 6 dígitos a tu correo electrónico +

+
- - + +
+ + +
+
+ + Ingresa el código de 6 dígitos que recibiste por correo +
- + + - +
- +
-
+
+ + +
-
+
+ + +
+ + +
+ +
@@ -171,64 +322,276 @@ $mensajeMantenimiento = $config['mensaje_mantenimiento'] ?? 'El sistema se encue document.addEventListener('DOMContentLoaded', function () { const form = document.getElementById('formCodigo'); const mensaje = document.getElementById('mensaje'); - const inputCodigo = form.querySelector('input[name="codigo"]'); + const inputCodigo = document.getElementById('codigo'); + const btnVerificar = document.getElementById('btnVerificar'); + const btnReenviar = document.getElementById('btnReenviarRespaldo'); + const clearBtn = document.getElementById('clearCode'); + const intentosInfo = document.getElementById('intentosInfo'); - inputCodigo.addEventListener('input', () => { - mensaje.innerHTML = ''; - mensaje.classList.remove("text-danger", "text-success"); + let intentosRealizados = 0; + const maxIntentos = 5; + + // Auto-focus en el input al cargar + inputCodigo.focus(); + + // Mostrar/ocultar botón de limpiar + inputCodigo.addEventListener('input', function() { + clearBtn.style.display = this.value.length > 0 ? 'block' : 'none'; + + // Limpiar mensajes previos + limpiarMensajes(); + + // Remover clases de error/éxito + inputCodigo.classList.remove('error', 'success'); + + // Solo permitir números + this.value = this.value.replace(/[^0-9]/g, ''); + + // Actualizar contador visual + actualizarContadorVisual(); }); + // Limpiar input + clearBtn.addEventListener('click', function() { + inputCodigo.value = ''; + inputCodigo.focus(); + clearBtn.style.display = 'none'; + limpiarMensajes(); + inputCodigo.classList.remove('error', 'success'); + }); + + // Permitir solo números en tiempo real + inputCodigo.addEventListener('keypress', function(e) { + if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'Tab', 'Enter'].includes(e.key)) { + e.preventDefault(); + } + }); + + // Auto-envío cuando se completen 6 dígitos (opcional) + inputCodigo.addEventListener('input', function() { + if (this.value.length === 6) { + // Opcional: enviar automáticamente después de un breve delay + // setTimeout(() => form.dispatchEvent(new Event('submit')), 500); + } + }); + + // Manejo del formulario form.addEventListener('submit', function(e) { e.preventDefault(); const codigo = inputCodigo.value.trim(); - if (!codigo || codigo.length !== 6) { - mensaje.textContent = "❌ Ingresa un código de 6 dígitos."; - mensaje.classList.add("text-danger"); - return; - } + if (!validarCodigo(codigo)) return; + enviarCodigo(codigo); + }); + + // Validación del código + function validarCodigo(codigo) { + if (!codigo) { + mostrarError("❌ Por favor, ingresa el código."); + return false; + } + + if (codigo.length !== 6) { + mostrarError("❌ El código debe tener exactamente 6 dígitos."); + inputCodigo.classList.add('error'); + return false; + } + + if (!/^\d{6}$/.test(codigo)) { + mostrarError("❌ El código solo debe contener números."); + inputCodigo.classList.add('error'); + return false; + } + + return true; + } + + // Enviar código para verificación + function enviarCodigo(codigo) { + // Mostrar estado de carga + btnVerificar.disabled = true; + btnVerificar.classList.add('loading'); + btnVerificar.innerHTML = 'Verificando...'; + const formData = new FormData(); formData.append('codigo', codigo); fetch('/IMPORTADORES/login/verificarCodigo', { method: 'POST', body: formData, - credentials: 'same-origin' // ✅ Importante para mantener sesión + credentials: 'same-origin' }) .then(response => response.json()) .then(data => { if (data.success) { - mensaje.textContent = data.message; - mensaje.classList.remove("text-danger"); - mensaje.classList.add("text-success"); - + // Código correcto + mostrarExito(data.message); + inputCodigo.classList.add('success'); + inputCodigo.disabled = true; + // Redirigir al formulario de cambio de contraseña setTimeout(() => { window.location.href = '/IMPORTADORES/login/cambiarPasswordVista'; }, 1500); + } else { - mensaje.textContent = "❌ " + data.message; - mensaje.classList.remove("text-success"); - mensaje.classList.add("text-danger"); + // Código incorrecto + manejarCodigoIncorrecto(data); } }) - .catch(() => { - mensaje.textContent = "❌ Error al verificar el código."; - mensaje.classList.remove("text-success"); - mensaje.classList.add("text-danger"); + .catch(error => { + console.error('Error:', error); + mostrarError("❌ Error de conexión. Intenta nuevamente."); + inputCodigo.classList.add('error'); + limpiarYEnfocarInput(); + }) + .finally(() => { + // Restaurar botón + btnVerificar.disabled = false; + btnVerificar.classList.remove('loading'); + btnVerificar.innerHTML = 'Verificar código'; + }); + } + + // Manejar código incorrecto + function manejarCodigoIncorrecto(data) { + intentosRealizados++; + + mostrarError(data.message); + inputCodigo.classList.add('error'); + + // Limpiar input y enfocar para nuevo intento + limpiarYEnfocarInput(); + + // Actualizar contador de intentos + actualizarContadorIntentos(); + + // Si se bloqueó el código + if (data.blocked) { + bloquearFormulario(); + } + } + + // Limpiar input y enfocar + function limpiarYEnfocarInput() { + setTimeout(() => { + inputCodigo.value = ''; + inputCodigo.classList.remove('error'); + inputCodigo.focus(); + clearBtn.style.display = 'none'; + }, 1500); // Esperar 1.5 segundos antes de limpiar + } + + // Actualizar contador visual + function actualizarContadorVisual() { + const longitud = inputCodigo.value.length; + if (longitud > 0) { + inputCodigo.setAttribute('placeholder', '0'.repeat(6 - longitud) + inputCodigo.value); + } else { + inputCodigo.setAttribute('placeholder', '000000'); + } + } + + // Actualizar contador de intentos + function actualizarContadorIntentos() { + if (intentosRealizados > 0) { + const restantes = maxIntentos - intentosRealizados; + intentosInfo.innerHTML = ` + + Intentos restantes: ${restantes} de ${maxIntentos} + `; + intentosInfo.classList.add('fade-in'); + } + } + + // Bloquear formulario cuando se exceden intentos + function bloquearFormulario() { + inputCodigo.disabled = true; + btnVerificar.disabled = true; + btnReenviar.disabled = true; + + intentosInfo.innerHTML = ` + + Código bloqueado por seguridad + `; + + // Redirigir a solicitar nuevo código después de 5 segundos + setTimeout(() => { + window.location.href = '/IMPORTADORES/login/recuperar'; + }, 5000); + } + + // Funciones de utilidad para mensajes + function mostrarMensaje(texto, tipo) { + mensaje.innerHTML = `
${texto}
`; + mensaje.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + function mostrarError(texto) { + mostrarMensaje(`${texto}`, 'text-danger'); + } + + function mostrarExito(texto) { + mostrarMensaje(`${texto}`, 'text-success'); + } + + function mostrarInfo(texto) { + mostrarMensaje(`${texto}`, 'text-info'); + } + + function limpiarMensajes() { + mensaje.innerHTML = ''; + } + + // Manejo del botón de reenvío + btnReenviar.addEventListener("click", function() { + const btnOriginalText = this.innerHTML; + + // Deshabilitar botón temporalmente + this.disabled = true; + this.innerHTML = 'Enviando...'; + + fetch("/IMPORTADORES/login/reenviarCodigo", { + method: "POST", + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: "accion=reenviarCodigo" + }) + .then(response => response.json()) + .then(data => { + mostrarInfo(data.message); + + // Habilitar botón después de 30 segundos + setTimeout(() => { + this.disabled = false; + this.innerHTML = btnOriginalText; + }, 30000); + }) + .catch(error => { + console.error('Error:', error); + mostrarError("❌ Error al reenviar código."); + + // Restaurar botón en caso de error + this.disabled = false; + this.innerHTML = btnOriginalText; }); }); - }); - document.getElementById("btnReenviarRespaldo").addEventListener("click", () => { - fetch("/IMPORTADORES/login/reenviarCodigo", { - method: "POST", - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - body: "accion=reenviarCodigo" - }) - .then(response => response.json()) - .then(data => alert(data.message)); + // Manejar tecla Enter en cualquier parte del formulario + document.addEventListener('keypress', function(e) { + if (e.key === 'Enter' && !btnVerificar.disabled) { + form.dispatchEvent(new Event('submit')); + } + }); + + // Prevenir paste de contenido no numérico + inputCodigo.addEventListener('paste', function(e) { + e.preventDefault(); + const paste = (e.clipboardData || window.clipboardData).getData('text'); + const numericPaste = paste.replace(/[^0-9]/g, '').substring(0, 6); + this.value = numericPaste; + this.dispatchEvent(new Event('input')); + }); });