138 lines
4.9 KiB
Python
138 lines
4.9 KiB
Python
"""
|
|
Based on https://github.com/ubernostrum/pwned-passwords-django.
|
|
|
|
Original license:
|
|
|
|
Copyright (c) 2018, James Bennett
|
|
All rights reserved.
|
|
|
|
Redistribution and use in source and binary forms, with or without
|
|
modification, are permitted provided that the following conditions are
|
|
met:
|
|
|
|
* Redistributions of source code must retain the above copyright
|
|
notice, this list of conditions and the following disclaimer.
|
|
* Redistributions in binary form must reproduce the above
|
|
copyright notice, this list of conditions and the following
|
|
disclaimer in the documentation and/or other materials provided
|
|
with the distribution.
|
|
* Neither the name of the author nor the names of other
|
|
contributors may be used to endorse or promote products derived
|
|
from this software without specific prior written permission.
|
|
|
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
|
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."""
|
|
|
|
|
|
import hashlib
|
|
import logging
|
|
|
|
import requests
|
|
from django.conf import settings
|
|
from django.contrib.auth.password_validation import CommonPasswordValidator
|
|
from django.core.exceptions import ValidationError
|
|
from django.utils.six import string_types
|
|
from django.utils.translation import gettext as _, ungettext
|
|
|
|
from judge.utils.unicode import utf8bytes
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
API_ENDPOINT = "https://api.pwnedpasswords.com/range/{}"
|
|
REQUEST_TIMEOUT = 2.0 # 2 seconds
|
|
|
|
|
|
def _get_pwned(prefix):
|
|
"""
|
|
Fetches a dict of all hash suffixes from Pwned Passwords for a
|
|
given SHA-1 prefix.
|
|
"""
|
|
try:
|
|
response = requests.get(
|
|
url=API_ENDPOINT.format(prefix),
|
|
timeout=getattr(
|
|
settings,
|
|
"PWNED_PASSWORDS_API_TIMEOUT",
|
|
REQUEST_TIMEOUT,
|
|
),
|
|
)
|
|
response.raise_for_status()
|
|
except requests.RequestException:
|
|
# Gracefully handle timeouts and HTTP error response codes.
|
|
log.warning("Skipped Pwned Passwords check due to error", exc_info=True)
|
|
return None
|
|
|
|
results = {}
|
|
for line in response.text.splitlines():
|
|
line_suffix, _, times = line.partition(":")
|
|
results[line_suffix] = int(times)
|
|
|
|
return results
|
|
|
|
|
|
def pwned_password(password):
|
|
"""
|
|
Checks a password against the Pwned Passwords database.
|
|
"""
|
|
if not isinstance(password, string_types):
|
|
raise TypeError("Password values to check must be strings.")
|
|
password_hash = hashlib.sha1(utf8bytes(password)).hexdigest().upper()
|
|
prefix, suffix = password_hash[:5], password_hash[5:]
|
|
results = _get_pwned(prefix)
|
|
if results is None:
|
|
# Gracefully handle timeouts and HTTP error response codes.
|
|
return None
|
|
return results.get(suffix, 0)
|
|
|
|
|
|
class PwnedPasswordsValidator(object):
|
|
"""
|
|
Password validator which checks the Pwned Passwords database.
|
|
"""
|
|
|
|
DEFAULT_HELP_MESSAGE = _("Your password can't be a commonly used password.")
|
|
DEFAULT_PWNED_MESSAGE = _("This password is too common.")
|
|
|
|
def __init__(self, error_message=None, help_message=None):
|
|
self.help_message = help_message or self.DEFAULT_HELP_MESSAGE
|
|
error_message = error_message or self.DEFAULT_PWNED_MESSAGE
|
|
|
|
# If there is no plural, use the same message for both forms.
|
|
if isinstance(error_message, string_types):
|
|
singular, plural = error_message, error_message
|
|
else:
|
|
singular, plural = error_message
|
|
self.error_message = {
|
|
"singular": singular,
|
|
"plural": plural,
|
|
}
|
|
|
|
def validate(self, password, user=None):
|
|
amount = pwned_password(password)
|
|
if amount is None:
|
|
# HIBP API failure. Instead of allowing a potentially compromised
|
|
# password, check Django's list of common passwords generated from
|
|
# the same database.
|
|
CommonPasswordValidator().validate(password, user)
|
|
elif amount:
|
|
raise ValidationError(
|
|
ungettext(
|
|
self.error_message["singular"],
|
|
self.error_message["plural"],
|
|
amount,
|
|
),
|
|
params={"amount": amount},
|
|
code="pwned_password",
|
|
)
|
|
|
|
def get_help_text(self):
|
|
return self.help_message
|