skip to content
alcher.dev

Lith Labs 005: User Login

/ 7 min read

Part of the Lith Labs series

Goal

The goal is for existing users to access a protected dashboard page via a form on a login page. Unauthenticated access to the dashboard page will result in a redirection to the login page.

Custom User Model

Although Django’s default User model is very powerful on its own, I’d like to separate the user credentials (identifier + password) from the user’s information (name, profile picture, etc.). I decide to go with extending the AbstractBaseUser with the PermissionsMixin. I removed the username field and used the email field as the unique identifier. This requires a custom model manager since the majority of Django’s builtin auth functionalities work with the username field.

# src/lith/models/auth.py
from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import (
    AbstractBaseUser,
    UserManager as DjangoUserManager,
    PermissionsMixin,
)
from django.db import models
from django.utils.translation import gettext as _


class UserManager(DjangoUserManager):
    def create_superuser(self, username=None, email=None, password=None, **extra_fields):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)

        if extra_fields.get("is_staff") is not True:
            raise ValueError("Superuser must have is_staff=True.")
        if extra_fields.get("is_superuser") is not True:
            raise ValueError("Superuser must have is_superuser=True.")

        return self._create_user(email, password, **extra_fields)

    def create_user(self, username=None, email=None, password=None, **extra_fields):
        extra_fields.setdefault("is_staff", False)
        extra_fields.setdefault("is_superuser", False)
        return self._create_user(email, password, **extra_fields)

    def _create_user(self, email, password, **extra_fields):
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.password = make_password(password)
        user.save(using=self._db)
        return user


class User(AbstractBaseUser, PermissionsMixin):
    username = None
    email = models.EmailField(unique=True)
    is_staff = models.BooleanField(
        _("staff status"),
        default=False,
        help_text=_("Designates whether the user can log into this admin site."),
    )
    is_active = models.BooleanField(
        _("active"),
        default=True,
        help_text=_(
            "Designates whether this user should be treated as active. Unselect this instead of deleting accounts."
        ),
    )
    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []

    objects = UserManager()
Credit to simpleisbetterthancomplex.com’s article as a quick refresher on the gotchas of using a custom User model.

This on its own is enough for the createsuperuser command to work. But in order for the Django admin panel to work, I needed to register a custom admin class:

# src/lith/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from django.utils.translation import gettext as _

from .models import User


class UserAdmin(DjangoUserAdmin):
    list_filter = ("is_superuser", "groups")
    list_display = ("email", "last_login")
    search_fields = ("email",)
    readonly_fields = ("last_login",)
    fieldsets = (
        (None, {"fields": ("email", "password")}),
        (
            _("Permissions"),
            {
                "fields": (
                    "is_superuser",
                    "groups",
                    "user_permissions",
                ),
            },
        ),
        (_("Important dates"), {"fields": ("last_login",)}),
    )
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("email", "password1", "password2", "is_staff", "is_superuser"),
            },
        ),
    )
    ordering = ("email",)


admin.site.register(User, UserAdmin)

I’d need to point this custom model in the settings file as well:

# src/lith/config/settings.py
# ...snip

AUTH_USER_MODEL = "lith.User"

After running make makemigrations and make migrate, I can now create a new admin by running make createsuperuser and log into the admin panel at /admin.

Email insensitivity

I wanted to add case-insensitive uniqueness constraint to the email field. I’m aware of PostgreSQL specific CI* fields, but I settled with a simpler constraint that converts the field to lower first before comparing.

But first, a failing test:

@pytest.mark.django_db
def test_email_is_case_insensitive_unique():
    User.objects.create(email="foo@example.com")

    with pytest.raises(IntegrityError):
        User.objects.create(email="Foo@example.com")

Adding the constraint is straightforward:

class User(AbstractBaseUser, PermissionsMixin):
    # ...snip

    class Meta:
        constraints = [
            models.UniqueConstraint(
                Lower("email"), name="unique_email", violation_error_message="Email already exists."
            )
        ]

Login Page — Happy Path

I started with test for the happy path using the given-when-then structure:

@pytest.mark.django_db
def test_can_login_using_valid_credentials(client: Client):
    # Given a user.
    user = User.objects.create(email="foo@example.com")
    user.set_password("test-password")
    user.save()

    # When the user attempts to log in using valid credentials.
    payload = {
        "email": "foo@example.com",
        "password": "test-password",
    }
    response = client.post(reverse("lith:login"), data=payload)

    # Then they get redirected to the dashboard page.
    assert 302 == response.status_code
    # TODO: Replace this with a reverse lookup when the dashboard URL is available.
    assert "/dashboard" == response.url

The implementation is very straightforward:

def login(request: HttpRequest) -> HttpResponseRedirect | None:
    email = request.POST.get("email")
    password = request.POST.get("password")
    user = authenticate(username=email, password=password)

    if user is not None:
        return redirect("/dashboard")

