# Recuperación de contraseña **Estado:** ✅ IMPLEMENTADO (2026-04-14) ## Problema El login no tenía opción de "¿Olvidaste tu contraseña?". Si un user olvidaba la suya, la única recuperación posible era: el admin del tenant le reseteaba manualmente (cambiando el hash directo en BD o recreando el user). Fricción alta + riesgo de que el admin viera la nueva contraseña. ## Flujo implementado ``` /login → "¿Olvidaste tu contraseña?" link ↓ /forgot-password → email → POST /auth/password-reset/request (rate-limit 3/h) ↓ Backend: valida user, invalida tokens previos, genera token 32-bytes hex, guarda en DB con expiresAt = now + 1h, envía email vía Nodemailer ↓ Email con link https://…/reset-password?token=abc123 ↓ Usuario abre link → /reset-password?token=xxx → nueva password + confirmación ↓ POST /auth/password-reset/confirm (rate-limit 10/h) ↓ Backend: valida token (exists, !used, !expired), actualiza passwordHash, marca token usado, borra TODOS los refresh tokens del user (cierra sesiones activas), audita ↓ Redirect a /login con mensaje de éxito ``` ## Schema ```prisma model PasswordResetToken { id String @id @default(uuid()) userId String @map("user_id") token String @unique expiresAt DateTime @map("expires_at") usedAt DateTime? @map("used_at") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([expiresAt]) @@map("password_reset_tokens") } ``` ## Endpoints | Endpoint | Rate limit | Request | Response | |---------|-----------|---------|----------| | `POST /auth/password-reset/request` | 3/h per IP | `{ email }` | `{ message }` genérico (siempre 200 aunque email no exista) | | `POST /auth/password-reset/confirm` | 10/h per IP | `{ token, newPassword }` | `{ message }` success, o 400 con razón (inválido/usado/expirado/pwd corto) | ## Seguridad | Vector de ataque | Defensa | |------------------|---------| | **Enumeration** (descubrir qué emails existen) | Request endpoint responde 200 con mensaje idéntico independiente de si el email existe o no. `console.log` interno diferencia pero no al cliente. | | **Brute force del token** | Token 32 bytes hex = 256-bit entropía, imposible adivinar. Además rate limit 10/h por IP en confirm endpoint. | | **Spam de emails de reset** | Rate limit 3/h por IP en request endpoint (previene atacante que intenta enviar emails phishing-like desde nuestro sistema a múltiples victims). | | **Token reuse** | Campo `usedAt` marca single-use. Segundo intento falla con "Este enlace ya fue usado". | | **Captura del token en tránsito** | HTTPS + token va en URL como query param. Aceptable para email links — si el usuario controla su email, controla el token. | | **Session hijack post-compromise** | Al completar reset, **`DELETE FROM refresh_tokens WHERE user_id = ?`** — todas las sesiones activas del user quedan inválidas. Forza re-login. | | **Password débil** | Mínimo 8 caracteres validado en backend (Zod) + frontend. | | **Token activo tras nuevo request** | Al generar nuevo token, todos los tokens previos no usados del mismo user se marcan como `usedAt = now()` (efectivamente invalidados). | ## Audit Dos eventos nuevos en `audit_log`, visibles en `/admin/audit-log`: - `user.password_reset_requested` — metadata `{ email }` - `user.password_reset_completed` — metadata `{ email }` Útil en forense: "¿cuándo este user reseteó? ¿múltiples requests indican actividad sospechosa?" ## Archivos ### Backend - `apps/api/prisma/schema.prisma` — modelo `PasswordResetToken` + relación en `User` - `apps/api/src/services/auth.service.ts` — `requestPasswordReset()`, `confirmPasswordReset()` - `apps/api/src/services/email/email.service.ts` — `sendPasswordReset()` - `apps/api/src/services/email/templates/password-reset.ts` (nuevo) — template con link + advertencia de expiración - `apps/api/src/controllers/auth.controller.ts` — `requestPasswordReset`, `confirmPasswordReset` handlers + Zod validation - `apps/api/src/routes/auth.routes.ts` — 2 rutas nuevas + 2 rate limiters ### Frontend - `apps/web/lib/api/auth.ts` — `requestPasswordReset()`, `confirmPasswordReset()` - `apps/web/app/(auth)/login/page.tsx` — link "¿Olvidaste tu contraseña?" - `apps/web/app/(auth)/forgot-password/page.tsx` (nuevo) — form email + vista de confirmación - `apps/web/app/(auth)/reset-password/page.tsx` (nuevo) — form nueva password con confirmación, handling de token inválido/ausente ## Consideraciones operacionales ### SMTP en dev Sin `SMTP_USER`/`SMTP_PASS` en `.env`, los emails se logean a consola del API. El link de reset aparece en el log con el token — útil para testing local sin infra de email configurada. ### Email en producción Requiere SMTP configurado (Nodemailer + Gmail Workspace ya está en el stack). Template usa `baseTemplate` que incluye logo y footer consistente con los demás emails. ### Retención de tokens Los tokens expirados o usados no se borran automáticamente. No es crítico (índice en `expiresAt` + FK Cascade al user). Para limpieza futura: cron que borre `WHERE expiresAt < NOW() - INTERVAL '30 days' OR usedAt IS NOT NULL AND usedAt < NOW() - INTERVAL '30 days'`. ### Password policy Mínimo 8 caracteres. No validación de complejidad (mayúsculas, números, símbolos) porque: - La policy complica sin agregar seguridad real contra ataques modernos (brute-force offline con hash → no importa la complejidad si es suficientemente larga) - bcrypt 12 rounds + rate limit ya previenen ataques online Si en el futuro se quiere endurecer, agregar validación en `authService.confirmPasswordReset` + mensaje claro al user. ## Pendientes 1. **UI "cambiar contraseña desde mi cuenta"** — user autenticado cambiando su propia password (sin flow de email). Comparte helper `hashPassword` + incrementa lógica similar. Pospuesto hasta que se implemente `jwt-revocation` (que también necesita endpoint de password change). 2. **Cron de limpieza** de tokens expirados/usados > 30 días. 3. **Notificación al completar** — email adicional "tu contraseña fue cambiada, si no fuiste tú contacta soporte". Previene takeover silencioso si hay compromiso. 4. **2FA para recuperación** — si el user tiene 2FA activado (feature futura), pedir código además del token del email antes de resetear.