# 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.
