Django: Registro y autenticación de usuarios


Un aspecto muy común en las aplicaciones web es el registro y autenticación de usuarios, en este artículo veremos paso a paso como Django nos facilita todas estas tareas mediante la App django.contrib.auth, toda la interacción con los usuarios de la base datos será transparente, nosotros simplemente tendremos que generar los templates para que la web tenga el aspecto deseado.

Antes de comenzar es recomendable leer los artículos anteriores sobre Django ya que son los pasos previos a este artículo:


Como siempre activamos el venv del proyecto:

cd rxWod
source bin/activate
cd rxWodProject/

El modo mas rápido de crear usuarios es mediante la CLI de Python:

python manage.py shell
>>> from django.contrib.auth.models import User
>>> user = User.objects.create_user('kr0m2', 'kr0m2@alfaexploit.com')

Podemos cambiar parámetros mediante los atributos del usuario:

>>> user.last_name = 'Awesome'
>>> user.is_staff=True
>>> user.set_password('PASSWORD12345')
>>> user.save()

Podemos consultar los atributos disponibles con el siguiente comando:

>>> print(user._meta.fields)
<django.db.models.fields.AutoField: id>, <django.db.models.fields.CharField: password>, <django.db.models.fields.DateTimeField: last_login>, <django.db.models.fields.BooleanField: is_superuser>, <django.db.models.fields.CharField: username>, <django.db.models.fields.CharField: first_name>, <django.db.models.fields.CharField: last_name>, <django.db.models.fields.EmailField: email>, <django.db.models.fields.BooleanField: is_staff>, <django.db.models.fields.BooleanField: is_active>, <django.db.models.fields.DateTimeField: date_joined>

Podemos ver el usuario recién creado en la interfaz web:

http://localhost:8000/admin/auth/user/

La forma mas rápida de cambiar un password es mediante CLI:

python manage.py changepassword USERNAME

Django ya viene con todos los componentes habilitados en fichero settings.py para realizar la autenticación de usuarios, comprobamos que así sea:

vi rxWodProject/settings.py
INSTALLED_APPS = [
    ...
    'django.contrib.auth',
    'django.contrib.contenttypes',
    ...
]

MIDDLEWARE = [
    ...
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    ...
]

Para el alta de usuarios requeriremos la introducción de un email que será validado por el usuario, de este modo nos aseguramos de que el usuario y solo el usuario pueda resetear el password en un futuro.

Para poder enviar el email debemos configurar Django con los parámetros SMTP, en mi caso voy a utilizar una cuenta de Gmail, primero debemos habilitar el acceso inseguro mediante el siguiente enlace.

Y configuraremos las reglas de firewall en caso de ser necesarias(manual IPFW):

$cmd 00401 allow tcp from me to any 587 out via $wanif
$cmd 00401 allow tcp from any 587 to me in via $wanif

Los parámetros SMTP se configurarán en el fichero settings  del proyecto, pero vamos a externalizarlo para poder commitear el fichero settings:

vi rxWodProject/settings.py
# Email configuration
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'

email_auth_path = str(Path(__file__).resolve().parent) + '/email_auth.txt'
f = open(email_auth_path, "r")
lines = f.readlines()
f.close()

EMAIL_HOST = lines[0].strip().split(' ')[0]
EMAIL_USE_TLS = lines[1].strip().split(' ')[0]
EMAIL_PORT = lines[2].strip().split(' ')[0]
EMAIL_HOST_USER = lines[3].strip().split(' ')[0]
EMAIL_HOST_PASSWORD = lines[4].strip().split(' ')[0]

Creamos el fichero con las credenciales:

vi rxWodProject/email_auth.txt
smtp.gmail.com
True
587
rxworkofday@gmail.com
PASSWORD

NOTA: En mi caso se van a enviar emails de forma muy puntual, si la web empieza a tener tráfico o si se envían muchos emails por la razón que sea seguramente tengamos que hablar con Gmail para empezar a pagar una cuenta empresarial.

Comprobamos desde la shell del proyecto que el envío funcione:

python manage.py shell
>>> from django.core.mail import send_mail
>>> send_mail('Subject here', 'Here is the message.', 'rxworkofday@gmail.com', ['kr0m@alfaexploit.com'], fail_silently=False,)
1

El email recibido es el siguiente:


Ahora que ya tenemos el sistema de envío de emails funcionando podemos centrarnos en nuesta aplicación usersAuth.

