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

Comments

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *