> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Autenticación multifactor

> Cuando esto se entregue, una contraseña robada no basta para alcanzar los datos de los clientes de un tenant.

> Traducción. Autoritativo: [`fs-idn-0005-mfa.md`](/modules/identity/features/fs-idn-0005-mfa).

## Contexto

Todo incidente de credential stuffing en esta categoría termina igual: una contraseña reutilizada,
sin segundo factor, y una lectura completa de una base de clientes. MFA es el control con mejor
relación entre esfuerzo e incidentes evitados, y es requisito básico para cualquier plataforma B2B
que guarde datos personales.

Dos posturas de diseño. **TOTP, no SMS.** El SMS es phishable, vulnerable a SIM-swap y cuesta por
mensaje; TOTP funciona sin conexión, no cuesta nada y es lo que ya hace cualquier app autenticadora.
Y **los códigos de recuperación son la vía de recuperación, no el soporte**. Un reset de MFA mediado
por soporte es un objetivo de ingeniería social, y es la puerta que los atacantes efectivamente
golpean.

## Alcance *(normativo)*

* Enrolamiento, verificación y remoción de TOTP.
* Códigos de recuperación de un solo uso, hasheados, generados como conjunto.
* Desafío de MFA durante el sign-in y en la reautenticación.
* Política de exigencia: obligatorio para roles con permisos sensibles.
* Política a nivel de organización que permite exigir MFA a todos.

## Fuera de alcance *(normativo)*

* **SMS como factor.** Explícitamente no se ofrece — phishable, vulnerable a SIM-swap, y cuesta por
  mensaje para menos seguridad que una app gratis.
* Llaves de hardware como factor distinto. Un passkey (FS-IDN-0003) ya cubre ese terreno.
* MFA para members. Se autentican por OTP o token delegado, ambos ya de un solo uso.
* Reset de MFA mediado por soporte — ver la regla 6.

## Comportamiento *(normativo)*

1. TOTP con ventana estándar de 30 segundos y tolerancia de ±1 ventana por deriva de reloj. Un código
   es de **un solo uso**: reproducirlo dentro de su ventana se rechaza.
2. El enrolamiento **no se completa hasta verificar un código**. Guardar un secreto sin probar que el
   usuario puede generar desde él deja gente afuera.
3. Los códigos de recuperación se generan como conjunto de diez al enrolar, se muestran **una sola
   vez**, se guardan hasheados y cada uno es de un solo uso. Usar uno notifica al usuario.
4. Con menos de tres códigos sin usar se advierte y se ofrece regenerar. Regenerar invalida todo el
   conjunto anterior.
5. Desactivar MFA exige reautenticación **y** un segundo factor vigente, y notifica.
6. **El soporte no puede resetear MFA.** Un usuario que pierde su autenticador y sus códigos se
   recupera vía un owner de la organización, y si el último owner es quien quedó fuera, por un proceso
   manual documentado con verificación de identidad deliberadamente lento. Hacerlo fácil es hacer
   fácil el ataque.
7. Un rol con cualquier permiso `is_sensitive` **exige MFA**. Asignarlo a un usuario sin MFA lo
   otorga pero bloquea su uso hasta que enrole, y se lo explica.
8. Un tenant puede fijar una exigencia a nivel de organización, aplicable en el siguiente sign-in.
9. El desafío de MFA tiene rate limit y bloqueo progresivo, como todo camino de credenciales.

## Datos *(normativo)*

| Tabla                         | Invariantes clave                                                                                                                              |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `identity.mfa_factors`        | FK `user_id`; `type` totp; `secret` cifrado en reposo; `verified_at` NOT NULL para un factor activo; `last_used_at`; único por (usuario, tipo) |
| `identity.mfa_recovery_codes` | FK `user_id`; `code_hash`; `consumed_at` nullable; generados e invalidados como conjunto                                                       |

## API *(normativo)*

| Endpoint                                            | Clase      | Permiso                              | Presupuesto  |
| --------------------------------------------------- | ---------- | ------------------------------------ | ------------ |
| `POST /v1/identity/mfa/enroll`                      | Management | dueño de sesión, con reautenticación | p95 \<300 ms |
| `POST /v1/identity/mfa/verify`                      | Runtime    | desafío pendiente                    | p95 \<300 ms |
| `POST /v1/identity/mfa/recovery-codes`              | Management | dueño de sesión, con reautenticación | p95 \<300 ms |
| `POST /v1/identity/mfa/disable`                     | Management | dueño de sesión, reauth + factor     | p95 \<300 ms |
| `PUT /v1/identity/organizations/current/mfa-policy` | Management | `identity.organizations.update`      | p95 \<1 s    |

## Eventos *(normativo)*

`identity.mfa.enabled` y `identity.mfa.disabled`. Ambos producen además una notificación al usuario
por `messaging`, porque un cambio de MFA que el usuario no hizo es la señal más fuerte de que algo
anda mal.

## Criterios de aceptación *(normativo)*

1. Un código TOTP no se puede reproducir dentro de su ventana.
2. Una deriva de ±30 segundos verifica; ±90 no.
3. Un enrolamiento sin código verificado no deja factor activo.
4. Un código de recuperación funciona una vez y notifica; el mismo falla después.
5. Regenerar códigos invalida todos los anteriores.
6. Asignar un rol con permiso sensible a un usuario sin MFA lo otorga y bloquea su uso hasta el
   enrolamiento, con razón tipada.
7. Una exigencia de organización aplica en el siguiente sign-in para cada miembro.
8. Desactivar MFA sin reautenticación y factor vigente se rechaza.
9. **Negativo:** ningún endpoint de soporte, acción de admin ni herramienta de Ops resetea el MFA de
   un usuario.

## Ejecución

Un solo slice, comando síncrono. TOTP por el plugin de Better Auth; secretos cifrados a nivel de
columna. Las notificaciones van por `messaging` mediante evento.

## Preguntas abiertas

| # | Pregunta                                                                                       | Decide | Para                            |
| - | ---------------------------------------------------------------------------------------------- | ------ | ------------------------------- |
| 1 | ¿Obligatorio para todo usuario, o solo para roles con permisos sensibles? (pregunta 1 del PRD) | Daniel | antes de aprobar                |
| 2 | El proceso ante bloqueo del último owner — ¿quién verifica identidad, y cómo?                  | Daniel | antes del lanzamiento comercial |

## Changelog

| Versión | Fecha      | Cambio           | Por qué | Autor                  |
| ------- | ---------- | ---------------- | ------- | ---------------------- |
| 0.1.0   | 2026-08-17 | Borrador inicial | —       | daniel + claude-opus-5 |

## Registro de entrega

*Aún no implementado.*
