skip to content
alcher.dev

Lith Labs 006: Login Flow Gaps

/ 4 min read

Part of the Lith Labs series

Goal

The goal is to cover the following gaps in the login flow:

  1. Give authenticated users a way to log out.
  2. 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:

  1. 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.
  2. The logout button should act as a submit button to a form
  3. 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:

  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/006-login-flow-gaps branch.