El email de bienvenida ya trae un enlace que funciona
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
- Password fijado una sola vez: el método
create_userya deja el password inusable; no se toca después. Eliminada la llamada redundante aset_unusable_password()yuser.save(update_fields=["password"]). - 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. - URLs dinámicas: los links de email ahora salen de la setting
SITE_URLen lugar del hardcodehttps://crearack.com. Permite que STAGE genere enlaces hacia su propio host. - 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 concheck_token. - Caza el bug si alguien lo reintroduce (test pasa en verde, pasa en rojo con la vulnerabilidad).
- Verifica que
SITE_URLse 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 eltransaction.on_commitdispare bajo pytest (sin esto el outbox queda vacío).
- Flujo completo: alta → email real en
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→ defaulthttps://crearack.com. - STAGE: fija
SITE_URL=https://stage.example.neten su Dokploy. - Patrón
getenv-orpreviene 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
Segunda ronda (v1.82.8) — SESSION_COOKIE_SAMESITE, task #258
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 deSameSite(lo aplica el navegador, no el servidor) — este test evita que alguien reintroduzcaStrictsin 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]]