Chapter 18 · Authentication, sessions and messages
18.14 Password reset: PasswordResetTokenGenerator and the reset views
A password reset lets a user who has forgotten a password set a new one through a link mailed to the account’s address. PasswordResetView and PasswordResetForm send the link, PasswordResetConfirmView checks it and takes the new password, and PasswordResetTokenGenerator makes and checks the token in the link without storing anything.
The link has to prove that whoever follows it read the mail sent to the account’s address. It should work once, and not for long, and the form that sends it should not tell a stranger which addresses have accounts. Django keeps no record of the links it sends: the token is an HMAC of the user’s own state, the password hash among it, which the reset itself changes, and checking a token is making it again. The four views are in django: "password_reset/ asks for the address, "password_reset/ says a mail went out, "reset/ is the link, and "reset/done/" says the password was set.
Asking for a link: PasswordResetView
PasswordResetView is a form view over PasswordResetForm, marked login_not_required and csrf_protect (The form views). When the form is valid it calls PasswordResetForm with the view’s templates and token generator, the request, and use_https=self, then redirects to "password_reset_done".
PasswordResetForm decides who gets a mail. It asks the default manager for the active users whose email field (AbstractBaseUser) matches by "__iexact"; then, in Python, it keeps those with a usable password whose stored address equals the typed one under _unicode_ci_compare: NFKC normalization and case folding, the comparison Unicode Technical Report 36 recommends, as its docstring says. The mail goes to the stored address: nora typed NORA@harbour.example, and it went to nora@harbour.example.
The visitor is told the same either way: the view redirects to the done page whether a mail was sent or not, which the documentation says keeps an attacker from learning which addresses exist; it warns that the time a mail takes to send may tell it all the same. Nor does a failed send show: it is caught and logged to "django.
The mail: PasswordResetForm.save
For each user, save makes the context of the mail’s templates: "uid", the primary key as a string in URL-safe base64 by urlsafe_base64_encode (The helpers in django/); "token", from PasswordResetTokenGenerator; "protocol", "https" or "http"; "domain" and "site_name", from get_current_site unless a caller gives save a domain_override, as the view does not; without django that is a RequestSite whose name and domain are both the request’s host; "user" and "email"; and the view’s extra_email_context over them all.
PasswordResetForm renders the subject and joins its lines into one, since, its comment says, a subject must not contain newlines; it renders the body and sends an EmailMultiAlternatives, with an HTML alternative only when the view names an HTML template (Email messages: EmailMessage and EmailMultiAlternatives, Sending email: mailers and the email backends). The subject’s template, "registration/, is the auth application’s; the body’s, "registration/, and the templates of the views’ pages are the admin’s, so a site without the admin writes its own. The office’s ends with {{ protocol }}://. Nora’s mail, subject “Password reset on harbour.example”:
Someone asked to reset the password of nora.
http://harbour.example/accounts/reset/MQ/dgj90m-95ce358e6d687e9859dffa3b75fa3751/
The token: PasswordResetTokenGenerator
PasswordResetTokenGenerator passes to PasswordResetTokenGenerator the seconds from the start of 2001 to now, read as local time with no zone, and the generator’s secret. The token is two parts joined by a hyphen. The first is that number in base 36, which a comment says stays six digits until about 2069. The second is salted_hmac of PasswordResetTokenGenerator, salted with PasswordResetTokenGenerator, the class’s dotted path, with PasswordResetTokenGenerator, SHA-256 unless a subclass names another, keeping every second hex digit, 32 of 64, to shorten the URL, as the comment says.
PasswordResetTokenGenerator and PasswordResetTokenGenerator are properties that read SECRET_KEY and SECRET_KEY_FALLBACKS at each use unless set on the instance (Secret keys and django). default_token_generator is the module’s one instance, the default of the views and of save.
_make_hash_value joins as text the user’s primary key, the stored password string, the last login with its microseconds and zone dropped, or nothing before the first login, the timestamp, and the user’s email, or nothing. Its docstring says why a token works once: the password field changes at a reset, even to the same password, because of the salt; the last login usually changes soon after; failing both, the timeout ends it. The HMAC, it adds, keeps a token from helping to crack the password while the secret is safe. A changed address ends a token the same way.
PasswordResetTokenGenerator refuses a missing user or token, a token that is not two parts joined by one hyphen, and a first part that is not base 36. It makes the token again for that timestamp under secret and then each of secret_fallbacks, comparing with constant_time_compare, and refuses if none matches. Last, it refuses a timestamp more than PASSWORD_RESET_TIMEOUT seconds old, 259,200 (three days) by default. The timestamp is inside the HMAC, so it cannot be moved without the comparison failing. The documentation says a shorter timeout makes a token no harder to guess, since tokens are designed to be safe from brute force without one.
check_token do with each. The last part changes when nora’s password, last login or address does.Following the link: PasswordResetConfirmView
PasswordResetConfirmView raises ImproperlyConfigured unless the URL gave "uidb64" and "token". It finds the user with PasswordResetConfirmView, which decodes the base64, converts it with the primary key field’s to_python, and asks the default manager; a value that does not decode, convert or name a user gives no user, and the page for an invalid link. With a user there are two cases:
- The URL holds a token. If
PasswordResetTokenGeneratoraccepts it, the view stores it in the session under.check_token INTERNAL_RESET_SESSION_TOKEN,"_password_reset_token", and redirects to the same path with the token replaced byPasswordResetConfirmView,.reset_url_token "set-password". Its comment gives the reason: the page with the form has no token in its URL, so the token cannot leak in the"Referer"header of a request made from that page. - The URL holds
"set-password". The view checks the session’s token and shows the form if it passes.
Anything else renders the template with "validlink" false and no form. The session’s token is checked again at every request to the form’s page, the submission included.
The form is SetPasswordForm for the user found: two fields that must match, and the validators of AUTH_PASSWORD_VALIDATORS (The authentication forms: AuthenticationForm and the password forms). PasswordResetConfirmView saves it, which sets the password and saves the user; deletes the session’s entry; logs the user in, with PasswordResetConfirmView, only if PasswordResetConfirmView is true, which by default it is not; and redirects to PasswordResetCompleteView. The new password changes the hash a logged-in session keeps, so a session the user has elsewhere ends at its next request (The user of a request: login, logout and AuthenticationMiddleware).
The test client, for nora:
| request | status | what followed |
|---|---|---|
"POST" /accounts/password_reset/, NORA@harbour.example |
status 302 | what followed to /accounts/password_reset/done/; one mail, to nora@harbour.example |
"POST" the same, nobody@harbour.example |
status 302 | what followed to /accounts/password_reset/done/; no mail |
"GET" the link |
status 302 | what followed to /accounts/reset/MQ/set-password/; "sessionid" set, the token in the session |
"GET" /accounts/reset/MQ/set-password/ |
status 200 | what followed the form |
"POST" there, the new password twice |
status 302 | what followed to /accounts/reset/done/; the token gone from the session |
"GET" the link again |
status 200 | what followed “This link is not valid.” |
A link sent to tomas stopped working, with no reset, when tomas logged in before following it: tomas’s last login had changed.