Files
HoruxDespachos/docs/plans/2026-04-14-password-reset.md
2026-04-27 22:09:36 -06:00

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 — modelo PasswordResetToken + relación en User
  • apps/api/src/services/auth.service.tsrequestPasswordReset(), confirmPasswordReset()
  • apps/api/src/services/email/email.service.tssendPasswordReset()
  • apps/api/src/services/email/templates/password-reset.ts (nuevo) — template con link + advertencia de expiración
  • apps/api/src/controllers/auth.controller.tsrequestPasswordReset, confirmPasswordReset handlers + Zod validation
  • apps/api/src/routes/auth.routes.ts — 2 rutas nuevas + 2 rate limiters

Frontend

  • apps/web/lib/api/auth.tsrequestPasswordReset(), 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.