This time I’m gonna address Django’s builtin authentication system, more specifically the ways we can build custom improvements over the already very solid foundations it provides.
The idea for this post came from reading an article summing up some considerations we should have when dealing with passwords. Most of those considerations are about what controls to implement (what “types” of passwords to accept) and how to securely store those passwords. By default Django does the following:
- Passwords are stored using PBKDF2. There are also other alternatives such as Argon2 and bcrypt, that can be defined in the setting
PASSWORD_HASHERS
. - Every Django release the “strength”/cost of this algorithm is increased. For example, version 3.1 applied 216000 iterations and the last version (3.2 at the time of writing) applies 260000. The migration from one to another is done automatically once the user logs in.
- There are a set of validators that control the kinds of passwords allowed to be used in the system, such as enforcing a minimum length. These validators are defined on the setting
AUTH_PASSWORD_VALIDATORS
.
By default when we start a new project these are the included validators :
UserAttributeSimilarityValidator
MinimumLengthValidator
CommonPasswordValidator
NumericPasswordValidator
The names are very descriptive and I would say a good starting point. But as the article mentions the next step is to make sure users aren’t reusing previously breached passwords or using passwords that are known to be easily guessed (even when complying with the other rules). CommonPasswordValidator
already does part of this job but with a very limited list (20000 entries).
Improving password validation
So for the rest of this post I will show you some ideas on how we can make this even better. More precisely, prevent users from using a known weak password.
1. Use your own list
The easiest approach, but also the more limited one, is providing your own list to `CommonPasswordValidator`, containing more entries than the ones provided by default. The list must be provided as a file with one entry in lower case per line. It can be set like this:
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
"OPTIONS": {"password_list_path": "<path_to_your_file>"}
}
2. Use zxcvbn-python
Another approach is to use an existing and well-known library that evaluates the password, compares it with a list of known passwords (30000) but also takes into account slight variations and common patterns.
To use zxcvbn-python
we need to implement our own validator, something that isn’t hard and can be done this way:
# <your_app>/validators.py
from django.core.exceptions import ValidationError
from zxcvbn import zxcvbn
class ZxcvbnValidator:
def __init__(self, min_score=3):
self.min_score = min_score
def validate(self, password, user=None):
user_info = []
if user:
user_info = [
user.email,
user.first_name,
user.last_name,
user.username
]
result = zxcvbn(password, user_inputs=user_info)
if result.get("score") < self.min_score:
raise ValidationError(
"This passoword is too weak",
code="not_strong_enough",
params={"min_score": self.min_score},
)
def get_help_text(self):
return "The password must be long and not obvious"
Then we just need to add to the settings just like the other validators. It’s an improvement but we still can do better.
3. Use “have i been pwned?”
As suggested by the article, a good approach is to make use of the biggest source of leaked passwords we have available, haveibeenpwned.com.
The full list is available for download, but I find it hard to justify a 12GiB dependency on most projects. The alternative is to use their API (documentation available here), but again we must build our own validator.
# <your_app>/validators.py
from hashlib import sha1
from io import StringIO
from django.core.exceptions import ValidationError
import requests
from requests.exceptions import RequestException
class LeakedPasswordValidator:
def validate(self, password, user=None):
hasher = sha1(password.encode("utf-8"))
hash = hasher.hexdigest().upper()
url = "https://api.pwnedpasswords.com/range/"
try:
resp = requests.get(f"{url}{hash[:5]}")
resp.raise_for_status()
except RequestException:
raise ValidationError(
"Unable to evaluate password.",
code="network_failure",
)
lines = StringIO(resp.text).readlines()
for line in lines:
suffix = line.split(":")[0]
if hash == f"{hash[:5]}{suffix}":
raise ValidationError(
"This password has been leaked before",
code="leaked_password",
)
def get_help_text(self):
return "Use a different password"
Then add it to the settings.
Edit: As suggested by one reader, instead of this custom implementation we could use pwned-passwords-django (which does practically the same thing).
And for today this is it. If you have any suggestions for other improvements related to this matter, please share them in the comments, I would like to hear about them.
3 replies on “Django Friday Tips: Password validation”
Good stuff. You don’t reference the NIST guidelines explicitly, but I think your recommendations are more or less a superset. The avoidance of common passwords is arguably the most important thing for ordinary users, so it’s good that you covered that so well.
[…] This time I’m gonna address Django’s builtin authentication system, more specifically the ways we can build custom improvements over the already very solid foundations it provides. Read more […]