CreaRack-SL

El email de bienvenida ya trae un enlace que funciona

Funcionalidadactivecreado Tue Aug 04#core#auth#email#saas#security#django#v1.65.3#f1-pr0

Problema

Al registrar una nueva organización vía /api/signup/, el endpoint enviaba un email de bienvenida con un enlace “Crea tu contraseña” que nacía siempre inválido o expirado. El destinatario veía invalid or expired link y no podía completar el alta.

Causa raíz: El endpoint regeneraba el password DESPUÉS de que la señal post_save calculara el token del link. Como el token hashea el password, el recalculado lo invalidaba inmediatamente. Nunca fue un problema en vivo porque el endpoint no tenía UI pública, pero se habría manifestado en el primer cliente fundador.

Cazado por: Los revisores fríos del plan de F1 en sesión de plan-review (2026-08-04).

Solución (v1.65.3)

Backend

  1. Password fijado una sola vez: el método create_user ya deja el password inusable; no se toca después. Eliminada la llamada redundante a set_unusable_password() y user.save(update_fields=["password"]).
  2. Email enviado DESPUÉS del commit: la señal ahora usa transaction.on_commit(lambda: send_welcome_email(instance)). Así el token se calcula sobre el estado final de la BD, no el intermedio del save.
  3. URLs dinámicas: los links de email ahora salen de la setting SITE_URL en lugar del hardcode https://crearack.com. Permite que STAGE genere enlaces hacia su propio host.
  4. Caducidad del enlace visible: el email declara que el enlace expira en 3 días (valor de PASSWORD_RESET_TIMEOUT), con traducción ES.

Testing (nuevo)

  • tests/api/test_signup_activation.py (109 líneas):
    • Flujo completo: alta → email real en mail.outbox → token que valida con check_token.
    • Caza el bug si alguien lo reintroduce (test pasa en verde, pasa en rojo con la vulnerabilidad).
    • Verifica que SITE_URL se respeta en los enlaces.
    • Cubre también el flujo admin (creación de usuario con password inmediato).
    • Usa django_capture_on_commit_callbacks(execute=True) para que el transaction.on_commit dispare bajo pytest (sin esto el outbox queda vacío).

Detalles técnicos

Cambios en core/signals.py

@receiver(post_save, sender="core.User")
def send_welcome_on_create(sender, instance, created, **kwargs):
    """Send welcome email AFTER the creating transaction commits."""
    if created and instance.email:
        from .email_service import send_welcome_email
        transaction.on_commit(lambda: send_welcome_email(instance))

El on_commit es imprescindible, no cosmético. Sin él, cualquier cambio al usuario dentro de la misma transacción (como el que hacía last_login en los tests) invalidaba el token del enlace.

Cambios en core/email_service.py

def _generate_set_password_url(user):
    token_generator = EmailAwarePasswordResetTokenGenerator()
    token = token_generator.make_token(user)
    uidb36 = int_to_base36(user.pk)
    return f"{settings.SITE_URL}/accounts/password/reset/key/{uidb36}-{token}/"

def send_welcome_email(user):
    # ...
    expires_days = int(getattr(settings, "PASSWORD_RESET_TIMEOUT", 259200)) // 86400
    context = {
        # ...
        "login_url": settings.SITE_URL,
        "set_password_url": _generate_set_password_url(user),
        "expires_days": expires_days,
    }

Nueva setting en config/settings/base.py

SITE_URL = os.getenv("SITE_URL") or "https://crearack.com"
  • PROD: no define SITE_URL → default https://crearack.com.
  • STAGE: fija SITE_URL=https://stage.example.net en su Dokploy.
  • Patrón getenv-or previene el footgun de Dokploy con env vars declaradas como vacías.

Template actualizado

El email de bienvenida ahora muestra:

Este enlace caduca en {{ expires_days }} días. Si ha caducado, usa "¿Has olvidado tu contraseña?" en la página de acceso para solicitar uno nuevo.

Incluye traducción ES en locale/es/LC_MESSAGES/django.po.

Contexto (F1 PR-0)

  • Task: #137 (Preparar alta de cuentas para producción)
  • Iniciativa: Fase 1 (F1) del registro con invitación
  • Precedente de seguridad: v1.65.2 cerró una puerta de registro no intencionada

El mismo síntoma volvió meses después, con una causa raíz distinta: el enlace “crea tu contraseña” del email de bienvenida (y el de restablecer contraseña) mostraba “expirado” al pulsarlo desde el correo, pero funcionaba si se pegaba la URL a mano en el navegador — un falso “discriminante de incógnito” que llevaba tiempo confundiendo al equipo.

Causa raíz: SESSION_COOKIE_SAMESITE = "Strict". Un click desde un email llega como navegación cross-site; con Strict el navegador retiene la cookie de sesión en el salto de la redirección del propio dominio, así que el paso set-password de allauth (que relee el token estashado EN LA SESIÓN) llegaba sin sesión y caía a la rama token_fail. Pegar la URL a mano es una navegación “typed”, y ahí la cookie Strict sí viaja — de ahí el comportamiento fantasma.

Diagnóstico: evidencia de PROD — el primer GET del enlace siempre devolvía 302 (el token era válido), un curl con cookie jar veía el formulario de contraseña, pero el navegador real no. Cazado en vivo durante el ensayo de la organización de prueba de la tarea #256.

Fix: SESSION_COOKIE_SAMESITE = "Lax" (el default de Django). El CSRF real lo cubren el middleware + token de Django, y Lax sigue sin enviar cookies en peticiones POST cross-site, que es donde vive el riesgo real.

Tests (tests/api/test_signup_activation.py):

  • test_activation_link_opens_set_password_form: desenlace completo del click — sigue el link del email, valida el 302 y comprueba que el destino es el formulario (type="password"), no la rama de token caducado.
  • test_session_cookie_samesite_is_lax: guard que congela la política. El CI no puede reproducir el comportamiento real de SameSite (lo aplica el navegador, no el servidor) — este test evita que alguien reintroduzca Strict sin darse cuenta.

Esto no invalida la solución de v1.65.3 (el bug del token de password): son dos causas raíz distintas del mismo síntoma visible, sobre el mismo flujo de activación, cazadas en momentos distintos.

Véase también

  • [[entity—core—service—send-welcome-email]]
  • [[entity—core—service—signup-activation]]
  • [[entity—config—setting—site-url]]
  • [[entity—core—model—user]]
  • [[entity—core—endpoint—signup]]