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:
- Implement a custom User Model.
- Create a login view with a login form.
- Add an auth-protected dashboard route.
The source is available at the feature/005-user-login
branch.