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\nIntroducción
\n\n\n\nTOTP 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\nEsta 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\nPrerrequisitos
\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.
Instalar la dependencia para generar QR (se usa Endroid en el ejemplo):
\n\n\n\ncomposer require endroid/qr-code\n\n\n\nDesarrollo
\n\n\n\nProcedimiento
\n\n\n\nResumen de pasos implementados en el proyecto:
\n\n\n\n- Agregar campos TOTP a la entidad User y crear migración.
- Crear un servicio TotpService con generación/verificación de códigos y generación del URI otpauth://.
- Crear formularios y controladores para habilitar, verificar y deshabilitar 2FA.
- Registrar un EventSubscriber que intercepte peticiones y redirija a la verificación cuando corresponda.
A continuación se muestran fragmentos clave (no es necesario copiar todo el archivo, adapte según su proyecto).
\n\n\n\nEntidad: 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\nMigració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\nServicio 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\nForm 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\nControlador 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\nSuscriptor 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\nSeguridad 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\nsecurity:\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\nProtecciones 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\nEjemplos
\n\n\n\nGenerar 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\nVerificar 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\nChecklist
\n\n\n\n- Agregar totp_enabled y totp_secret en la entidad y migrar.
- Registrar TotpService en el contenedor y probar generación/verificación localmente.
- Crear formularios y controladores para setup/disable/verify.
- Implementar EventSubscriber para forzar verificación tras el login.
- Configurar rate limiting y proteger rutas sensibles.
- Planificar recuperación: backup codes o proceso de soporte.
Conclusión
\n\n\n\nImplementar 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\nPriorice 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