Chapter 18 · Authentication, sessions and messages
18.6 Password validation: validate_password and the validators
validate_password decides whether a new password is good enough: it runs every validator that AUTH_PASSWORD_VALIDATORS lists and gathers their refusals into one ValidationError. Beside it, django/ holds the four validators Django ships, their help texts, and password_changed, which tells them a password was set.
A password validator is an object of any class with two methods, validate(password, user=None), which raises ValidationError to refuse, and get_help_text(), which states the rule; it may have a third, password_changed(password, user=None). Validation is asked where a person chooses a password, never by the user model.
The validators of a process
AUTH_PASSWORD_VALIDATORS is a list of dictionaries, each naming a class by its dotted path under "NAME", with keyword arguments under "OPTIONS". It is empty by default, so a project that leaves it validates nothing. The settings startproject writes list Django’s four without options: UserAttributeSimilarityValidator, MinimumLengthValidator, CommonPasswordValidator, NumericPasswordValidator.
get_password_validators imports each class with import_string and calls it with the options. A failed import, a class missing from its module included, becomes ImproperlyConfigured, as does a name that is not callable. get_default_password_validators calls it with the setting under functools.cache, once in a process. In Django only the test framework empties the cache, when a test overrides the setting (Overriding settings). So the setting’s CommonPasswordValidator reads its file once.
validate_password
validate_password asks every validator of the setting, or of a list it is given, in order, with the password and the user. A ValidationError from one is kept and the next is asked; any other exception passes up. If any refused, it raises one ValidationError of the list, so every reason comes at once, each with its code (Django’s exceptions: django). With startproject’s four, for nora (nora@harbour.example) and for no user:
# a run: validate_password, and the messages of the error it raised
"Endeavour-1768", nora: accepted
"Nora-17", nora:
The password is too similar to the username.
This password is too short. It must contain at least 8 characters.
"Harbour-Nora", nora:
The password is too similar to the email address.
" LIGHTHOUSE ", nora:
This password is too common.
"17681768", nora:
This password is entirely numeric.
"Harbour-Nora", no user: accepted
Without a user, the similarity validator accepts anything.
The four validators
MinimumLengthValidator refuses a password shorter than min_length=8 or the options’ length, with the code "password_too_short". NumericPasswordValidator refuses one made only of digits, "password_entirely_numeric"; it asks str.isdigit, which is true of other scripts’ digits too.
CommonPasswordValidator refuses a password found in a list, "password_too_common". Django’s, "common-passwords beside the module, is lowercased and deduplicated, and the class’s docstring credits it to Royce Williams. The validator reads the whole file into a set when it is made, as gzip or else as plain text, and compares the password lowercased and stripped of surrounding white space, as " LIGHTHOUSE " shows.
UserAttributeSimilarityValidator refuses a password too like the user’s own details: the attributes of UserAttributeSimilarityValidator, "username", "first_name", "last_name" and "email", unless the options name others, skipping one missing, empty or not a string. Each value is compared whole and in its parts split at non-word characters, so “Harbour-Nora” fell to the harbour of nora@harbour.example. The measure is difflib of the two lowercased, which counts the characters they share in any order: harbour-nora against harbour scores 0.74, an anagram of a username 1.0. At max_similarity=0 or above, or the options’ limit (at least 0.1), the password is refused with "password_too_similar" and the field’s verbose name, or the attribute’s name. A part a tenth of the password’s length or less, too short to reach the limit, is not measured (at a limit of 0.2 or below, only a shorter part), which the docstring of exceeds_maximum_length_ratio says avoids the potentially expensive sequence matching.
The help texts
password_validators_help_texts returns each validator’s get_help_text() in the setting’s order, sentences such as “Your password must contain at least 8 characters.” password_validators_help_text_html puts them, escaped, in an HTML list. It is wrapped by lazy: the password forms take it as a help text as their classes are defined, and it is built at each rendering (Lazy values: lazy, Promise and keep_lazy).
password_changed
AbstractBaseUser keeps the raw password on the instance as AbstractBaseUser. AbstractBaseUser, once the row is written, hands it to password_changed with the user and forgets it, so the hook runs once, at the first save after a password is set, even a save that does not write the password. password_changed calls the method of that name on each validator that has one; none of Django’s four has. The documentation’s example is a validator refusing a password used before.
Two hashes are stored without it. AbstractBaseUser, upgrading a hash, clears the raw password before it saves, its comment saying an upgrade is not a password change; UserManager hashes with make_password, not set_password.
Who asks
The forms ask through SetPasswordMixin, which puts the refusals on the second password field. BaseUserCreationForm asks once the form’s values are on the new user, so the username being chosen is compared; SetPasswordForm (and so PasswordChangeForm) and AdminPasswordChangeForm ask too, the admin’s forms only when the password is to be usable (The authentication forms: AuthenticationForm and the password forms). createsuperuser asks only when it prompts, against an unsaved user made from the answers, and offers to go on anyway; changepassword asks whenever the two entries match, for three tries at most (Creating a superuser: createsuperuser and changepassword).
AbstractBaseUser, UserManager and UserManager never ask, nor does createsuperuser when it takes "DJANGO_SUPERUSER_PASSWORD" without prompting. The documentation gives two reasons: developers, not users, work at that level, and a model’s validation does not run as it is created.