I’d also need to register the route in the lith app and the main config/urls.py:

# src/lith/urls.py
from django.urls import path

from .views import login

app_name = "lith"

urlpatterns = [path("login/", login, name="login")]

# src/config/urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("lith.urls")),
]

Login Page — Invalid Credentials

Testing the invalid credentials path is also straightforward:

@pytest.mark.django_db
def test_login_with_invalid_credentials_shows_an_error_message(client: Client):
    # When an invalid set of credentials are used to log in.
    payload = {
        "email": "unauthenticated@example.com",
        "password": "test-password",
    }
    response = client.post(reverse("lith:login"), data=payload)

    # Then the response returns an unauthorized status code
    assert 401 == response.status_code

    # ... and a helpful error message.
    assert "Invalid credentials" in response.content.decode("utf-8")

The implementation is a bit more involved. First, I had to add a message and render a template when the authentication fails:

if user is None:
    messages.error(request, "Invalid credentials")
    return render(request, "lith/auth/login.html", status=401)

And second, I have to create a template for the login page and a reusable messages partial:

# src/lith/templates/lith/auth/login.html
{% include "lith/partials/messages.html" %}

# src/lith/templates/lith/partials/messages.html
{% if messages %}
<ul class="messages">
    {% for message in messages %}
    <li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
    {% endfor %}
</ul>
{% endif %}

Note: I intentionally kept the template as thin as possible as I don’t want to concern myself with visual implementation yet.

Login Page — Creating the Form

While manually fetching POST data from the request works fine in simple cases, a distinct validation layer is necessary for maintainability. Django Forms is Django’s way of implementing such layer (and much more). I moved the payload definition into a form class:

# src/lith/forms/auth.py
from django import forms


class LoginForm(forms.Form):
    email = forms.EmailField(label="Email")
    password = forms.CharField(label="Password", widget=forms.PasswordInput)

The view will require a bit of restructuring:

# src/lith/views.py
def login(request: HttpRequest) -> HttpResponseRedirect | HttpRequest:
    status = 200
    if request.method == "POST":
        form = LoginForm(request.POST)
        if form.is_valid():
            email = form.cleaned_data["email"]
            password = form.cleaned_data["password"]

            user = authenticate(username=email, password=password)

            if user is not None:
                return redirect("/dashboard")

            messages.error(request, "Invalid credentials")
            status = 401
    else:
        form = LoginForm()

    context = {
        "form": form,
    }

    return render(request, "lith/auth/login.html", context=context, status=status)

Running the tests should still pass!

Login Page — Rendering the Form

Rendering a usable form is very trivial — it’s just a matter of creating a <form> tag that wraps the form variable in the template:

# src/lith/templates/lith/auth/login.html
<form action="{% url "lith:login" %}" method="post">
    {% csrf_token %}
    {{ form }}

    <input type="submit" value="Login">
</form>

Dashboard Page

The dashboard page is supposed to be accessed only by logged-in users. I wrote a couple of tests for it:

@pytest.mark.django_db
def test_authenticated_users_can_access_the_dashboard(client: Client):
    # Given an existing user
    user = User.objects.create(email="foo@example.com")
    user.set_password("test-password")
    user.save()

    # When that user is logged in and accesses the dashboard page
    client.login(username="foo@example.com", password="test-password")
    response = client.get(reverse("lith:dashboard"))

    # Then the user gets a successful response
    assert 200 == response.status_code

@pytest.mark.django_db
def test_guests_get_redirected_to_login_when_accessing_the_dashboard(client: Client):
    # When a guest accesses the dashboard page
    response = client.get(reverse("lith:dashboard"))

    # Then the user is redirected to the login page
    assert 302 == response.status_code
    assert reverse("lith:login") in response.url

The authentication check’s heavy lifting is done by the login_required decorator. To demonstrate that the request is authenticated, I decided to display a message using the current user’s email:

@login_required
def dashboard(request: HttpRequest) -> HttpResponse:
    return HttpResponse(f"hello, {request.user.email}!")

I’ll spare the details of registering the URL. But an important part is to customize the LOGIN_URL setting as this setting is used by the login_required decorator to determine where to redirect unauthenticated requests. Given that the URL registration hasn’t happened yet when the settings are loaded, I have to use the reverse_lazy function:

# src/lith/config/settings.py

# Login URL
# https://docs.djangoproject.com/en/5.1/ref/settings/#login-url

LOGIN_URL = reverse_lazy("lith:login")

Conclusion

In this lab, I implemented the following:

  1. Implement a custom User Model.
  2. Create a login view with a login form.
  3. Add an auth-protected dashboard route.

The source is available at the feature/005-user-login branch.