Django Module 6 – Authentication and authorization
Login, logout and password reset
Wire up Django's login, logout and password reset views, protect pages with login_required or LoginRequiredMiddleware, and see what each step does to a session.
What you will learn
- Connect Django's login, logout and password reset views to your own templates
- Protect pages with login_required, or make every page private with LoginRequiredMiddleware
- Explain what login() and logout() change in the session, and why logout must be a POST
- Trace a password reset link from the email to the new password, and say when it stops working
Before you start
What you’ll learn · Before you start
What you will learn
- Connect Django's login, logout and password reset views to your own templates
- Protect pages with login_required, or make every page private with LoginRequiredMiddleware
- Explain what login() and logout() change in the session, and why logout must be a POST
- Trace a password reset link from the email to the new password, and say when it stops working
Before you start
On this page
A resident who signs in to Nivas on the tablet at the society office and walks away leaves a session that the next person at the tablet can use, and a password reset link left in an inbox opens her account for up to 3 days. Signing in, signing out and resetting a password are where a web app gives access and takes it back, and each step has a detail that decides whether it is safe.
Django ships all of it in django.contrib.auth: eight URLs and their views, to which you add templates. This lesson
connects them to Nivas, follows a session through a sign-in and a sign-out, protects pages one at a time and then all
at once, and traces a reset link from the email to the moment it stops working. Every run on the page went through
Django 6.1.2’s test client.
Django ships the sign-in views, and you write their templates
One line in the project’s URLconf, path('accounts/', include('django.contrib.auth.urls')), adds eight URL patterns.
Each is a class-based view with a name you can reverse, and each shows a template of yours under registration/,
except logout while LOGOUT_REDIRECT_URL is set:
| URL under accounts/, and its name | What it shows, from registration/ |
|---|---|
login/, login |
login.html: the sign-in form |
logout/, logout |
signs out on a POST only, then goes to LOGOUT_REDIRECT_URL (logged_out.html when it is not set) |
password_change/, password_change |
password_change_form.html, for a signed-in user |
password_change/done/, password_change_done |
password_change_done.html |
password_reset/, password_reset |
password_reset_form.html: asks for the email address |
password_reset/done/, password_reset_done |
password_reset_done.html: the same page for every address |
reset/<uidb64>/<token>/, password_reset_confirm |
password_reset_confirm.html: the new password |
reset/done/, password_reset_complete |
password_reset_complete.html |
The views are Django’s; the pages are yours, so they match the rest of the site. Here are the Nivas URLconf and the
sign-in template. The form is an AuthenticationForm with a username and a password field, and the hidden next
field carries the page the visitor asked for:
config/urls.py · nivas/config/urls.py
from django.urls import include, path
from notices import views
urlpatterns = [
path('', views.home, name='home'),
path('language/', views.choose_language, name='choose-language'),
path('rules/', views.society_rules, name='rules'),
path('accounts/', include('django.contrib.auth.urls')),
path('notices/', views.notice_list, name='notices'),
path('dues/', views.dues, name='dues'),
] templates/registration/login.html · nivas/templates/registration/login.html
{% extends "base.html" %}
{% block title %}Sign in{% endblock %}
{% block content %}
<h1>Sign in</h1>
{{ form.non_field_errors }}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
{{ form.username.as_field_group }}
{{ form.password.as_field_group }}
{# LoginView reads ?next= and checks that it stays on this site before it redirects there. #}
<input type="hidden" name="next" value="{{ next }}">
<button type="submit">Sign in</button>
</form>
<p><a href="{% url 'password_reset' %}">Forgotten your password?</a></p>
{% endblock %} notices/views.py · nivas/notices/views.py
from django.contrib.auth.decorators import login_not_required, login_required
from django.shortcuts import redirect, render
from django.views.decorators.http import require_POST
NOTICES = ['Water tank cleaning on Saturday, 10:00 to 13:00', 'Lift B is back in service']
@login_not_required # public, also when LoginRequiredMiddleware makes every other page private
def home(request):
return render(request, 'home.html')
@login_not_required
@require_POST
def choose_language(request):
"""A visitor's choice, kept in their session before they sign in (an anonymous session)."""
request.session['language'] = request.POST.get('language', 'en')
return redirect('home')
@login_not_required
def society_rules(request):
return render(request, 'rules.html')
@login_required # anonymous visitors go to settings.LOGIN_URL, with ?next= this page
def notice_list(request):
return render(request, 'notices/list.html', {'notices': NOTICES})
def dues(request): # no decorator: public, unless LoginRequiredMiddleware is installed
return render(request, 'notices/dues.html', {'due': 'B-1203: 2,450.00 due this month'}) The settings add two lines: LOGIN_REDIRECT_URL = 'home', where a sign-in goes when the request named no page, and
LOGOUT_REDIRECT_URL = 'home'. LOGIN_URL keeps its default, /accounts/login/. This program signs in through the
test client: django.test.Client: sends requests to a Django project from Python code, through the same handler, middleware and views a server would reach, without a network or a running server. More in “How Django handles a request”, as a browser would:
"""Sign in to Nivas through Django's LoginView: a protected page, the login page, a wrong password, the right one,
and a ?next= that points to another site.
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.contrib.auth.models import User # noqa: E402
from django.test import Client # noqa: E402
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
User.objects.create_user('asha', 'asha@example.com', 'lift-B-works-again')
def show(response):
where = f', Location: {response["Location"]}' if response.has_header('Location') else ''
print(f' {response.status_code} {response.reason_phrase}{where}')
browser = Client()
print('1. GET /notices/ without signing in')
show(browser.get('/notices/'))
print('2. GET /accounts/login/?next=/notices/')
response = browser.get('/accounts/login/?next=/notices/')
show(response)
print(f' template: {response.templates[0].name}')
print(f' Cache-Control: {response["Cache-Control"]}')
print('3. POST a wrong password')
response = browser.post('/accounts/login/', {'username': 'asha', 'password': 'wrong', 'next': '/notices/'})
show(response)
print(f' {response.context["form"].non_field_errors()[0]}')
print('4. POST the right password, next=/notices/')
show(browser.post('/accounts/login/', {'username': 'asha', 'password': 'lift-B-works-again', 'next': '/notices/'}))
response = browser.get('/notices/')
print(f' then GET /notices/: {response.status_code}, "{response.context["user"].get_username()}" is signed in')
print('5. POST the right password, next=https://example.com/')
other = Client()
show(other.post('/accounts/login/', {'username': 'asha', 'password': 'lift-B-works-again', 'next': 'https://example.com/'})) Output
1. GET /notices/ without signing in 302 Found, Location: /accounts/login/?next=/notices/ 2. GET /accounts/login/?next=/notices/ 200 OK template: registration/login.html Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private 3. POST a wrong password 200 OK Please enter a correct username and password. Note that both fields may be case-sensitive. 4. POST the right password, next=/notices/ 302 Found, Location: /notices/ then GET /notices/: 200, "asha" is signed in 5. POST the right password, next=https://example.com/ 302 Found, Location: /
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_login.py
Read the five steps. The notices page sent the visitor to the login page with next=/notices/, the page she asked
for. The login page came back with Cache-Control: max-age=0, no-cache, no-store, must-revalidate, private, because
LoginView is wrapped in never_cache; it is also wrapped in csrf_protect and sensitive_post_parameters, which
keeps the password out of Django’s error reports. A wrong password got one message about the username and the
password together, so the page never says which of the two was wrong. The right password led to /notices/, and the
last sign-in, whose next named another site, went to /, the LOGIN_REDIRECT_URL: LoginView follows next
only when it stays on this site.
Checkpoint
Check: The sign-in views and their templates
login() gives the session a new key, and logout() empties it
A session: Data Django keeps on the server for one browser, found again on each request through the random key in the browser's sessionid cookie. is data Django keeps on the server for one browser, found again on each request
through the random key in the sessionid cookie. Signing in writes the user into that data, so whoever holds the key
holds the account. That is why the key must change at the moment of signing in: an attacker who got a victim to use
a key the attacker already knew, which is called session fixation: An attack in which a victim is made to sign in with a session key the attacker already knows; giving the session a new key at sign-in, as login() does, defeats it., would
otherwise be signed in too. OWASP’s session management guidance makes renewing the key after every change of
privilege mandatory, and Django’s login() does it for you. Step through a visitor who chooses a language, signs in
and signs out:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- An anonymous session. Before signing in, the visitor chooses Hindi. The view stores it in the session, so SessionMiddleware saves a new row and sends its key, 32 characters, in the sessionid cookie.
- A private page. She opens /notices/. The view has @login_required and nobody is signed in, so the answer is a redirect to the login page, with the page she wanted in next.
- The password is checked. She posts her username and password. authenticate() asks each backend in AUTHENTICATION_BACKENDS; ModelBackend finds asha and checks the password against its stored hash.
- login() changes the key. login() gives the session a new key and deletes the row of key A, keeping its data: the language is still there. Whoever knew key A, even someone who planted it, now holds nothing.
- The user goes into the session. The new session holds the user's id, the backend that checked her and a hash made from her password, and the CSRF token is changed too. The answer redirects to next.
- Every later request. From now on AuthenticationMiddleware reads the user from the session on each request, so request.user is asha and /notices/ answers 200.
- A logout link does nothing. A GET to /accounts/logout/ answers 405 Method Not Allowed, with Allow: POST, OPTIONS. She is still signed in, so a link or an image on another page cannot sign her out.
- The logout form. The header form posts to /accounts/logout/ with its CSRF token. logout() flushes the session: the row of key B is deleted and the cookie is emptied.
The run behind the figure checks each claim against the session table:
"""What signing in and out does to a visitor's session: the key, the data in it, the CSRF token and the row in the
session table. Keys and tokens are random, so it prints their lengths and whether they changed.
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import logging
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.contrib.auth.models import User # noqa: E402
from django.contrib.sessions.models import Session # noqa: E402
from django.test import Client # noqa: E402
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
User.objects.create_user('asha', 'asha@example.com', 'lift-B-works-again')
logging.getLogger('django.request').setLevel(logging.ERROR) # the 405 below is expected
browser = Client()
def cookie(name):
morsel = browser.cookies.get(name)
return morsel.value if morsel else ''
def language():
key = cookie('sessionid')
row = Session.objects.filter(session_key=key).first()
return row.get_decoded().get('language', 'none') if row else 'none'
print('1. Before signing in, a visitor chooses Hindi')
browser.post('/language/', {'language': 'hi'})
browser.get('/accounts/login/') # the login page sets the csrftoken cookie
key_before, csrf_before = cookie('sessionid'), cookie('csrftoken')
print(f' session key: {len(key_before)} characters; language in the session: {language()}')
print('2. Asha signs in')
response = browser.post('/accounts/login/', {'username': 'asha', 'password': 'lift-B-works-again'})
print(f' {response.status_code} {response.reason_phrase}, Location: {response["Location"]}')
print(f' session key changed: {cookie("sessionid") != key_before}; language kept: {language()}')
print(f' CSRF token changed: {cookie("csrftoken") != csrf_before}')
print(f' the old key is still in the session table: {Session.objects.filter(session_key=key_before).exists()}')
print('3. GET /accounts/logout/, as a link would')
response = browser.get('/accounts/logout/')
print(f' {response.status_code} {response.reason_phrase}, Allow: {response["Allow"]}')
print(f' still signed in: {browser.get("/notices/").status_code == 200}')
print('4. POST /accounts/logout/, from the form in the page header')
key_signed_in = cookie('sessionid')
response = browser.post('/accounts/logout/')
print(f' {response.status_code} {response.reason_phrase}, Location: {response["Location"]}')
print(f' session cookie now: "{cookie("sessionid")}"; its row is gone: {not Session.objects.filter(session_key=key_signed_in).exists()}')
print(f' GET /notices/ now: {browser.get("/notices/").status_code}') Output
1. Before signing in, a visitor chooses Hindi session key: 32 characters; language in the session: hi 2. Asha signs in 302 Found, Location: / session key changed: True; language kept: hi CSRF token changed: True the old key is still in the session table: False 3. GET /accounts/logout/, as a link would 405 Method Not Allowed, Allow: POST, OPTIONS still signed in: True 4. POST /accounts/logout/, from the form in the page header 302 Found, Location: / session cookie now: ""; its row is gone: True GET /notices/ now: 302
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_sessions.py
At the sign-in the key changed, the row of the old key left the table, and the language chosen before the sign-in was kept, as the documentation says data of the anonymous session is. The CSRF token changed too. The new session holds the user’s id, the backend that checked her and a hash made from her password hash. Django compares that hash on every request, so when the password changes, every other session of the account stops matching and is signed out.
Signing out is a POST. Since Django 5.0, LogoutView answers a GET with 405 Method Not Allowed, so a link or an
image that another page loads cannot sign anyone out, and the template signs out with a small form that carries the
CSRF token.
A sign-out link
<a href="{% url 'logout' %}">Sign out</a> gets a 405 on Django 5.0 and later, and the resident stays signed in.
Sign out with <form method="post" action="{% url 'logout' %}">, {% csrf_token %} and a button, as the
header of base.html does.
logout() then flushes the session: the run found the row deleted, the cookie empty and /notices/ redirecting
again.
Key idea
login() replaces the session key and keeps the data, and logout() deletes the session on the server, so a key
learned before the sign-in, or left behind after the sign-out, opens nothing.
Checkpoint
Check: What login() and logout() do to the session
Protect pages one at a time, or make every page private
@login_required on a view sends a visitor who has not signed in to settings.LOGIN_URL, with the path they asked
for in next. It protects exactly the views that carry it, so a view somebody forgot to decorate is public. Nivas
has one: dues, the page of maintenance dues, has no decorator.
LoginRequiredMiddleware, new in Django 5.1, turns the rule around: with it in MIDDLEWARE, every view needs a
signed-in visitor unless it is marked @login_not_required. It goes after AuthenticationMiddleware, whose user it
reads, and Django’s system check reports any other order as error auth.E013. The sign-in and reset views of
django.contrib.auth already carry the mark, so the login page never redirects to itself. Step through the
decision it makes for each request:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The URL is resolved. The middleware works in process_view(), after the URLconf has found the view and before it runs, so it can read the view itself. An address that matches no view is a 404 as before.
- /rules/ is public. society_rules has @login_not_required, which sets login_required = False on the view. The middleware lets it through without asking who the visitor is.
- /dues/ is not marked. dues has no decorator at all. Without the middleware it answered 200 to anyone; now an unmarked view counts as private, so the middleware asks whether the visitor is signed in.
- Nobody is signed in. request.user is AnonymousUser, so the middleware answers 302 to /accounts/login/?next=/dues/, as @login_required would, and the view never runs.
- After signing in. Once Asha has signed in, the same request reaches the same question with request.user set to asha, and the view runs: a page nobody marked is private, never public by mistake.
This program asks for six pages as a visitor who has not signed in, first with the project’s middleware and then
with LoginRequiredMiddleware added:
"""Every page of Nivas asked for by a visitor who has not signed in, first with the project's MIDDLEWARE, then with
LoginRequiredMiddleware added after AuthenticationMiddleware. home, choose_language and society_rules are marked
@login_not_required; notice_list has @login_required; dues has no decorator at all.
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.conf import settings # noqa: E402
from django.test import Client, override_settings # noqa: E402
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
PAGES = ['/', '/rules/', '/notices/', '/dues/', '/accounts/login/', '/accounts/password_reset/']
PRIVATE = [*settings.MIDDLEWARE, 'django.contrib.auth.middleware.LoginRequiredMiddleware']
def answer(client, path):
response = client.get(path)
if response.status_code == 302:
return f'302 to {response["Location"]}'
return f'{response.status_code} {response.reason_phrase}'
print('Without LoginRequiredMiddleware:')
visitor = Client()
for path in PAGES:
print(f' {path:27} {answer(visitor, path)}')
print('With LoginRequiredMiddleware:')
with override_settings(MIDDLEWARE=PRIVATE):
visitor = Client() # a new client loads the new middleware
for path in PAGES:
print(f' {path:27} {answer(visitor, path)}') Output
Without LoginRequiredMiddleware: / 200 OK /rules/ 200 OK /notices/ 302 to /accounts/login/?next=/notices/ /dues/ 200 OK /accounts/login/ 200 OK /accounts/password_reset/ 200 OK With LoginRequiredMiddleware: / 200 OK /rules/ 200 OK /notices/ 302 to /accounts/login/?next=/notices/ /dues/ 302 to /accounts/login/?next=/dues/ /accounts/login/ 200 OK /accounts/password_reset/ 200 OK
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 private_by_default.py
Only one line changed. Without the middleware /dues/ answered 200 to anyone; with it, the same view redirected to
the login page with next=/dues/. The public pages, the notices page and Django’s own sign-in pages answered exactly
as before. With the middleware a forgotten mark fails closed: a page nobody marked asks for a sign-in, and a page that
should be public answers with a redirect that someone notices at once.
Check next before you redirect to it
LoginView follows next only after url_has_allowed_host_and_scheme() has confirmed that it stays on this site,
which is why the run’s sign-in with next=https://example.com/ went to /. A view of your own that redirects to
request.GET['next'] unchecked is an open redirect: A page that redirects to any address taken from the request, so that a link to a trusted site can send its visitors on to another site.: a link to your site that sends
people on to any other. Check it with the same function. A rule of your own, a path that starts with one /, must
also refuse a backslash in second place and every tab or line break, which browsers drop before they read the
address; the practice below writes that rule.
Checkpoint
Check: login_required and LoginRequiredMiddleware
A reset link works once, and for three days at most
A forgotten password is reset by email. PasswordResetView takes an address and emails a link to every active user
who has that address and a usable password; the link leads to a form for the new password. Step through the recorded
run, from the form to the same link opened a second time:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A known address. She posts asha@example.com to /accounts/password_reset/. PasswordResetView looks for active users with that address and a usable password, finds asha and sends the email.
- The same answer for everyone. The answer is 302 to /accounts/password_reset/done/. A post of nobody@example.com gets the same 302 and the same page, and no email, so the page tells nobody which addresses have accounts.
- The link. The email holds /accounts/reset/MQ/<token>/. MQ is the user id 1 in base64; the token is a timestamp and an HMAC of her id, password hash, last login and email, made with SECRET_KEY.
- The token leaves the address. She opens the link. The view checks the token, keeps it in her session and redirects to /accounts/reset/MQ/set-password/, so the token is no longer in the address bar, the history or a Referer header.
- A new password. She posts the new password twice. The form runs the password validators, set_password() stores a new hash, and the answer is 302 to /accounts/reset/done/.
- The link works once. Opening the same link again shows "This link no longer works": the HMAC was made from the old password hash, so it no longer matches. A new sign-in would have killed it too, through last_login.
- Three days at most. An unused link dies on its own once its timestamp is older than PASSWORD_RESET_TIMEOUT, 259,200 seconds. The recorded token was valid at exactly 259,200 seconds and refused at 259,201.
The run reads the email from mail.outbox, where the test environment keeps every message it sends:
"""The password reset flow of django.contrib.auth.urls, read from the email Django sends (mail.outbox), then the
clock of the reset token. Tokens are random, so the link is printed with <token> in their place.
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import datetime
import os
import re
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.conf import settings # noqa: E402
from django.contrib.auth.models import User # noqa: E402
from django.contrib.auth.tokens import PasswordResetTokenGenerator # noqa: E402
from django.core import mail # noqa: E402
from django.test import Client # noqa: E402
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
User.objects.create_user('asha', 'asha@example.com', 'lift-B-works-again')
def show(response):
where = f', Location: {response["Location"]}' if response.has_header('Location') else ''
print(f' {response.status_code} {response.reason_phrase}{where}')
browser = Client()
print('1. A reset for asha@example.com, then one for nobody@example.com')
show(browser.post('/accounts/password_reset/', {'email': 'asha@example.com'}))
show(browser.post('/accounts/password_reset/', {'email': 'nobody@example.com'}))
print(f' emails sent: {len(mail.outbox)}, to {mail.outbox[0].to[0]}; subject: {mail.outbox[0].subject}')
link = re.search(r'http://testserver(/accounts/reset/\S+/)', mail.outbox[0].body).group(1)
uid, token = link.split('/')[3:5]
print(f' link: {link.replace(token, "<token>")} (uid {uid} is the user id 1 in base64)')
print('2. Asha opens the link')
show(browser.get(link))
print('3. She chooses a new password')
response = browser.get('/accounts/reset/MQ/set-password/')
print(f' GET the form: {response.status_code}, link valid: {response.context["validlink"]}')
show(browser.post('/accounts/reset/MQ/set-password/', {'new_password1': 'quiet-hours-22-06', 'new_password2': 'quiet-hours-22-06'}))
print('4. The same link again')
response = Client().get(link, follow=True)
print(f' {response.status_code}, link valid: {response.context["validlink"]}')
print('5. Signing in')
print(f' old password: {Client().login(username="asha", password="lift-B-works-again")}; new password: {Client().login(username="asha", password="quiet-hours-22-06")}')
class Clock(PasswordResetTokenGenerator):
"""The token generator with a clock this program sets (Django's _now() is there to be replaced in tests)."""
now = datetime.datetime(2026, 1, 1, 9, 0)
def _now(self):
return self.now
print(f'6. The token clock: PASSWORD_RESET_TIMEOUT = {settings.PASSWORD_RESET_TIMEOUT} seconds')
user = User.objects.get(username='asha')
tokens = Clock()
token = tokens.make_token(user)
start = Clock.now
for seconds in [0, 259_200, 259_201]:
Clock.now = start + datetime.timedelta(seconds=seconds)
print(f' {seconds:>7,} seconds later: valid {tokens.check_token(user, token)}')
Clock.now = start
user.last_login = datetime.datetime(2026, 1, 1, 9, 5, tzinfo=datetime.UTC)
print(f' after Asha signs in again (last_login changes): valid {tokens.check_token(user, token)}') Output
1. A reset for asha@example.com, then one for nobody@example.com
302 Found, Location: /accounts/password_reset/done/
302 Found, Location: /accounts/password_reset/done/
emails sent: 1, to asha@example.com; subject: Password reset on testserver
link: /accounts/reset/MQ/<token>/ (uid MQ is the user id 1 in base64)
2. Asha opens the link
302 Found, Location: /accounts/reset/MQ/set-password/
3. She chooses a new password
GET the form: 200, link valid: True
302 Found, Location: /accounts/reset/done/
4. The same link again
200, link valid: False
5. Signing in
old password: False; new password: True
6. The token clock: PASSWORD_RESET_TIMEOUT = 259200 seconds
0 seconds later: valid True
259,200 seconds later: valid True
259,201 seconds later: valid False
after Asha signs in again (last_login changes): valid False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_reset.py
The link names your domain
Without the sites framework, the link’s domain comes from the request’s Host header, which Django checks against
ALLOWED_HOSTS. In production keep that list to your own domains, so a reset email can only ever point there.
Four details keep the flow safe. Both addresses got the same 302 and the same page, and only the known one got an
email, so the form tells nobody which addresses have accounts. The documentation adds a caution: sending an email
takes time, so a request for a known address may be measurably slower. The token in the link moved into the session
at the first visit, and the browser went on to set-password/, so the token is no longer in the address bar or in a
Referer header sent to another site. The token is an HMAC of the user’s id, password hash, last sign-in time,
email and a timestamp, made with SECRET_KEY; a new password changed the hash it was made from, so the second visit
showed “This link no longer works”. And with no use at all, the link expires: PASSWORD_RESET_TIMEOUT is 259,200
seconds, three days, and the token was still valid at exactly that age and refused one second later.
Key idea
A reset link is a signed, three-day token that dies as soon as the password, the email or the last sign-in changes, and the form gives the same answer for every address.
Checkpoint
Check: The password reset link
Practise: the sign-in gate of Nivas
Two small functions do the decisions of this lesson in plain Python, with sample tests that run in your browser: the
gate that makes Nivas private by default, and the check that keeps next on this site.
Code lab · 2 steps
Build the sign-in gate of Nivas
Decide each request the way LoginRequiredMiddleware does, then send a resident back after signing in only to a page of this site.
- Decide a request the way LoginRequiredMiddleware does To do
- Send a resident back only to a page of this site To do
Exercise · Easy · Python
Decide a request the way LoginRequiredMiddleware does
Nivas becomes private by default: every page needs a signed-in resident, except the pages the society marks public. Write gate(full_path, signed_in, public), which decides one request the way LoginRequiredMiddleware and redirect_to_login() do. It returns None when the page may be served, or the address to redirect to.
full_pathis the path with its query string, asrequest.get_full_path()gives it, such as'/notices/?page=2'.publicis a set of paths, such as{'/', '/rules/'}. Only the path decides, never the query string, and a path is public only when it is exactly one of them.- A signed-in visitor is served every page.
- The login page,
LOGIN_URL, is always served, whateverpublicholds, so a visitor is never sent from the login page to the login page. - Any other request from a visitor who has not signed in goes to
LOGIN_URLwith the wholefull_pathinnext, encoded as Django encodes it:urlencode({'next': full_path}, safe='/').
gate('/notices/?page=2', False, {'/', '/rules/'}) returns '/accounts/login/?next=/notices/%3Fpage%3D2'.
Starter code · gate.py
from urllib.parse import urlencode # noqa: F401 (you will need it for the redirect)
LOGIN_URL = '/accounts/login/'
def gate(full_path, signed_in, public):
"""Return None to serve the page, or the login URL with ?next= to redirect to."""
return None The sample tests · test_gate.py
from gate import gate
PUBLIC = {'/', '/rules/'}
def test_public_pages_are_served_to_everyone():
assert gate('/', False, PUBLIC) is None
assert gate('/rules/', False, PUBLIC) is None
def test_a_private_page_sends_the_visitor_to_sign_in():
assert gate('/dues/', False, PUBLIC) == '/accounts/login/?next=/dues/'
def test_the_query_string_goes_into_next_encoded():
assert gate('/notices/?page=2', False, PUBLIC) == '/accounts/login/?next=/notices/%3Fpage%3D2'
assert gate('/notices/?page=2&tower=B', False, PUBLIC) == '/accounts/login/?next=/notices/%3Fpage%3D2%26tower%3DB'
def test_a_signed_in_resident_is_served_every_page():
for path in ['/', '/dues/', '/notices/?page=2']:
assert gate(path, True, PUBLIC) is None, path
def test_the_login_page_is_always_served():
assert gate('/accounts/login/', False, set()) is None
assert gate('/accounts/login/?next=/dues/', False, PUBLIC) is None
def test_only_the_path_decides_whether_a_page_is_public():
assert gate('/rules/?lang=hi', False, PUBLIC) is None
def test_a_path_must_match_exactly_to_be_public():
assert gate('/rules-archive/', False, PUBLIC) == '/accounts/login/?next=/rules-archive/'
assert gate('/rules', False, PUBLIC) == '/accounts/login/?next=/rules'
def test_nothing_is_public_unless_listed():
assert gate('/', False, set()) == '/accounts/login/?next=/' A hint
Split the path from the query string first: full_path.split('?', 1)[0] is the part that decides. Then check the three cases that serve the page, in any order, and build the redirect only when none of them applies.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Easy · Python
Send a resident back only to a page of this site
After a sign-in, Nivas sends the resident to the page in ?next=. A redirect to whatever next says would let any link that carries a crafted next send residents from your sign-in page to another site, which is why LoginView checks it first. Write safe_next(next_url, default='/'), the stricter rule this project keeps for its own views: return next_url only when it is a path on this site, and default otherwise.
A path on this site starts with exactly one /:
'/notices/'and'/notices/?page=2'are paths of this site.'https://example.com/'names another site, and so does'//example.com/', which browsers read ashttps://example.com/on an HTTPS page.- Browsers treat a backslash in an address like a slash, so
'/\\example.com/', a slash and then one backslash (Python writes a backslash as'\\'), is read as'//example.com/'. - Browsers also remove every tab and line break from an address before they read it, so
'/\t/example.com/', a slash, a tab and then/example.com/, is read as'//example.com/'too. Anextthat holds a tab ('\t'), a line feed ('\n') or a carriage return ('\r') anywhere givesdefault. - An empty
next, or one that does not start with/('notices/'), givesdefault.
safe_next('/notices/?page=2') returns '/notices/?page=2', and safe_next('//example.com/') returns '/'.
Starter code · next_url.py
def safe_next(next_url, default='/'):
"""Return next_url if it is a path on this site, else default."""
return next_url The sample tests · test_next_url.py
from next_url import safe_next
def test_paths_of_this_site_are_kept():
assert safe_next('/notices/') == '/notices/'
assert safe_next('/notices/?page=2') == '/notices/?page=2'
assert safe_next('/') == '/'
def test_an_address_with_a_scheme_gives_the_default():
assert safe_next('https://example.com/') == '/'
assert safe_next('http://example.com/notices/') == '/'
def test_two_slashes_name_another_site():
assert safe_next('//example.com/') == '/'
def test_a_backslash_counts_as_a_slash():
assert safe_next('/\\example.com/') == '/'
def test_tabs_and_line_breaks_give_the_default():
assert safe_next('/\t/example.com/') == '/'
assert safe_next('/\n/example.com/') == '/'
assert safe_next('/\r\n/example.com/') == '/'
assert safe_next('/notices/\t') == '/'
def test_an_empty_or_relative_next_gives_the_default():
assert safe_next('') == '/'
assert safe_next('notices/') == '/'
def test_the_default_can_be_chosen():
assert safe_next('//example.com/', default='/notices/') == '/notices/'
assert safe_next('/dues/', default='/notices/') == '/dues/' A hint
Refuse first any next_url that holds '\t', '\n' or '\r'. Then look at the first two characters only: the first must be '/', and the second must be neither '/' nor a backslash, which Python writes as '\\'. next_url[1:2] is the second character, or '' when there is none, so it never raises an error on a string of one character.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then check the four ideas of this lesson, from the sign-in views to the reset link. The questions start where your answers have been weakest so far, and each choice explains itself:
Practice that adapts to you
Practise this lesson
12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): Why does Django’s logout need a POST request?
Since Django 5.0, LogoutView signs out only on POST and answers a GET with 405 Method Not Allowed. A GET must not
change anything, and a logout that worked on GET could be set off by any page that makes the browser load the
address, such as an image or a link, with no CSRF token to stop it. As a POST, the logout comes from a form of the
site itself, carrying the CSRF token, so only the resident’s own click signs them out. In a template that means a
small form with {% csrf_token %} and a button, not a link.
Two questions of this module’s bank start from these flows: a stolen phone that is still signed in, and a reset page that would like to tell people which addresses have no account.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A resident's phone was stolen while she was signed in to Nivas. From her laptop she changes her password. Is the stolen phone still signed in? Explain what Django does, what it leaves to you, and what you would add.
-
Mid to senior
Support asks for the password reset page to say "We have no account with that email", to cut down on tickets. What do you tell them, and what do you build instead?
Key takeaways
include('django.contrib.auth.urls')adds the sign-in, sign-out, password change and password reset views; you write their templates underregistration/and setLOGIN_REDIRECT_URLandLOGOUT_REDIRECT_URL.login()gives the session a new key and keeps its data, which defeats session fixation, andlogout()deletes the session on the server; logout is a POST since Django 5.0, so sign out with a form.@login_requiredprotects one view;LoginRequiredMiddleware, afterAuthenticationMiddleware, makes every view private unless it is marked@login_not_required, so a forgotten page fails closed.LoginViewfollowsnextonly to a page of the same site; a redirect of your own tonextmust be checked the same way.- A reset link gives the same answer for every address, moves its token out of the URL, works once and expires
after
PASSWORD_RESET_TIMEOUT, 259,200 seconds by default.
Terms in this lesson
- open redirect
- A page that redirects to any address taken from the request, so that a link to a trusted site can send its visitors on to another site.
- session
- Data Django keeps on the server for one browser, found again on each request through the random key in the browser's sessionid cookie.
- session fixation
- An attack in which a victim is made to sign in with a session key the attacker already knows; giving the session a new key at sign-in, as login() does, defeats it.
References
- Using the Django authentication system (Django Software Foundation)
- django.contrib.auth reference (Django Software Foundation)
- Middleware: LoginRequiredMiddleware (Django Software Foundation)
- Settings: LOGIN_URL (Django Software Foundation)
- Settings: PASSWORD_RESET_TIMEOUT (Django Software Foundation)
- Settings: ALLOWED_HOSTS (Django Software Foundation)
- System check framework: auth (Django Software Foundation)
- URL Standard (WHATWG)
- How to use sessions (Django Software Foundation)
- Django 5.0 release notes (Django Software Foundation)
- Django 5.1 release notes (Django Software Foundation)
- Session Management Cheat Sheet (OWASP Cheat Sheet Series)
- Unvalidated Redirects and Forwards Cheat Sheet (OWASP Cheat Sheet Series)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress