Video coming soon

The recording for this lesson has not been published yet.

What we cover

  • django.contrib.auth.urls in one line
  • LoginView, LogoutView and redirect settings
  • UserCreationForm and a signup view
  • next parameter and safe redirects

Lesson notes

1. You already have the machinery

In the tutorial you logged into /admin/ and the little "Log in" link on your blog pointed there too. That was not a shortcut for beginners: the admin uses exactly the same django.contrib.auth app that you are about to expose on your own pages. Nothing new needs installing.

Two of the entries in the default settings do all the work:

# mysite/settings.py (already there since startproject)
INSTALLED_APPS = [
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    ...
]

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

2. How request.user appears out of nothing

request.user is not magic, it is two middlewares in a fixed order. SessionMiddleware reads the sessionid cookie and loads the session dictionary; AuthenticationMiddleware looks for the key _auth_user_id in it and fetches that user from the database.

Browser Cookie: sessionid SessionMiddleware loads django_session row into request.session AuthenticationMiddleware reads _auth_user_id sets request.user found missing User instance is_authenticated AnonymousUser never None django_session table

Two consequences worth remembering. First, request.user is never None - for a visitor who is not signed in it is an AnonymousUser, so always test request.user.is_authenticated. Second, the cookie only carries a session key; the user id lives on the server.

3. django.contrib.auth.urls in one line

Django ships a URLconf with the whole set of auth views. Include it once:

# mysite/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("django.contrib.auth.urls")),
    path("accounts/", include("accounts.urls")),
    path("", include("blog.urls")),
]

That single include() gives you these routes and names:

URLURL nameTemplate it expects
accounts/login/loginregistration/login.html
accounts/logout/logoutregistration/logged_out.html
accounts/password_change/password_changeregistration/password_change_form.html
accounts/password_reset/password_resetfour templates - lesson 4.3

Note what is not in the list: registration. Django has no signup view, because every project wants different fields. You write it yourself in section 6.

4. Your own login page

LoginView renders whatever you give it. Extend the base.html you already have:

<!-- templates/registration/login.html -->
{% extends "base.html" %}

{% block content %}
  <h1>Log in</h1>

  {% if form.errors %}
    <p class="text-danger">Your username and password did not match. Please try again.</p>
  {% endif %}

  <form method="post">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">Log in</button>
    <input type="hidden" name="next" value="{{ next }}">
  </form>

  <p><a href="{% url 'password_reset' %}">Forgot your password?</a></p>
  <p>No account yet? <a href="{% url 'signup' %}">Sign up</a>.</p>
{% endblock %}

And the navigation, which now knows who is looking at it:

<!-- templates/base.html -->
{% if user.is_authenticated %}
  <span>Hello, {{ user.username }}</span>
  <a href="{% url 'blog:post_new' %}">New post</a>
  <form method="post" action="{% url 'logout' %}">
    {% csrf_token %}
    <button type="submit">Log out</button>
  </form>
{% else %}
  <a href="{% url 'login' %}">Log in</a>
{% endif %}

5. Where users land, and the settings that decide it

# mysite/settings.py
LOGIN_URL = "login"              # where @login_required sends anonymous visitors
LOGIN_REDIRECT_URL = "blog:post_list"   # after a successful login
LOGOUT_REDIRECT_URL = "blog:post_list"  # after logout

Without LOGIN_REDIRECT_URL Django uses /accounts/profile/, which does not exist in your project - a 404 straight after a successful login is a classic first bug. All three settings accept a URL name or a path.

If a single view needs a different destination, override it there rather than in settings:

# accounts/urls.py
from django.contrib.auth.views import LoginView
from django.urls import path

from . import views

urlpatterns = [
    path("signup/", views.SignUpView.as_view(), name="signup"),
    path(
        "staff-login/",
        LoginView.as_view(
            template_name="accounts/staff_login.html",
            next_page="admin:index",
            redirect_authenticated_user=True,
        ),
        name="staff_login",
    ),
]

6. A signup view with UserCreationForm

UserCreationForm gives you a username field and two password fields, runs Django's password validators and hashes the password on save. Point it at your own user model from lesson 4.1 and add the fields you care about:

# accounts/forms.py
from django.contrib.auth.forms import UserCreationForm

from .models import User


class SignUpForm(UserCreationForm):
    class Meta(UserCreationForm.Meta):
        model = User
        fields = ("username", "email")
# accounts/views.py
from django.contrib.auth import login
from django.urls import reverse_lazy
from django.views.generic import CreateView

from .forms import SignUpForm


class SignUpView(CreateView):
    form_class = SignUpForm
    template_name = "registration/signup.html"
    success_url = reverse_lazy("blog:post_list")

    def form_valid(self, form):
        response = super().form_valid(form)
        login(self.request, self.object)      # sign the new user in immediately
        return response

The function-view version, if you prefer the style you learned in the tutorial:

def signup(request):
    if request.method == "POST":
        form = SignUpForm(request.POST)
        if form.is_valid():
            user = form.save()
            login(request, user)
            return redirect("blog:post_list")
    else:
        form = SignUpForm()
    return render(request, "registration/signup.html", {"form": form})

Turn on the validators you want in settings - the defaults are already sensible:

AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
     "OPTIONS": {"min_length": 10}},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

7. The next parameter and safe redirects

When @login_required intercepts a request it sends the visitor to /accounts/login/?next=/post/3/edit/. After a successful login, LoginView reads that next value and returns the user to the page they were actually trying to reach.

# blog/views.py
from django.contrib.auth.decorators import login_required


@login_required
def post_new(request):
    ...

This is a redirect controlled by the URL, so it is an obvious attack surface: a phishing link could contain ?next=https://evil.example.com/. Django protects you by validating the target with url_has_allowed_host_and_scheme() and silently falling back to LOGIN_REDIRECT_URL when the host is not yours. Two rules follow:

  • List every hostname you serve in ALLOWED_HOSTS, because the check uses it.
  • If you ever handle next yourself, validate it the same way - do not call redirect(request.GET["next"]):
    from django.utils.http import url_has_allowed_host_and_scheme
    
    target = request.POST.get("next", "")
    if url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}):
        return redirect(target)
    return redirect("blog:post_list")

A quick test that documents the whole flow:

# accounts/tests.py
from django.test import TestCase
from django.urls import reverse


class SignUpTests(TestCase):
    def test_signup_logs_the_user_in(self):
        response = self.client.post(
            reverse("signup"),
            {"username": "ada", "email": "ada@example.com",
             "password1": "correct-horse-9", "password2": "correct-horse-9"},
        )
        self.assertRedirects(response, reverse("blog:post_list"))
        self.assertTrue(response.wsgi_request.user.is_authenticated)

    def test_new_post_requires_login(self):
        url = reverse("blog:post_new")
        self.assertRedirects(self.client.get(url), f"/accounts/login/?next={url}")

8. Exercise

  1. Include django.contrib.auth.urls and write templates/registration/login.html and logged_out.html on top of your base.html.
  2. Set LOGIN_URL, LOGIN_REDIRECT_URL and LOGOUT_REDIRECT_URL, then verify each one in the browser.
  3. Replace the logout link in the navigation with a POST form and confirm that a GET to /accounts/logout/ now answers 405.
  4. Add a SignUpView with UserCreationForm that logs the new user in, and try a password like password1234 to see the validators speak up.
  5. Decorate post_new with @login_required, log out, visit the URL and watch the next parameter bring you back after login. Then try ?next=https://example.com/ and observe that Django ignores it.

Further reading

After this lesson you will

  • Let visitors create an account and sign in
  • Style the built-in auth pages with your templates