Chapter 18 · Authentication, sessions and messages
18.12 The authentication forms: AuthenticationForm and the password forms
django/ holds the forms under the login and password views and the admin: AuthenticationForm, which checks a username and a password with authenticate; SetPasswordMixin, which checks a new password typed twice and sets it, and the forms built on it; and UserChangeForm, which shows the stored hash and never a password.
A new password is typed twice and must agree with itself, is judged by the validators of AUTH_PASSWORD_VALIDATORS with its user in hand, and reaches the database only as a hash, through AbstractBaseUser. The forms check in clean and _post_clean, hooks of Validation: full_clean, cleaned_data and the errors, and set the password in save. AuthenticationForm takes its user model from get_user_model when the module is imported, and the password forms are handed their user, so these serve any subclass of AbstractBaseUser; the creation and change forms are model forms of User, which a project with its own user model rewrites or extends.
UsernameField
UsernameField, the username’s field in the login, creation and change forms, normalizes it to Unicode’s NFKC form, as AbstractBaseUser does a stored username. A value longer than the field’s max_length is left alone: normalizing, says the comment, can lengthen a string but never shorten it, so such a value fails anyway, and it is slow enough on Windows to serve a denial of service.
AuthenticationForm
AuthenticationForm takes the request as its first argument, so the data comes by keyword, data=. It passes the request to authenticate, for the backends; LoginView gives it its own (The login and password views: LoginView, LogoutView and PasswordChangeView). Its username field is called "username" whatever the user model calls its own: AuthenticationForm gives it the max_length of the field that USERNAME_FIELD names, or 254 where that has none, and its verbose name as the label if none is set.
AuthenticationForm acts only when both fields passed. It calls authenticate with the request, username= and password=. None raises the error that AuthenticationForm makes, code "invalid_login", filed under "__all__"; a user goes to AuthenticationForm. AuthenticationForm returns the user kept, or None, and LoginView passes it to login.
# a shell, with the harbour office's users
>>> from django.contrib.auth.forms import AuthenticationForm
>>> form = AuthenticationForm(data={"username": "nora", "password": "Beagle-1831"})
>>> form.is_valid(), form.get_user()
(False, None)
>>> form.errors.as_data()
{'__all__': [ValidationError(['Please enter a correct username and password. Note that both fields may be case-sensitive.'])]}
One message serves a username no user has and a wrong password. confirm_login_allowed refuses a user whose is_active is false, code "inactive", but under the default backend it never meets one: ModelBackend refuses such a user first, and the inactive user with the right password is told what everyone is told. The method decides only under a backend that lets inactive users through, such as AllowAllUsersModelBackend, as the documentation says, and then only for a right password:
# a shell, with tomas made inactive
>>> tomas = {"username": "tomas", "password": "Beagle-1831"}
>>> AuthenticationForm(data=tomas).non_field_errors().as_data()[0].code
'invalid_login'
>>> settings.AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.AllowAllUsersModelBackend"]
>>> AuthenticationForm(data=tomas).errors.as_data()
{'__all__': [ValidationError(['This account is inactive.'])]}
The admin’s AdminAuthenticationForm extends confirm_login_allowed to refuse a user who is not staff (The site’s own pages: the index, the login and AdminSite).
Setting a password: SetPasswordMixin
SetPasswordMixin makes the two fields, which keep spaces. The mixin’s three steps are called by the form that uses it, at the hooks it chooses:
SetPasswordMixinfiles.validate_passwords "password_mismatch"under the second field when both passed and differ.SetPasswordMixinruns.validate_password_for_user validate_passwordon the second field’s value with the user, and files what it raises under that field (Password validation:validate_passwordand the validators).SetPasswordMixinsets the password from the first field and saves the user, unless.set_password_and_save commit=False.
An error takes its field out of cleaned_data, so after a mismatch the second step finds nothing: a pair that does not agree is never shown to the validators, and either complaint stands under the confirmation.
# a shell, with nora
>>> from django.contrib.auth.forms import SetPasswordForm
>>> SetPasswordForm(nora, {"new_password1": "password1", "new_password2": "password2"}).errors.as_data()
{'new_password2': [ValidationError(['The two password fields didn’t match.'])]}
>>> SetPasswordForm(nora, {"new_password1": "password1", "new_password2": "password1"}).errors.as_data()
{'new_password2': [ValidationError(['This password is too common.'])]}
SetPasswordForm, handed the user before the data, runs the first two steps in clean and the third in save. PasswordResetConfirmView uses it at the end of a reset, whose first form, PasswordResetForm, mails the link (Password reset: PasswordResetTokenGenerator and the reset views). PasswordChangeForm, for PasswordChangeView, adds "old_password", checked by PasswordChangeForm with AbstractBaseUser: a wrong one is refused under its field with “Your old password was entered incorrectly. Please enter it again.”, and the new pair is checked all the same.
Creating a user: BaseUserCreationForm and UserCreationForm
BaseUserCreationForm is a model form of User with the username and the two password fields. It checks the match in clean and runs the validators in _post_clean, after BaseModelForm has copied the cleaned values onto the new user, as its comment says; so UserAttributeSimilarityValidator compares the password with the username being chosen. BaseUserCreationForm sets the password, then saves the user and its many-to-many values.
The username’s uniqueness is the model’s: its field is unique, and BaseModelForm asks the database for a row with that value (Model forms: ModelForm and fields_for_model). UserCreationForm adds a match that ignores case ("username__iexact"), and files the model’s message for a duplicate, from Model, through BaseModelForm; it then returns nothing, so cleaned_data holds None for the username, and the model’s own check passes over a field with an error. The documentation’s reason is confusion between similar usernames. The base form has only the model’s check, an exact match, which on the example’s SQLite database finds no “Nora”:
# a shell, with the harbour office's users
>>> from django.contrib.auth.forms import BaseUserCreationForm, UserCreationForm
>>> taken = {"username": "Nora", "password1": "Galley-Proof-77", "password2": "Galley-Proof-77"}
>>> UserCreationForm(taken).errors.as_data()
{'username': [ValidationError(['A user with that username already exists.'])]}
>>> BaseUserCreationForm(taken).is_valid()
True
SetUnusablePasswordMixin and the admin’s forms
SetUnusablePasswordMixin lets a form set an unusable password instead, so that the user cannot log in by password but, says its help text, may through another backend. Its radio choice, "true" at first or "false", is replaced in cleaned_data by "set_usable_password". False, nothing is checked and saving calls AbstractBaseUser; true, an empty password field is refused as "required", the fields being optional where the radio is offered, and SetPasswordMixin does the rest. Those forms are the admin’s: AdminUserCreationForm, a UserCreationForm with the radio, and the AdminPasswordChangeForm of django/, which offers it only for a user whose password is usable (The admin).
UserChangeForm and the read-only hash
UserChangeForm is a model form of every field of User, with the password replaced by a ReadOnlyPasswordHashField, which is not required and is disabled. A disabled field cleans its initial value whatever was posted, so the form writes the stored hash back as it was (A form field: the base class).
Its widget, ReadOnlyPasswordHashWidget, draws "auth/, which calls the tag render_password_as_hash: “No password set.” for an empty or unusable string, “Invalid password format or unknown hashing algorithm.” where finding the hasher or reading the string raises ValueError, otherwise what BasePasswordHasher gives for the hasher identify_hasher finds, for PBKDF2 the salt and the hash starred after six characters. A button follows, “Reset password”, or “Set password” with no usable password, linking to "../password/", which from a user’s change page in the admin is the password page that UserAdmin adds:
# a shell, with nora's password as the opening page shows it
>>> from django.contrib.auth.forms import UserChangeForm
>>> print(UserChangeForm(instance=nora)["password"])
<div disabled aria-describedby="id_password_helptext" id="id_password">
<p><strong>algorithm</strong>: <bdi>pbkdf2_sha256</bdi> <strong>iterations</strong>: <bdi>1800000</bdi> <strong>salt</strong>: <bdi>RVCWTs****************</bdi> <strong>hash</strong>: <bdi>KqCZK8**************************************</bdi></p>
<p><a role="button" class="button" href="../password/">Reset password</a></p>
</div>