Etiqueta: migraciones

  • Implementar TOTP (MFA) en Symfony 7: guía práctica

    Implementar TOTP (MFA) en Symfony 7: guía práctica

    \n

    En esta guía práctica se explica cómo añadir autenticación multifactor TOTP (Time-based One-Time Password) a una aplicación Symfony 7. Incluye el servicio TOTP, cambios en la entidad, migración, controladores, formulario, suscriptor de eventos y recomendaciones de seguridad.

    \n\n\n\n\n\n\n\n

    Introducción

    \n\n\n\n

    TOTP es el estándar (RFC 6238) que usan Google Authenticator, Authy y similares. Genera códigos de un solo uso a partir de un secreto compartido y la hora actual.

    \n\n\n\n

    Esta implementación está pensada para integrarse con el sistema de autenticación de Symfony: genera secretos, muestra un QR para el usuario, verifica códigos en el login y obliga a verificar tras el login usando la sesión.

    \n\n\n\n

    Prerrequisitos

    \n\n\n\n
    • Proyecto Symfony 7 con sistema de autenticación ya configurado.
    • Doctrine ORM (para persistir el secreto).
    • Conocimientos básicos de seguridad en Symfony.
    \n\n\n\n

    Instalar la dependencia para generar QR (se usa Endroid en el ejemplo):

    \n\n\n\n
    composer require endroid/qr-code
    \n\n\n\n

    Desarrollo

    \n\n\n\n

    Procedimiento

    \n\n\n\n

    Resumen de pasos implementados en el proyecto:

    \n\n\n\n
    1. Agregar campos TOTP a la entidad User y crear migración.
    2. Crear un servicio TotpService con generación/verificación de códigos y generación del URI otpauth://.
    3. Crear formularios y controladores para habilitar, verificar y deshabilitar 2FA.
    4. Registrar un EventSubscriber que intercepte peticiones y redirija a la verificación cuando corresponda.
    \n\n\n\n

    A continuación se muestran fragmentos clave (no es necesario copiar todo el archivo, adapte según su proyecto).

    \n\n\n\n

    Entidad: añadir campos para estado y secreto TOTP.

    \n\n\n\n
    <?php\n// src/Entity/User.php (fragmento)\n\n#[ORM\Column(name: 'totp_enabled', type: 'boolean', options: ['default' => false])]\nprivate bool $totpEnabled = false;\n\n#[ORM\Column(name: 'totp_secret', type: 'string', length: 64, nullable: true)]\nprivate ?string $totpSecret = null;\n\npublic function isTotpEnabled(): bool\n{\n    return $this->totpEnabled;\n}\n\npublic function setTotpEnabled(bool $enabled): self\n{\n    $this->totpEnabled = $enabled;\n    return $this;\n}\n\npublic function getTotpSecret(): ?string\n{\n    return $this->totpSecret;\n}\n\npublic function setTotpSecret(?string $secret): self\n{\n    $this->totpSecret = $secret ? trim($secret) : null;\n    return $this;\n}\n
    \n\n\n\n

    Migración: ejemplo que añade las columnas totp_enabled y totp_secret.

    \n\n\n\n
    <?php\n// migrations/Version20240101000000.php (fragmento)\n\npublic function up(Schema $schema): void\n{\n    $table = $schema->getTable('users');\n    if (!$table->hasColumn('totp_enabled')) {\n        $table->addColumn('totp_enabled', 'boolean', ['default' => false]);\n    }\n    if (!$table->hasColumn('totp_secret')) {\n        $table->addColumn('totp_secret', 'string', ['length' => 64, 'notnull' => false]);\n    }\n}\n
    \n\n\n\n

    Servicio TotpService: responsabilidades principales — generar secreto, generar/verificar códigos y crear URI de aprovisionamiento.

    \n\n\n\n
    <?php\n// src/Service/TotpService.php (fragmento)\n\npublic function generateSecret(int $length = 32): string\n{\n    $bytes = random_bytes($length);\n    return rtrim(strtr(base64_encode($bytes), '+/', 'XY'), '=');\n}\n\npublic function getProvisioningUri(string $label, string $issuer, string $secret): string\n{\n    $label = rawurlencode($label);\n    $issuerEncoded = rawurlencode($issuer);\n\n    return sprintf(\n        'otpauth://totp/%s?secret=%s&issuer=%s&period=%d&digits=%d&algorithm=%s',\n        $label,\n        $secret,\n        $issuerEncoded,\n        $this->period,\n        $this->digits,\n        strtoupper($this->algorithm)\n    );\n}\n\npublic function verifyCode(string $secret, string $code, int $window = 1): bool\n{\n    $now = time();\n    $code = trim($code);\n    for ($i = -$window; $i <= $window; $i++) {\n        $timestamp = $now + ($i * $this->period);\n        if (hash_equals($this->getCode($secret, $timestamp), $code)) {\n            return true;\n        }\n    }\n    return false;\n}\n
    \n\n\n\n

    Form types: formularios sencillos para habilitar y deshabilitar 2FA que aceptan el código de 6 dígitos.

    \n\n\n\n
    <?php\n// src/Form/User/TwoFactorEnableType.php (fragmento)\n\n$builder->add('code', TextType::class, [\n    'label' => 'Authentication code',\n    'attr' => [\n        'autocomplete' => 'one-time-code',\n        'inputmode' => 'numeric',\n        'pattern' => '[0-9]*',\n        'maxlength' => 6,\n    ],\n]);\n
    \n\n\n\n

    Controlador de ajustes: genera secreto temporal en sesión, muestra QR y confirma el código antes de persistir el secreto en la base de datos.

    \n\n\n\n
    <?php\n// src/Controller/User/SettingsController.php (fragmento)\n\n$pendingSecret = $session->get('2fa_pending_secret');\n// Regenerar si no existe o expiró\nif (!$pendingSecret || (time() - $session->get('2fa_pending_secret_time', 0)) > 600) {\n    $pendingSecret = $totpService->generateSecret();\n    $session->set('2fa_pending_secret', $pendingSecret);\n    $session->set('2fa_pending_secret_time', time());\n}\n\n$uri = $totpService->getProvisioningUri($user->getEmail(), $request->getHttpHost(), $pendingSecret);\n$qrSvg = $totpService->generateInlineSvgQr($uri, 180);\n
    \n\n\n\n

    Suscriptor de eventos: intercepta solicitudes y redirige a la ruta de verificación si el usuario tiene 2FA activado pero no ha verificado la sesión.

    \n\n\n\n
    <?php\n// src/EventSubscriber/UserTwoFactorSubscriber.php (fragmento)\n\nif (!$event->isMainRequest()) {\n    return;\n}\n\n$request = $event->getRequest();\nif (!str_starts_with($request->getPathInfo(), '/panel')) {\n    return; // limitar al panel de usuario\n}\n\n$user = $this->getAuthenticatedUser();\nif (!$user || !$user->isTotpEnabled() || !$user->getTotpSecret()) {\n    return;\n}\n\n$session = $this->getSession();\nif ($session && $session->get(self::SESSION_KEY) === true) {\n    return; // ya verificado\n}\n\n$event->setResponse(new RedirectResponse($this->urlGenerator->generate('user_2fa')));\n
    \n\n\n\n

    Seguridad en la configuración del firewall: redirigir al usuario a la ruta /panel/2fa tras el login es una opción sencilla para iniciar el flujo de verificación.

    \n\n\n\n
    security:\n    firewalls:\n        user:\n            pattern: ^/panel\n            provider: user_provider\n            lazy: true\n            form_login:\n                login_path: /panel/\n                check_path: /panel/\n                enable_csrf: true\n                default_target_path: /panel/2fa\n            logout:\n                path: /panel/logout\n                target: /panel/\n\n    access_control:\n        - { path: ^/panel/?$, roles: PUBLIC_ACCESS }\n        - { path: ^/panel, roles: IS_AUTHENTICATED_REMEMBERED }\n
    \n\n\n\n

    Protecciones adicionales sugeridas: limitación de intentos (rate limiting), comparación en tiempo constante (hash_equals) y manejo cuidadoso de sesiones y secretos temporales.

    \n\n\n\n
    <?php\n// Ejemplo de rate limiting dentro del controlador de verificación\n$limiter = $twoFactorLimiter->create($request->getClientIp());\nif (!$limiter->consume()->isAccepted()) {\n    throw new TooManyRequestsHttpException(null, 'Too many attempts. Please wait.');\n}\n
    \n\n\n\n

    Ejemplos

    \n\n\n\n

    Generar un URI de aprovisionamiento (otpauth) que puedan escanear las apps autenticadoras:

    \n\n\n\n
    // Uso en controlador\n$label = $user->getEmail();\n$issuer = $request->getHttpHost();\n$secret = $totpService->generateSecret();\n$uri = $totpService->getProvisioningUri($label, $issuer, $secret);\n$qrSvg = $totpService->generateInlineSvgQr($uri, 180);\n
    \n\n\n\n

    Verificar un código recibido del usuario (ventana de tolerancia = 1 período por defecto):

    \n\n\n\n
    // Verificación\nif ($totpService->verifyCode($storedSecret, $submittedCode)) {\n    // Aceptado: marcar sesión como verificada\n    $session->set('2fa_verified', true);\n} else {\n    // Código inválido\n}\n
    \n\n\n\n

    Checklist

    \n\n\n\n
    1. Agregar totp_enabled y totp_secret en la entidad y migrar.
    2. Registrar TotpService en el contenedor y probar generación/verificación localmente.
    3. Crear formularios y controladores para setup/disable/verify.
    4. Implementar EventSubscriber para forzar verificación tras el login.
    5. Configurar rate limiting y proteger rutas sensibles.
    6. Planificar recuperación: backup codes o proceso de soporte.
    \n\n\n\n

    Conclusión

    \n\n\n\n

    Implementar TOTP sin dependencias externas ofrece control y entendimiento del flujo de MFA. El patrón mostrado (servicio TOTP + suscriptor de eventos + verificación en sesión) es ligero y fácil de adaptar a requisitos adicionales como backup codes o WebAuthn.

    \n\n\n\n

    Priorice siempre: comparación en tiempo constante, limitación de intentos y proteger los secretos en reposo. Después de implementar, pruebe el flujo completo en entornos de staging antes de desplegar en producción.

    \n\n
  • Laravel a escala: 5 trucos para consultar millones de registros

    Laravel a escala: 5 trucos para consultar millones de registros

    Este artículo resume cinco técnicas prácticas para consultar millones de filas en Laravel sin agotar memoria ni tiempo CPU. Cada técnica incluye una explicación breve y ejemplos de código listos para usar.

    Aplica estas prácticas en procesos de reporting, ETL o consultas masivas para mantener tiempos de respuesta y uso de recursos controlados.

    Introducción

    En consultas a tablas masivas, prácticas comunes como SELECT * o cargar todo con get() provocan consumo excesivo de RAM y CPU. Las siguientes cinco estrategias reducen uso de recursos y aceleran procesos.

    Prerrequisitos

    Antes de aplicar las técnicas: asegúrate de contar con un esquema de base de datos razonable y mecanismos de cacheo/colas si vas a usar preagregados o jobs en segundo plano.

    • Acceso al código de tus modelos/Eloquent.
    • Permiso para crear índices y migraciones en la base de datos.
    • Un sistema de cache (Redis, Memcached) y, si procede, soporte para jobs/colas.

    Desarrollo

    Procedimiento

    A continuación se presentan las cinco técnicas: seleccionar columnas esenciales, procesar por chunks o stream, indexar y optimizar WHERE, preagregar datos y cachear resultados pesados.

    1. Seleccionar sólo las columnas esenciales

    Evita SELECT * cuando sólo necesitas unas pocas columnas. Reducir columnas reduce CPU, memoria y ancho de banda.

    <?php
    // ❌ Evitar: carga todas las columnas
    $orders = Order::all();
    
    // ✅ Seleccionar sólo lo necesario
    $orders = Order::select('id', 'amount', 'created_at')
        ->whereBetween('created_at', [$start, $end])
        ->get();
    Lenguaje del código: PHP (php)

    En el ejemplo base, reducir de 20 columnas a 3 puede bajar el consumo de memoria de ~1.2 GB a ~200 MB en un conjunto grande de filas.

    2. Procesar por chunks o con cursor (stream)

    No uses get() para millones de filas. chunk() y cursor() procesan en partes y mantienen baja la memoria.

    <?php
    // Procesar en bloques de 10.000
    Order::where('status', 'completed')
        ->select('id', 'amount')
        ->chunk(10000, function ($orders) {
            foreach ($orders as $order) {
                // generar fila de reporte
            }
        });
    
    // O procesar con cursor para stream perezoso
    $orders = Order::cursor()->filter(fn ($order) => $order->amount > 1000);
    Lenguaje del código: PHP (php)

    En pruebas citadas, procesar 5M de filas con cursor/chunks reduce consumo de memoria a decenas de MB frente a cientos o más.

    3. Índices y cláusulas WHERE eficientes

    Filtros y ordenamientos sin índices provocan full table scans. Indexa columnas usadas en WHERE, JOIN y ORDER BY y evita patrones ineficientes como LIKE ‘%…%’.

    <?php
    // Ejemplo de migración para agregar índices
    Schema::table('orders', function (Blueprint $table) {
        $table->index('status');
        $table->index('created_at');
    });
    Lenguaje del código: PHP (php)

    Un ejemplo reportado mostró una reducción de tiempo de consulta de 120 s a 0.8 s tras indexar columnas utilizadas en filtros.

    4. Preagregar datos para reportes pesados

    Evita agrupar o sumar decenas de millones de filas en tiempo real. Precalcula resúmenes con jobs nocturnos o usa vistas/materialized views si la BD lo permite.

    <?php
    class GenerateOrderSummary implements ShouldQueue {
        public function handle() {
            $summary = Order::selectRaw('
                DATE(created_at) AS date,
                COUNT(*) AS total_orders,
                SUM(amount) AS revenue
            ')
            ->whereDate('created_at', today()->subDay())
            ->groupBy('date')
            ->first();
    
            Report::updateOrCreate(['date' => $summary->date], (array)$summary);
        }
    }
    Lenguaje del código: PHP (php)

    Con preagregados, la carga de un reporte puede pasar de minutos a decenas de milisegundos; en el ejemplo base, un reporte cargó en ~50 ms.

    5. Cachear consultas costosas

    Para consultas repetidas, guarda el resultado en cache con expiración y/o tags. Invalida o refresca la cache cuando los datos subyacentes cambien.

    <?php
    $report = Cache::remember("daily_sales_report:{$date}", 3600, fn() =>
        Order::whereDate('created_at', $date)
            ->selectRaw('COUNT(*) AS orders, SUM(amount) AS revenue')
            ->first()
    );
    Lenguaje del código: PHP (php)

    Usa Redis o Memcached según tu infraestructura y define políticas claras de invalidación para evitar datos obsoletos.

    Ejemplos

    Ejemplos concretos ya mostrados: selección de columnas, chunk/cursor, migración para índices, job para preagregados y cacheo con Cache::remember.

    Adapta los tamaños de chunk y la expiración del cache a la carga real y al patrón de acceso de tu sistema.

    Checklist

    1. Revisar consultas y eliminar SELECT *.
    2. Usar chunk() o cursor() para procesado masivo.
    3. Indexar columnas usadas en WHERE, JOIN y ORDER BY.
    4. Preagregar resultados pesados con jobs o vistas.
    5. Cachear consultas repetidas y definir estrategia de invalidación.

    Conclusión

    Combinando selección precisa de columnas, procesamiento por partes, buenos índices, preagregados y cacheo, puedes consultar millones de registros en Laravel con uso de recursos razonable y tiempos de respuesta aceptables.

    Empieza por los cambios menos invasivos (select, chunk, cache) y mide impacto antes de introducir cambios más estructurales como materialized views o rediseño de esquemas.