Goal
The goal is to cover the following gaps in the login flow:
- Give authenticated users a way to log out.
- Make the login page a “guest-only” page.

Implementing Logout
A logout view is trivial to implement in Django, but there’s a handful of considerations to make:
- The view should only be accessible via
POST
to prevent malicious actors from sending a link to the/logout
route that terminates the user’s session. - The logout button should act as a submit button to a form
- It should be part of a reusable navbar
Let’s start off with some tests:
@pytest.mark.django_db
def test_user_can_logout_to_terminate_session(client: Client):
# Given an existing user that is logged in
user = User.objects.create(email="foo@example.com")
user.set_password("test-password")
user.save()
client.login(username="foo@example.com", password="test-password")
# When the user sends a request to logout
response = client.post(reverse("lith:logout"))
# Then the user's session ends
assert response.wsgi_request.user.is_anonymous
# ... and is redirected to the login page
assert 302 == response.status_code
assert reverse("lith:login") in response.url
# ... with an informational message
assert "You have been logged out" in str(response.content)
def test_logout_is_not_accessible_via_GET(client: Client):
# When a GET request is issued to the logout view
response = client.get(reverse("lith:logout"))
# Then a method not allowed response is returned
assert 405 == response.status_code
As with the login
view, the heavy lifting is already done by Django’s logout
function.
Limiting the view to only POST
requests is done by using the require_http_methods
decorator
# src/lith/views.py
from django.contrib.auth import logout as django_logout
# ... snip
@require_http_methods(["POST"])
def logout(request: HttpRequest) -> HttpResponseRedirect:
django_logout(request)
return redirect(reverse("lith:login"))
The Logout Button in a navbar template is pretty straightforward:
# src/lith/templates/lith/partials/navbar.html
<nav>
<ul>
<li>
<form action="{% url "lith:logout" %}" method="post">
{% csrf_token %}
<input type="submit" value="Logout">
</form>
</li>
</ul>
</nav>
# src/lith/templates/lith/dashboard/dashboard.html
{% include "lith/partials/navbar.html" %}
<h1>Hello, {{ email }}!</h1>
Lastly, I’d need to actually render a template on the dashboard view instead of returning a raw HttpResponse
:
@login_required
def dashboard(request: HttpRequest) -> HttpResponse:
context = {
"email": request.user.email,
}
return render(request, "lith/dashboard/dashboard.html", context=context)
Guest-only check
As usual, I started off with a pair of tests:
def test_login_page_can_be_accessed_by_guests(client: Client):
# When a guest accesses the login page
response = client.get(reverse("lith:login"))
# Then the guest gets a successful response
assert 200 == response.status_code
@pytest.mark.django_db
def test_authenticated_users_get_redirected_to_dashboard_when_accessing_the_login_page(
client: Client,
):
# Given an existing user
user = User.objects.create(email="foo@example.com")
user.set_password("test-password")
user.save()
# When that user accesses the login page
client.login(username="foo@example.com", password="test-password")
response = client.get(reverse("lith:login"))
# Then the user is redirected to the dashboard
assert 302 == response.status_code
assert reverse("lith:dashboard") in response.url
There’s a handful of ways to implement this check, but I opt for a reusable decorator:
# src/lith/views.py
def guest_required(f: Callable) -> Callable:
"""
Decorator for restricting a view to be only accessible by guests.
"""
def is_guest(user: User):
return user.is_anonymous
decorator = user_passes_test(is_guest, login_url=reverse_lazy("lith:dashboard"))
if f:
return decorator(f)
return decorator
# ...snip
@guest_required
def login(request: HttpRequest) -> HttpResponseRedirect | HttpRequest:
# ... snip
Fixing a subtle bug
After adding the guest/auth checks, I have uncovered a silly yet subtle bug. Can you spot what’s wrong with this snippet?
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(reverse("lith:dashboard"))
Answer: The user is not actually being logged in!
To fix this, I added a regression assertion:
@pytest.mark.django_db
def test_can_login_using_valid_credentials(client: Client):
# ... snip
# Then the user is redirected to the dashboard page
assert 302 == response.status_code
assert reverse("lith:dashboard") in response.url
# ... and the user's session is authenticated
assert response.wsgi_request.user.is_authenticated
And then actually call the login
function in the view:
from django.contrib.auth import authenticate, login as django_login
# ... snip
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:
django_login(request, user)
return redirect(reverse("lith:dashboard"))
Sneaky bug, but at least I caught it early :)
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/006-login-flow-gaps
branch.