Saltar al contenido principal

Autenticación multifactor (MFA/2FA)

La autenticación multifactor (MFA, multi-factor authentication) añade una segunda (o tercera) capa de verificación después de la contraseña. Si la contraseña de un usuario se filtra o es adivinada, el atacante sigue sin poder acceder sin el segundo factor.

Factores de autenticación

Los factores se clasifican en tres categorías:

FactorTipoEjemplos
Contraseña, PIN, pregunta secretaAlgo que sabesContraseña, frase de paso
TOTP, SMS, clave físicaAlgo que tienesGoogle Authenticator, YubiKey, código SMS
BiometríaAlgo que eresHuella dactilar, reconocimiento facial

Para que sea verdaderamente MFA, los factores deben ser de categorías distintas. Contraseña + PIN son dos factores de la misma categoría (ambos "algo que sabes") y no se consideran MFA real.

TOTP

El TOTP (Time-based One-Time Password) es el estándar más común para 2FA (two-factor authentication) sin SMS. Genera códigos de 6 dígitos que cambian cada 30 segundos, basándose en la hora actual y un secreto compartido. Funciona sin conexión a internet.

Estándar: RFC 6238. Implementado por Google Authenticator, Authy, 1Password, Bitwarden, etc.

Flujo de activación y uso

Activación:

  1. Usuario solicita activar 2FA.
  2. Servidor genera un secreto TOTP único para el usuario.
  3. Servidor devuelve el secreto como QR (otpauth://) y clave manual.
  4. Usuario escanea el QR con su app de autenticación.
  5. Usuario introduce el primer código para confirmar que la app funciona.
  6. Servidor activa 2FA para ese usuario y genera códigos de recuperación.

Login con 2FA activo:

  1. Usuario introduce usuario + contraseña → OK.
  2. Servidor devuelve un estado intermedio (no emite token aún).
  3. Usuario introduce el código de 6 dígitos de su app.
  4. Servidor verifica el código → emite token de sesión.

Implementación

import speakeasy from 'speakeasy';
import QRCode from 'qrcode';
import crypto from 'node:crypto';

// Paso 1: Generar el secreto y el QR para el usuario
export const initiate2FA = async (req, res) => {
const user = await findUserById(req.user.sub);

const secret = speakeasy.generateSecret({
name: `MiApp (${user.email})`, // Aparece en la app de autenticación
length: 32,
});

// Guardar el secreto temporalmente (aún no activado)
await db.savePendingTotpSecret(user.id, secret.base32);

const qrCodeDataUrl = await QRCode.toDataURL(secret.otpauth_url);

res.json({
qrCode: qrCodeDataUrl,
manualKey: secret.base32, // Para usuarios que no pueden escanear el QR
});
};

// Paso 2: Verificar el primer código e activar 2FA
export const confirm2FA = async (req, res) => {
const { token } = req.body; // Código de 6 dígitos introducido por el usuario
const pendingSecret = await db.getPendingTotpSecret(req.user.sub);

const isValid = speakeasy.totp.verify({
secret: pendingSecret,
encoding: 'base32',
token,
window: 1, // Acepta el código actual ± 30 segundos (margen de reloj)
});

if (!isValid) return res.status(400).json({ error: 'Código incorrecto. Inténtalo de nuevo.' });

// Activar 2FA y generar códigos de recuperación
const backupCodes = generateBackupCodes();
const hashedCodes = backupCodes.map(code =>
crypto.createHash('sha256').update(code).digest('hex')
);

await db.activateTotp(req.user.sub, pendingSecret, hashedCodes);

// Mostrar los códigos de recuperación una sola vez
res.json({
message: '2FA activado correctamente.',
backupCodes, // Mostrar una sola vez. El usuario debe guardarlos.
});
};

// Paso 3: Verificar el código TOTP durante el login
export const verifyTotpOnLogin = async (req, res) => {
const { pendingUserId, token } = req.body;

// pendingUserId viene de un token temporal emitido tras validar la contraseña
const user = await findUserById(pendingUserId);
if (!user?.totpEnabled) return res.status(400).json({ error: 'Usuario sin 2FA' });

const isValid = speakeasy.totp.verify({
secret: user.totpSecret,
encoding: 'base32',
token,
window: 1,
});

if (!isValid) return res.status(401).json({ error: 'Código 2FA incorrecto' });

// Emitir token de sesión completo
const accessToken = generateAccessToken(user);
res.json({ accessToken });
};

Códigos de recuperación

Siempre ofrece códigos de un solo uso (backup codes) al activar 2FA. Son la salvaguarda si el usuario pierde acceso a su app de autenticación (robo, pérdida, cambio de móvil).

export const generateBackupCodes = (count = 8) => {
// Formato legible: XXXXX-XXXXX
return Array.from({ length: count }, () => {
const part1 = crypto.randomBytes(3).toString('hex').toUpperCase();
const part2 = crypto.randomBytes(3).toString('hex').toUpperCase();
return `${part1}-${part2}`;
});
// Ejemplo: ['A3F2B1-C9D4E5', 'F6A7B8-C9D0E1', ...]
};

// Usar un código de recuperación
export const useBackupCode = async (userId, inputCode) => {
const user = await findUserById(userId);
const inputHash = crypto.createHash('sha256').update(inputCode).digest('hex');

const codeIndex = user.backupCodeHashes.findIndex(h => h === inputHash);
if (codeIndex === -1) return false;

// Eliminar el código usado (son de un solo uso)
user.backupCodeHashes.splice(codeIndex, 1);
await db.updateBackupCodes(userId, user.backupCodeHashes);

return true;
};

SMS OTP

El usuario recibe un código de 6 dígitos por SMS al intentar hacer login.

La ventaja es que es más familiar para usuarios no técnicos, no requiere instalar ninguna app.

Desventajas:

  • Menos seguro que TOTP. Vulnerable a ataques de SIM swapping (el atacante convence a la operadora para transferir el número a otra SIM) e interceptación de SS7 (protocolo de la red telefónica con vulnerabilidades conocidas).
  • Requiere un servicio externo de pago como, por ejemplo, Twilio.
  • No funciona sin cobertura/roaming.
import twilio from 'twilio';
const client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);