La URL de login viene definida por django.contrib.auth y la URL donde redirigirnos en caso de autenticarnos correctamente la definimos nosotros:

vi rxWodProject/settings.py
LOGIN_URL='/accounts/login'
LOGIN_REDIRECT_URL='/'

Para dar de alta usuarios podemos utilizar el formulario django.contrib.auth.forms(UserCreationForm), este realizará ciertas validaciones sobre los campos del formulario de forma automática.

El único inconveniente es que no obliga al usuario a introducir una cuenta de email para su registro, por lo tanto vamos a crear nuestro propio formulario que extenderá de UserCreationForm con esto heredaremos las validaciones anteriores pero pudiendo modificar los campos requeridos y pudiendo realizar las validaciones que consideremos oportunas.

En nuestro caso comprobaremos que no exista ninguna cuenta dada de alta con el email indicado, de este modo evitamos que se registren varias cuentas con el mismo email.

vi usersAuth/forms.py
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
from django import forms

class RegisterUserForm(UserCreationForm):
    class Meta:
        model = User
        fields =  ['username', 'email', 'password1', 'password2']

    # Check unique email
    # Email exists && account active -> email_already_registered
    # Email exists && account not active -> delete previous account and register new one
    def clean_email(self):
        email_passed = self.cleaned_data.get("email")
        email_already_registered = User.objects.filter(email = email_passed).exists()
        user_is_active = User.objects.filter(email = email_passed, is_active = 1)
        if email_already_registered and user_is_active:
            #print('email_already_registered and user_is_active')
            raise forms.ValidationError("Email already registered.")
        elif email_already_registered:
            #print('email_already_registered')
            User.objects.filter(email = email_passed).delete()

        return email_passed

Ahora en la vista register importamos nuestro formulario RegisterUserForm, el código se comportará de un modo u otro según ciertas condiciones:

  • El usuario ya está autenticado: Lo redirigimos al index.
  • La petición es GET: Instanciamos el formulario de registro de usuarios y se lo pasamos al template vía contexto para que lo muestre.
  • La petición es POST: Instanciamos el formulario de registro de usuarios pasándole los parámetros POST para su validación
    • Si pasa las validaciones: Guardamos el usuario como inactivo, enviamos el email con el link de confirmación y lo redirigimos a la ventana de login.
    • Si no pasa las validaciones: Generamos los messages asociados con los errores y lo redirigimos a la ventana de register donde mostraremos los messages generados por la vista.

Por otro lado tenemos la vista activate, esta será empleada para confirmar el registro de usuarios que clicken en el link del email de registro, la lógica es la siguiente:

  • Si el UUID es válido: Activamos el usuario, generamos el mensaje y redirigimos a login donde mostraremos los mensajes.
  • Si el UUID es inválido: Retornamos 'Activation link is invalid'.
vi usersAuth/views.py
from django.shortcuts import render
from django.http import HttpResponse, HttpResponseRedirect
from django.urls import reverse
from django.contrib.sites.shortcuts import get_current_site
from django.contrib import messages
from django.template.loader import render_to_string
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes
from django.contrib.auth.tokens import default_token_generator
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
UserModel = get_user_model()
from django.core.mail import send_mail
from .forms import RegisterUserForm

def register(request):
    if request.user.is_authenticated:
        print('Already authenticated')
        return HttpResponseRedirect(reverse('rxWod:index'))
    else:
        if request.method == 'POST':
            form = RegisterUserForm(request.POST)
            # RegisterUserForm is created from User model, all model field restrictions are checked to considerate it a valid form
            if form.is_valid():
                print('Valid form')
                # Save user to database but with is_active = False
                user = form.save(commit=False)
                user.is_active = False
                user.save()

                # Send confirmation email
                current_site = get_current_site(request)
                subject = 'Activate Your ' + current_site.domain + ' Account'
                message = render_to_string('usersAuth/email_confirmation.html',
                    {
                        "domain": current_site.domain,
                        "user": user,
                        "uid": urlsafe_base64_encode(force_bytes(user.pk)),
                        "token": default_token_generator.make_token(user),
                    },
                )
                to_email = form.cleaned_data.get('email')
                send_mail(subject, message, 'rxworkofday@gmail.com', [to_email])

                # Redirect user to login
                messages.success(request, 'Please Confirm your email to complete registration before Login.')
                return HttpResponseRedirect(reverse('login'))
            else:
                #print('Invalid form: %s' % form.errors.as_data())
                #print(type(form.errors.as_data()))
                if form.errors:
                    #messages.info(request, 'Input field errors:')
                    for key, values in form.errors.as_data().items():
                        #print("Bad value: %s - %s" % (key, values))
                        if key == 'username':
                            messages.info(request, 'Error input fields')
                            break
                        else:
                            for error_value in values:
                                print(error_value)
                                #print(type(error_value))
                                messages.info(request, '%s' % (error_value.message))

                return HttpResponseRedirect(reverse('usersAuth:register'))
        else:
            form = RegisterUserForm()

            context = {
                'form': form
            }
            return render(request, 'usersAuth/register.html', context)

