6.4 KiB
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
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— modeloPasswordResetToken+ relación enUserapps/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ónapps/api/src/controllers/auth.controller.ts—requestPasswordReset,confirmPasswordResethandlers + Zod validationapps/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ónapps/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
- 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 implementejwt-revocation(que también necesita endpoint de password change). - Cron de limpieza de tokens expirados/usados > 30 días.
- Notificación al completar — email adicional "tu contraseña fue cambiada, si no fuiste tú contacta soporte". Previene takeover silencioso si hay compromiso.
- 2FA para recuperación — si el user tiene 2FA activado (feature futura), pedir código además del token del email antes de resetear.