The recording for this lesson has not been published yet.
People forget passwords. In the tutorial the answer was
python manage.py changepassword from a console - fine while you are the only
user, useless the moment a friend signs up on your blog. Django already contains the whole
reset flow; your job is to add four templates and to teach the project how to send email.
You included django.contrib.auth.urls in lesson 4.2, so these four routes are
live already:
accounts/password_reset/ name="password_reset"
accounts/password_reset/done/ name="password_reset_done"
accounts/reset/<uidb64>/<token>/ name="password_reset_confirm"
accounts/reset/done/ name="password_reset_complete"
| View | Template | Purpose |
|---|---|---|
PasswordResetView |
registration/password_reset_form.htmlplus password_reset_email.html and password_reset_subject.txt |
Asks for an email address, builds the token, sends the message |
PasswordResetDoneView |
registration/password_reset_done.html |
"We have emailed you instructions" |
PasswordResetConfirmView |
registration/password_reset_confirm.html |
Validates the link and shows the new-password form |
PasswordResetCompleteView |
registration/password_reset_complete.html |
Confirmation and a link to the login page |
<!-- templates/registration/password_reset_form.html -->
{% extends "base.html" %}
{% block content %}
<h1>Reset your password</h1>
<p>Enter the address you signed up with and we will send you a link.</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Send the link</button>
</form>
{% endblock %}
<!-- templates/registration/password_reset_confirm.html -->
{% extends "base.html" %}
{% block content %}
{% if validlink %}
<h1>Choose a new password</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Save</button>
</form>
{% else %}
<h1>This link no longer works</h1>
<p>It has expired or has already been used.
<a href="{% url 'password_reset' %}">Request a new one</a>.</p>
{% endif %}
{% endblock %}
The email body is a template too, and it is plain text. Note that the protocol and domain come from the request through the sites framework, so you do not hard-code your host:
{# templates/registration/password_reset_email.html #}
Hello {{ user.get_username }},
Someone asked to reset the password for your account on {{ site_name }}.
If it was you, follow this link:
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
The link works once and expires in three days. If it was not you, ignore this
message - your password has not changed.
{# templates/registration/password_reset_subject.txt #}
Reset your password on {{ site_name }}
Django strips newlines from the rendered subject, but a template that ends with a blank line still produces a confusing header. One line, no trailing text.
The URL carries two opaque pieces:
uidb64 - the user's primary key, base64 encoded. It is not a secret, only
an identifier.token - an HMAC produced by
PasswordResetTokenGenerator from SECRET_KEY plus the user's
primary key, the current password hash, the last_login timestamp, the email
address and a timestamp.Two useful properties follow directly from that recipe:
last_login is part of the input.SECRET_KEY kills every outstanding link. That is
a feature, but it also means you must not regenerate the key on every deploy.# mysite/settings.py
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 3 # seconds; the default is 3 days
Nothing here is stored in the database - there is no "reset request" table to clean up.
If nobody has that email address, Django shows the identical "check your inbox" page and sends nothing. Do not "improve" this with an error message: it would turn your form into a tool for discovering who has an account.
You do not need a mail server to develop. Point Django at the console and the message is
printed into runserver's output:
# mysite/settings.py (development)
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
DEFAULT_FROM_EMAIL = "blog@localhost"
Content-Type: text/plain; charset="utf-8"
Subject: Reset your password on example.com
From: blog@localhost
To: ada@example.com
Hello ada,
...
http://127.0.0.1:8000/accounts/reset/Mg/cf8j2p-6b3f1c0d9e.../
Copy the link into the browser and you have tested the whole path. Other useful backends:
| Backend | What it does | Good for |
|---|---|---|
console.EmailBackend | prints to stdout | day-to-day development |
filebased.EmailBackend | writes .log files to EMAIL_FILE_PATH | inspecting HTML mail |
locmem.EmailBackend | collects into django.core.mail.outbox | tests - it is the default there |
smtp.EmailBackend | talks to a real server | staging and production |
Because tests use the locmem backend, asserting on the mail is easy:
# accounts/tests.py
from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase
from django.urls import reverse
class PasswordResetTests(TestCase):
def test_reset_sends_one_email_with_a_working_link(self):
get_user_model().objects.create_user(
"ada", "ada@example.com", "old-password-123"
)
self.client.post(reverse("password_reset"), {"email": "ada@example.com"})
self.assertEqual(len(mail.outbox), 1)
body = mail.outbox[0].body
link = [w for w in body.split() if "/accounts/reset/" in w][0]
self.assertEqual(self.client.get(link, follow=True).status_code, 200)
def test_unknown_address_is_silent(self):
self.client.post(reverse("password_reset"), {"email": "nobody@example.com"})
self.assertEqual(len(mail.outbox), 0)
Never send from your personal mailbox and never put credentials in
settings.py. Read them from the environment, exactly as you did for
SECRET_KEY in module 1:
# mysite/settings.py (production)
import os
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = os.environ["EMAIL_HOST"]
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587))
EMAIL_HOST_USER = os.environ["EMAIL_HOST_USER"]
EMAIL_HOST_PASSWORD = os.environ["EMAIL_HOST_PASSWORD"]
EMAIL_USE_TLS = True # port 587
# EMAIL_USE_SSL = True # port 465 - never both
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "blog@example.com")
SERVER_EMAIL = DEFAULT_FROM_EMAIL # used for error reports to ADMINS
EMAIL_TIMEOUT = 10 # seconds, so a dead server cannot hang a request
# .env - never committed
EMAIL_HOST=smtp.example-provider.com
EMAIL_PORT=587
EMAIL_HOST_USER=apikey
EMAIL_HOST_PASSWORD=...
DEFAULT_FROM_EMAIL=Blog <hello@yourdomain.com>
Use a transactional email service - Mailgun, Postmark, Brevo, Amazon SES, SendGrid - rather than a personal Gmail account. They handle SPF, DKIM and DMARC records for your domain, which is what actually keeps your mail out of the spam folder, and they show you delivery logs. On the free PythonAnywhere plan outbound connections are limited to a whitelist, so check that your provider is on it or upgrade the account.
The reset email is built from the request host, so two more settings matter in production:
ALLOWED_HOSTS must contain your real domain, otherwise the request is
rejected before the view runs.SITE_ID and edit the entry in the admin under Sites, or the mail says
example.com.Smoke-test the credentials from the shell before you trust the flow:
python manage.py shell -c "from django.core.mail import send_mail; \
send_mail('Test', 'It works.', None, ['you@example.com'])"
If the SMTP server is slow, your page is slow. EMAIL_TIMEOUT limits the
damage; the real fix is a background worker, which is module 9. Also throttle the reset
form, or someone will use it to mail-bomb an address.
password_reset_confirm.html.PASSWORD_RESET_TIMEOUT to 60, wait two minutes and confirm the link
expires.