Saltar al contenido
🐍 Curso de Python
/

Módulo 4 · Lección 32 de 45

Registro, inicio y cierre de sesión

Las ocho vistas que ya están escritas, el enlace de salir que dejó de funcionar, y por qué el mensaje de error es tan poco concreto.

⏱ 16 min de lectura

El panel de la lección 30 ya tenía inicio de sesión, usuarios, permisos y una página para cambiar la contraseña. Nada de eso es del panel: es de django.contrib.auth, y está disponible para tu sitio con una línea.

Y conviene decirlo pronto: esto no se escribe a mano. Guardar contraseñas, comprobarlas, mandar un correo de recuperación con un enlace que caduque y no dejar agujeros por el camino es un problema resuelto con veinte años de errores ajenos detrás. Aquí se usa lo que hay y se aprende a configurarlo.

Ocho vistas con una línea#

agenda/urls.py
urlpatterns = [
    path('cuentas/', include('django.contrib.auth.urls')),
    # ...
]
Salida en pantalla
login                    /cuentas/login/
logout                   /cuentas/logout/
password_change          /cuentas/password_change/
password_change_done     /cuentas/password_change/done/
password_reset           /cuentas/password_reset/
password_reset_done      /cuentas/password_reset/done/
password_reset_confirm   /cuentas/reset/uid/tok/
password_reset_complete  /cuentas/reset/done/

Las cuatro últimas son el proceso de "he olvidado mi contraseña" entero: pedir el correo, avisar de que se ha enviado, el enlace con su identificador y su testigo, y la confirmación. Funcionan en cuanto haya envío de correo configurado, que es la lección 38.

Las vistas están; las plantillas no. Y las busca en un sitio concreto:

plantillas/registration/login.html
{% extends "base.html" %}
{% block contenido %}
<h1>Entrar</h1>
<form method="post" novalidate>{% csrf_token %}
  {{ form.non_field_errors }}
  {{ form.as_div }}
  <button>Entrar</button>
</form>
{% endblock %}

La carpeta tiene que llamarse registration, en inglés. Si sale un TemplateDoesNotExist con ese nombre dentro, es esto.

Y fíjate en el non_field_errors: el error de "usuario o contraseña incorrectos" no es de ningún campo, así que sin esa línea el formulario rechaza el intento sin decir por qué. Es el olvido de la lección 25 en el peor sitio posible.

Los tres ajustes de redirección#

Salida en pantalla
LOGIN_URL          : /accounts/login/
LOGIN_REDIRECT_URL : /accounts/profile/
LOGOUT_REDIRECT_URL: None

Los tres valores por defecto están mal para casi cualquier proyecto, y el primero es una trampa: apunta a /accounts/, en inglés, aunque tú hayas montado las rutas en /cuentas/. El síntoma es que el decorador de la lección siguiente redirige a una página que no existe.

agenda/settings.py
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'contactos:lista'
LOGOUT_REDIRECT_URL = 'contactos:lista'

Admiten el nombre de una ruta, que es mejor que escribir la dirección por lo que se vio en la lección 21.

LOGIN_REDIRECT_URL es adónde va alguien tras entrar si no había un sitio al que volver. Y ese "si no había" es lo siguiente.

?next=#

Salida en pantalla
login normal                        ->  302 /contactos/   (LOGIN_REDIRECT_URL)
login con ?next=/contactos/         ->  302 /contactos/
login con ?next=https://malo.example.com/  ->  302 /contactos/

Lo pone el decorador de la lección siguiente cuando manda a alguien a identificarse, y es lo que hace que después vuelvas a donde ibas. En un enlace escrito a mano se pone igual:

html
<a href="{% url 'login' %}?next={{ request.path|urlencode }}">Entrar</a>

Y fíjate en la tercera línea de la medición: el next a otro dominio se ignora. Django comprueba que el destino sea del sitio antes de redirigir.