def activate(request, uidb64, token):
    try:
        uid = urlsafe_base64_decode(uidb64).decode()
        user = UserModel._default_manager.get(pk=uid)
    except(TypeError, ValueError, OverflowError, User.DoesNotExist):
        user = None

    if user is not None and default_token_generator.check_token(user, token):
        user.is_active = True
        user.save()
        # Redirect user to login
        messages.success(request, 'Successful email confirmation, you can proceed to login.')
        return HttpResponseRedirect(reverse('login'))
    else:
        return HttpResponse('Activation link is invalid!')

NOTA: Cuando redirigimos a la página de login no lo hacemos mediante HttpResponseRedirect(reverse('usersAuth:login')) ya que la URL de login no la proporciona nuestra App usersAuth si no django.contrib.auth así que utilizamos HttpResponseRedirect(reverse('login')) en su lugar.

Damos de alta las urls register y activate:

vi usersAuth/urls.py
from django.urls import path
from . import views

app_name = 'usersAuth'

urlpatterns = [
    path('register/', views.register, name='register'),
    path('activate/<uidb64>/<token>/',views.activate, name='activate'),
]

Para el registro necesitamos varios templates, el de registro y el que se utilizará cuando le enviemos al cliente el link de confirmación.

Creamos el directorio donde almacenaremos los templates:

mkdir -p usersAuth/templates/usersAuth

Generamos los templates:

vi usersAuth/templates/usersAuth/register.html
<form action="{% url 'usersAuth:register' %}" method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <input type="submit" class="btn btn-success" id="Register" value="Register"></input>
    {% for message in messages %}
        <p class="item" id="messages">{{ message }}</p>
    {% endfor%}
</form>
vi usersAuth/templates/usersAuth/email_confirmation.html
{% autoescape off %}
Hi {{ user.username }},

Please click on the link below to confirm your registration:

http://{{ domain }}{% url 'usersAuth:activate' uidb64=uid token=token %}
{% endautoescape %}

El resto de funcionalidades de autenticación las provee Django por defecto.


Mediante un cat podemos ver los paths y los nombres de las vistas asociadas a cada funcionalidad propocionada por django.contrib.auth:

cat /home/kr0m/rxWod/lib/python3.7/site-packages/django/contrib/auth/urls.py
urlpatterns = [
    path('login/', views.LoginView.as_view(), name='login'),
    path('logout/', views.LogoutView.as_view(), name='logout'),

    path('password_change/', views.PasswordChangeView.as_view(), name='password_change'),
    path('password_change/done/', views.PasswordChangeDoneView.as_view(), name='password_change_done'),

    path('password_reset/', views.PasswordResetView.as_view(), name='password_reset'),
    path('password_reset/done/', views.PasswordResetDoneView.as_view(), name='password_reset_done'),
    path('reset/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'),
    path('reset/done/', views.PasswordResetCompleteView.as_view(), name='password_reset_complete'),
]

Importamos las URLs de django.contrib.auth:

vi rxWodProject/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    # django.contrib.auth URLs
    path('accounts/', include('django.contrib.auth.urls')),
    path('', include('rxWod.urls')),
    path('', include('usersAuth.urls')),
    path('admin/', admin.site.urls),
]

Creamos el directorio donde buscará los templates:

mkdir -p usersAuth/templates/registration

Si no creamos el template utilizará por defecto el de la interfaz de admin, aunque este no suele ser el comportamiento deseado:

http://127.0.0.1:8000/accounts/password_reset/

Creamos los templates, en este enlace podemos ver el nombre por defecto de cada template:

  • login: registration/login.html
  • logout: registration/logged_out.html
  • password_change: registration/password_change_form.html
  • password_change_done: registration/password_change_done.html
  • password_reset: registration/password_reset_form.html
  • password_reset_done: registration/password_reset_done.html
  • password_reset_confirm: registration/password_reset_confirm.html
  • password_reset_complete: registration/password_reset_complete.html
