Files
HoruxDespachosNuevo/docs/plans/2026-04-14-password-reset.md

120 lines
6.4 KiB
Markdown

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