Eso protege de una redirección abierta: un enlace a tu dominio, con tu página de inicio de sesión de verdad, que después de identificarse manda a una copia del sitio en otro servidor. Como el enlace inicial es legítimo, es muy convincente.

La conclusión es para tu propio código: cualquier redirección a una dirección que venga de fuera hay que comprobarla, con url_has_allowed_host_and_scheme. Es exactamente el mismo agujero.

El enlace de salir ya no funciona#

Salida en pantalla
GET  /cuentas/logout/  ->  405
POST /cuentas/logout/  ->  200

Esto rompe casi todos los tutoriales que hay escritos, porque durante quince años el cierre de sesión fue un enlace:

html
<a href="{% url 'logout' %}">Salir</a>   <!-- 405 desde Django 5.0 -->

Ahora es un formulario:

html
<form method="post" action="{% url 'logout' %}">{% csrf_token %}
  <button type="submit">Salir</button>
</form>

El motivo es el de la lección 2, y es el mismo de la historia del buscador que vació la base de datos: un GET no debe cambiar nada. Con un enlace, cualquier cosa que recorra la página te echa la sesión, y una imagen en un foro con esa dirección como origen cerraba la sesión de quien la mirase.

Es molesto para maquetar un menú y es correcto. Y el 405 con su cabecera Allow es lo que se midió en la lección 22: la vista es una clase y sabe qué métodos acepta.

Registro#

Esta es la única que Django no trae, porque el registro depende de cada sitio: si hace falta confirmar el correo, si hay invitaciones, qué datos se piden. Lo que sí trae es el formulario:

Salida en pantalla
campos: ['username', 'password1', 'password2']

Y con eso, la vista son siete líneas:

contactos/views.py
from django.contrib.auth import login
from django.contrib.auth.forms import UserCreationForm


def registro(peticion):
        if peticion.method == 'POST':
                formulario = UserCreationForm(peticion.POST)
        if formulario.is_valid():
                        usuario = formulario.save()
            login(peticion, usuario)
            return redirect('contactos:lista')
    else:
                formulario = UserCreationForm()
    return render(peticion, 'registration/registro.html', {'formulario': formulario})

Es el ciclo de la lección 25 sin una sola variación. Lo único nuevo es el login(peticion, usuario), que deja la sesión iniciada para no obligar a escribir la contraseña que se acaba de elegir.

Para pedir más datos, se hereda:

python
class RegistroForm(UserCreationForm):
        email = forms.EmailField(required=True)

    class Meta(UserCreationForm.Meta):
                fields = ['username', 'email']

Ese class Meta(UserCreationForm.Meta) hereda del Meta del padre en vez de sustituirlo, que es lo que hay que hacer para no perder su configuración.

Qué pasa con la sesión#

Salida en pantalla
clave antes:   ptdjzdbgnu0u
clave después: b1xzbly5daee
¿cambia?:      True
¿conserva el carrito?: ['a', 'b']

Al iniciar sesión, Django cambia el identificador de la sesión y conserva su contenido. Eso tiene un nombre: evitar la fijación de sesión, un ataque en el que alguien te hace usar un identificador que él conoce y hereda tu sesión cuando te identificas. Como el identificador cambia justo en ese momento, el que él tenía no sirve.

Al salir, lo contrario:

Salida en pantalla
tras logout -> carrito: None | usuario: None

logout() vacía la sesión entera, no solo la marca de quién eres. Cualquier cosa que hubiera guardada se pierde, lo cual es lo correcto en un ordenador compartido y hay que tenerlo en cuenta si guardabas algo que debía sobrevivir.

Y las dos cookies que aparecen:

Salida en pantalla
cookies: ['csrftoken', 'sessionid']

sessionid es un identificador aleatorio; los datos no viajan en la cookie, están en la tabla de sesiones del servidor. csrftoken es la otra mitad de la protección del {% csrf_token %}. Las dos duran catorce días de serie, que es lo que vale SESSION_COOKIE_AGE.