vi usersAuth/templates/registration/login.html
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <input type="submit" class="btn btn-success" id="Login" value="Login"></input>
    {% for message in messages %}
        <p class="item" id="messages">{{ message }}</p>
    {% endfor%}
</form>
vi usersAuth/templates/registration/logged_out.html
<h1>Logout with success</h1>
{% for message in messages %}
    <p class="item" id="messages">{{ message }}</p>
{% endfor%}
vi usersAuth/templates/registration/password_change_form.html
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <input type="submit" class="btn btn-success" id="Change-password" value="Change password"></input>
    {% for message in messages %}
        <p class="item" id="messages">{{ message }}</p>
    {% endfor%}
</form>
vi usersAuth/templates/registration/password_change_done.html
<h1>Password successfully changed</h1>
{% for message in messages %}
    <p class="item" id="messages">{{ message }}</p>
{% endfor%}
vi usersAuth/templates/registration/password_reset_form.html
<form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <input type="submit" class="btn btn-success" id="Send-email" value="Send email"></input>
    {% for message in messages %}
        <p class="item" id="messages">{{ message }}</p>
    {% endfor%}
</form>
vi usersAuth/templates/registration/password_reset_done.html
<h1>Password reset sent</h1>
<p>We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly.<br>If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder.</p>
vi usersAuth/templates/registration/password_reset_confirm.html
<h1>Password Reset Confirm</h1>
<p>Please enter your new password.</p>
<form method="POST">
    {% csrf_token %}
    {{ form.as_p }}                    
    <button class="btn btn-primary" type="submit">Reset password</button>
</form>
vi usersAuth/templates/registration/password_reset_complete.html
<h1>Password reset complete</h1>
<p>Your password has been set. You may go ahead and log in now.</p>      

Para crear un usuario accedemos a la url /register:

http://127.0.0.1:8000/register

Rellenamos los datos y le damos a Register:

Nos redirigirá a http://127.0.0.1:8000/accounts/login/ indicando que debemos confirmar el enlace del email:

Siempre que accedamos a la interfaz de administración de Django debemos hacerlo desde una sesión anónima del navegador para evitar interferencias con las pruebas que estamos realizando con el usuario de test, si no lo hacemos así al hacer login en la interfaz de administración el código detectará que estamos logeados y no mostrará el contenido correcto.

Consultamos el estado del nuevo usuario donde podemos ver que no está activo ya que todavía no ha confirmado el email:

El usuario habrá recibido un email con el enlace de confirmación:

Cuando el usuario clicke en el enlace del email será redirigido a la página de login donde se le mostrará un mensaje indicándole que su cuenta ha sido confirmada y puede proceder a logearse:

Si consultamos la interfaz de administración veremos que el usuario ha pasado a estar activo:

Cuando el usuario proceda a hacer login verá las rutinas creadas, recordad que por ahora no hay ningún filtro a la hora de mostar las rutinas, se muetran todas independientemente del usuario:

http://127.0.0.1:8000/accounts/login

La página a la que nos envía una vez autenticados es / tal y como definimos en las settings:

LOGIN_REDIRECT_URL='/'

Si hacemos logout veremos la siguiente página:

http://127.0.0.1:8000/accounts/logout

Si cambiamos el password al haber hecho logout nos pedirá login primero:

http://127.0.0.1:8000/accounts/password_change

Y en cuanto hagamos login nos mostrará el formulario de cambio de password:

Al cambiar el password mostrará:

Si reseteamos el password nos pedirá el email asociado:

http://127.0.0.1:8000/accounts/password_reset

Nos indica que debemos seguir las instrucciones recibidas vía email:

El usuario recibirá un email con el siguiente contenido:

Al clickar en el enlace el usuario podrá cambiar el password:

El usuario es informado del cambio:

Ahora ya puede logearse con el password nuevo.

NOTA: Por el momento no nos estamos preocupando por las traducciones, este apartado lo cubriremos en detalle en artículos posteriores.

Las URLs de django.contrib.auth vienen protegidas por defecto requiriendo login pero nuestras vistas estarán expuestas, debemos tener en cuenta que hay vistas que requerirán autenticación mientras que otras no. Por ejemplo rxWod:index debe pedir credenciales para su acceso, las vistas usersAuth:register y usersAuth:activate no lo necesitan ya que deben ser accesibles por el usuario antes de haber registrado una cuenta.