export const sendSmsOtp = async (phoneNumber) => {
const otp = Math.floor(100000 + Math.random() * 900000).toString(); // 6 dígitos
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutos

// Guardar OTP hasheado en BD con TTL
await db.saveSmsOtp(phoneNumber, hashOtp(otp), expiresAt);

await client.messages.create({
body: `Tu código de verificación es: ${otp}. Expira en 10 minutos.`,
from: process.env.TWILIO_PHONE_NUMBER,
to: phoneNumber,
});
};
Recomendación

Usa TOTP siempre que puedas. Utiliza los SMS para casos donde sea imprescindible por experiencia de usuario (UX) o porque no haya otra alternativa.

WebAuthn / FIDO2 (mención)

Web Authentication (WebAuthn) es el estándar más moderno y seguro. Usa criptografía de clave pública: el dispositivo del usuario (smartphone, YubiKey, TouchID, Windows Hello) genera un par de claves. La clave privada nunca sale del dispositivo. Elimina completamente el phishing.

Su implementación es más compleja; librerías como @simplewebauthn/server abstraen gran parte del proceso.

Librerías relevantes

LibreríaDescripción
speakeasyGeneración y verificación de TOTP/HOTP. Madura y ampliamente usada.
otplibAlternativa moderna a speakeasy con mejor soporte de TypeScript.
qrcodeGenera QR codes (necesario para el flujo de activación de TOTP).
twilioSDK oficial de Twilio para envío de SMS OTP.
@simplewebauthn/serverImplementación de WebAuthn/FIDO2 en Node.js.