Mediante el decorator login_required podremos controlar el acceso.

vi rxWod/views.py
from django.shortcuts import render
from django.http import HttpResponse
from .models import Routine
from django.contrib.auth.decorators import login_required

@login_required()
def index(request):
    routines = Routine.objects.order_by('id')
    context = {
        'routines': routines,
    }
    return render(request, 'rxWod/index.html', context)

Si hacemos logout e intentamos acceder al index nos pedirá las credenciales de acceso:

http://localhost:8000/accounts/logout/

Accedemos a index y vemos que pasamos por login antes:

http://localhost:8000

Con esto tan solo requerimos login, no controlamos el contenido mostrado, el usuario una vez autenticado podrá ver todas las rutinas, no solo las suyas, esto lo podemos controlar filtrando por usuario en la query de la vista.

vi rxWod/views.py
from django.shortcuts import get_object_or_404, get_list_or_404, render, redirect
from django.http import HttpResponse
from .models import Routine
from django.contrib.auth.decorators import login_required

@login_required()
def index(request):
    #print('username: %s' % request.user.username)
    routines = Routine.objects.filter(user=request.user)
    context = {
        'routines': routines,
    }
    return render(request, 'rxWod/index.html', context)

Si creamos una rutina nueva desde la interfaz de administración y le asignamos el usuario jjivarspoquet:

Hacemos logout/login con jjivarspoquet para comprobar que ahora solo puede ver sus rutinas:

http://127.0.0.1:8000/accounts/logout

http://127.0.0.1:8000/accounts/login

Otro modo de filtrar es mediante condicionales en el template pero siempre se debe filtrar lo antes posible, es decir si podemos filtrar en la vista no debemos esperar a hacerlo en el template.


En la validación de usuarios hay un pequeño problema ya que un usuario registrado pero no validado permanecerá en la base de datos eternamente, estos registros se pueden acumular, ocupar espacio de almacenamiento y en última instancia degradar el rendimiento de la base de datos.

Para solventar el problema vamos a crear un comando custom que hará limpieza de usuarios antiguos:

mkdir -p usersAuth/management/commands
vi usersAuth/management/commands/delete_limbo_users.py
# delete_limbo_users.py
import datetime
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User

class Command(BaseCommand):
    help = 'Delete registered but unactive users older than the given number of days.'

    def add_arguments(self, parser):
        parser.add_argument('days', type=int, help='Retention period in days.')

    def handle(self, *args, **options):
        days = options['days']

        print('-------------------------------------------')
        print('| Checking limbo users older than: %s days |' % days)
        print('-------------------------------------------')

        retention_period = datetime.timedelta(days)
        expiry_date = datetime.date.today() - retention_period

        deleted_users = 0
        unactive_users = User.objects.filter(is_active = 0)
        for user in unactive_users:
            if user.date_joined.date() < expiry_date:
                print('>> Deleting user: : %s -- %s' % (user.username, user.date_joined))
                user.delete()
                deleted_users = deleted_users + 1
        
        if deleted_users == 0:
            print('>> No users to be deleted')

Para hacer la prueba vamos a editar el estado del usuario jjivarspoquet y lo pasamos a inactivo:

Este usuario fué dado de alta hace mas de 2 días:

Si le indicamos al script que elimine todos los usuario mas antiguos de 2 días debería borrarlo:

python manage.py delete_limbo_users 2
-------------------------------------------
| Checking limbo users older than: 2 days |
-------------------------------------------
>> Deleting user: : jjivarspoquet -- 2021-03-01 18:58:16+00:00

Si vamos a la sección de usuarios de la interfaz de administración veremos que el usuario ya no existe:

Para poder crontabearlo escribimos el siguiente script:

vi .scripts/delete_limbo_users.sh
#!/usr/local/bin/bash
cd /home/kr0m/rxWod/
source bin/activate
cd rxWodProject/
python manage.py delete_limbo_users 2
chmod 700 .scripts/delete_limbo_users.sh

Para que se ejecute todas las noches a las 12 dejaríamos el Cron del siguiente modo:

crontab -e
* 00 * * * /home/kr0m/.scripts/delete_limbo_users.sh 2>&1
Si te ha gustado el artículo puedes invitarme a un redbull aquí.
Si tienes cualquier pregunta siempre puedes enviarme un Email o escribir en el grupo de Telegram de AlfaExploit.
Autor: kr0m -- 05/03/2021 20:51:24 -- Categoria: Programacion Django