├── .editorconfig ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── django_pwned ├── __init__.py ├── api.py ├── apps.py ├── exceptions.py ├── locale │ └── fa │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po └── validators.py ├── pyproject.toml ├── setup.py └── tests ├── __init__.py ├── settings.py ├── test_github.py ├── test_min_unique_chars.py └── test_pwned.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | max_line_length = 120 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | charset = utf-8 13 | 14 | # Do not add "md" here! It breaks Markdown re-formatting in PyCharm. 15 | [*.{js,ts,jsx,tsx,json,yml,yaml,md}] 16 | indent_size = 2 17 | 18 | [*.md] 19 | # Shorter lines in documentation files improves readability 20 | max_line_length = 80 21 | # 2 spaces at the end of a line forces a line break in MarkDown 22 | trim_trailing_whitespace = false 23 | 24 | [Makefile] 25 | indent_style = tab 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | pre-commit: 7 | name: Run pre-commits 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-python@v5 12 | - uses: pre-commit/action@v3.0.1 13 | 14 | test: 15 | name: Test 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | python-version: ["3.12", "3.11", "3.10"] 20 | django-version: ["5.0", "4.2"] 21 | include: 22 | - python-version: "3.9" 23 | django-version: "4.2" 24 | - python-version: "3.8" 25 | django-version: "4.2" 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Set up Python ${{ matrix.python-version }} 29 | uses: actions/setup-python@v5 30 | with: 31 | python-version: ${{ matrix.python-version }} 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install django~=${{ matrix.django-version }} 36 | pip install -e ".[dev]" 37 | - name: Run tests 38 | run: | 39 | py.test --cov --junitxml=pytest.xml --cov-report=term-missing:skip-covered | tee pytest-coverage.txt 40 | - if: ${{ strategy.job-index == 0 }} 41 | name: Pytest coverage comment 42 | id: coverageComment 43 | uses: MishaKav/pytest-coverage-comment@main 44 | with: 45 | pytest-coverage-path: ./pytest-coverage.txt 46 | junitxml-path: ./pytest.xml 47 | - if: ${{ strategy.job-index == 0 && github.ref == 'refs/heads/main' }} 48 | name: Create the coverage badge 49 | uses: schneegans/dynamic-badges-action@v1.7.0 50 | with: 51 | auth: ${{ secrets.CODECOVERAGE_GIST }} 52 | gistID: 9813850da17ec3e10442c3f288d09065 53 | filename: pytest-coverage__${{ github.ref_name }}.json 54 | label: coverage 55 | message: ${{ steps.coverageComment.outputs.coverage }} 56 | color: ${{ steps.coverageComment.outputs.color }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | .*cache 4 | .coverage 5 | .python-version 6 | .idea/ 7 | .tox/ 8 | build/ 9 | dist/ 10 | htmlcov 11 | venv/ 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: trailing-whitespace # trims trailing whitespace 6 | args: [--markdown-linebreak-ext=md] 7 | - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline 8 | - id: check-yaml # checks syntax of yaml files 9 | - id: check-json # checks syntax of json files 10 | - id: check-added-large-files # prevent giant files from being committed 11 | - id: fix-encoding-pragma # removes "# -*- coding: utf-8 -*-" from python files (since we only support python 3) 12 | args: [--remove] 13 | - id: check-merge-conflict # check for files that contain merge conflict strings 14 | 15 | - repo: https://github.com/adamchainz/django-upgrade 16 | rev: 1.15.0 17 | hooks: 18 | - id: django-upgrade 19 | args: [--target-version, "3.2"] 20 | 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.13.0 23 | hooks: 24 | - id: pyupgrade 25 | 26 | - repo: https://github.com/pycqa/isort 27 | rev: 5.12.0 28 | hooks: 29 | - id: isort 30 | name: isort (python) 31 | 32 | - repo: https://github.com/psf/black 33 | rev: 23.9.1 34 | hooks: 35 | - id: black 36 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Mohammad Javad Naderi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | recursive-include django_pwned/locale * 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Pwned 2 | 3 | [![pypi](https://img.shields.io/pypi/v/django-pwned.svg)](https://pypi.python.org/pypi/django-pwned/) 4 | [![tests ci](https://github.com/QueraTeam/django-pwned/workflows/Tests/badge.svg)](https://github.com/QueraTeam/django-pwned/actions) 5 | [![coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/quera-org/9813850da17ec3e10442c3f288d09065/raw/pytest-coverage__main.json)](https://github.com/QueraTeam/django-pwned/actions) 6 | [![MIT](https://img.shields.io/github/license/QueraTeam/django-pwned.svg)](https://github.com/QueraTeam/django-pwned/blob/master/LICENSE.txt) 7 | [![black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 8 | 9 | A collection of django password validators. 10 | 11 | ## Compatibility 12 | 13 | - Python: **3.8**, **3.9**, **3.10**, **3.11**, **3.12** 14 | - Django: **4.2**, **5.0** 15 | 16 | ## Installation 17 | 18 | ``` 19 | pip install django-pwned 20 | ``` 21 | 22 | For translations to work, add `django_pwned` to `INSTALLED_APPS`. 23 | 24 | ## TL;DR: 25 | 26 | ```python 27 | AUTH_PASSWORD_VALIDATORS = [ 28 | {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, 29 | {"NAME": "django_pwned.validators.GitHubLikePasswordValidator"}, 30 | {"NAME": "django_pwned.validators.MinimumUniqueCharactersPasswordValidator"}, 31 | {"NAME": "django_pwned.validators.PwnedPasswordValidator"}, 32 | ] 33 | ``` 34 | 35 | ## Validators 36 | 37 | ### PwnedPasswordValidator(request_timeout=1.5, count_threshold=1) 38 | 39 | This validator uses the [Pwned Passwords API] to check for compromised passwords. 40 | 41 | Internally, this validator checks password with django's 42 | `CommonPasswordValidator` and if password was not in django's list, 43 | uses Pwned API to check password. So you can remove `CommonPasswordValidator` 44 | if you're using this validator. 45 | 46 | ```python 47 | AUTH_PASSWORD_VALIDATORS = [ 48 | # ... 49 | # {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 50 | {"NAME": "django_pwned.validators.PwnedPasswordValidator"}, 51 | # ... 52 | ] 53 | ``` 54 | 55 | You can set the API request timeout with the `request_timeout` parameter (in seconds). 56 | 57 | You can set the `count_threshold` to reject a password if it appears at least 58 | a certain number of times in the Pwned Passwords data set. 59 | By default, this threshold is set to `1`. 60 | For instance, setting `count_threshold=2` means the password will be rejected 61 | if it appears in the data set at least twice. 62 | 63 | Example configuration: 64 | 65 | ```python 66 | AUTH_PASSWORD_VALIDATORS = [ 67 | # ... 68 | { 69 | "NAME": "django_pwned.validators.PwnedPasswordValidator", 70 | "OPTIONS": { 71 | "request_timeout": 2, 72 | "count_threshold": 5, 73 | }, 74 | }, 75 | # ... 76 | ] 77 | ``` 78 | 79 | If for any reason (connection issues, timeout, ...) the request to Pwned API fails, 80 | this validator skips checking password and logs a message. 81 | 82 | ### GitHubLikePasswordValidator(min_length=8, safe_length=15) 83 | 84 | Validates whether the password is at least: 85 | 86 | - 8 characters long, if it includes a number and a lowercase letter, or 87 | - 15 characters long with any combination of characters 88 | 89 | Based on GitHub's documentation about [creating a strong password]. 90 | 91 | You may want to disable Django's `NumericPasswordValidator` 92 | and `MinimumLengthValidator` if you want to use 93 | `GitHubLikePasswordValidator`. 94 | 95 | The minimum number of characters can be customized with the `min_length` 96 | parameter. The length at which we remove the restriction about 97 | requiring both number and lowercase letter can be customized with the 98 | `safe_length` parameter. 99 | 100 | ### MinimumUniqueCharactersPasswordValidator(min_unique_characters=4) 101 | 102 | Validates whether the password contains at least 4 unique characters. 103 | For example `aaaaaaaaaabbbbbbccc` is an invalid password, but `aAbB` is a valid password. 104 | 105 | The minimum number of unique characters can be customized with the 106 | `min_unique_characters` parameter. 107 | 108 | ## Development 109 | 110 | - Create and activate a python virtualenv. 111 | - Install development dependencies in your virtualenv: `pip install -e '.[dev]'` 112 | - Install pre-commit hooks: `pre-commit install` 113 | - Run tests with coverage: `py.test --cov` 114 | 115 | ## License 116 | 117 | MIT 118 | 119 | [pwned passwords api]: https://haveibeenpwned.com/API/v3#PwnedPasswords 120 | [creating a strong password]: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-strong-password 121 | -------------------------------------------------------------------------------- /django_pwned/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.2.2" 2 | -------------------------------------------------------------------------------- /django_pwned/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Direct access to the Pwned Passwords API for checking whether a password is compromised. 3 | """ 4 | 5 | import hashlib 6 | import sys 7 | 8 | import requests 9 | 10 | from . import __version__ 11 | 12 | API_ENDPOINT = "https://api.pwnedpasswords.com/range/{}" 13 | USER_AGENT = "django-pwned/{} (Python/{} | requests/{})".format( 14 | __version__, "{}.{}.{}".format(*sys.version_info[:3]), requests.__version__ 15 | ) 16 | 17 | 18 | class PwnedRequestError(Exception): 19 | pass 20 | 21 | 22 | def _get_pwned(prefix, request_timeout: float) -> dict[str, int]: 23 | """ 24 | Fetches a dict of all hash suffixes from Pwned Passwords for a given SHA-1 prefix. 25 | """ 26 | try: 27 | response = requests.get( 28 | url=API_ENDPOINT.format(prefix), headers={"User-Agent": USER_AGENT}, timeout=request_timeout 29 | ) 30 | response.raise_for_status() 31 | except requests.RequestException as e: 32 | raise PwnedRequestError("Error fetching data from Pwned Passwords API: %r" % e) 33 | 34 | results = {} 35 | for line in response.text.splitlines(): 36 | line_suffix, times = line.split(":", 1) 37 | results[line_suffix] = int(times.replace(",", "")) 38 | 39 | return results 40 | 41 | 42 | def get_pwned_count(password: str, request_timeout: float) -> int: 43 | """ 44 | Checks a password against the Pwned Passwords database. 45 | 46 | Returns an integer (count of how many times the password appears in the Pwned data set) 47 | Raises PwnedRequestError if it couldn't get response from API (timeout, HTTP error code). 48 | """ 49 | if not isinstance(password, str): 50 | raise TypeError("Password values to check must be Unicode strings.") 51 | password_hash = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() 52 | prefix, suffix = password_hash[:5], password_hash[5:] 53 | return _get_pwned(prefix, request_timeout).get(suffix, 0) 54 | -------------------------------------------------------------------------------- /django_pwned/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DjangoPwnedConfig(AppConfig): 5 | name = "django_pwned" 6 | verbose_name = "Django Pwned" 7 | 8 | def ready(self): 9 | pass 10 | -------------------------------------------------------------------------------- /django_pwned/exceptions.py: -------------------------------------------------------------------------------- 1 | class InvalidArgumentsError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /django_pwned/locale/fa/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QueraTeam/django-pwned/904bc9bbfd3a352e99fc31e58e824082a3233f4a/django_pwned/locale/fa/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /django_pwned/locale/fa/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: PACKAGE VERSION\n" 4 | "Report-Msgid-Bugs-To: \n" 5 | "POT-Creation-Date: 2021-02-20 15:44+0330\n" 6 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 7 | "Last-Translator: FULL NAME \n" 8 | "Language-Team: LANGUAGE \n" 9 | "Language: \n" 10 | "MIME-Version: 1.0\n" 11 | "Content-Type: text/plain; charset=UTF-8\n" 12 | "Content-Transfer-Encoding: 8bit\n" 13 | "Plural-Forms: nplurals=2; plural=(n > 1);\n" 14 | 15 | #: validators.py 16 | msgid "Password is in a list of passwords commonly used on other websites." 17 | msgstr "" 18 | "این گذرواژه در لیستی از گذرواژه‌های رایج استفاده شده در سایر وب‌سایت‌ها قرار " 19 | "دارد." 20 | 21 | #: validators.py 22 | msgid "" 23 | "Your password can’t be in the list of commonly used passwords on other " 24 | "websites." 25 | msgstr "" 26 | "گذرواژه نباید در لیست گذرواژه‌های رایج استفاده شده در سایر وب‌سایت‌ها قرار داشته " 27 | "باشد." 28 | 29 | #: validators.py 30 | msgid "" 31 | "Passwords shorter than %(safe_length)d characters must include a number " 32 | "and a lowercase letter." 33 | msgstr "" 34 | "گذرواژه‌های کوتاه‌تر از %(safe_length)d حرف باید شامل حداقل یک عدد " 35 | "و یک حرف کوچک انگلیسی باشند." 36 | 37 | #: validators.py 38 | msgid "Make sure password has at least %(min_unique_characters)d unique characters." 39 | msgstr "گذرواژه باید حداقل %(min_unique_characters)d کاراکتر متمایز داشته باشد." 40 | 41 | #: validators.py 42 | msgid "Your password should contain at least %(min_unique_characters)d unique characters." 43 | msgstr "گذرواژه شما باید حداقل %(min_unique_characters)d کاراکتر متمایز داشته باشد." 44 | -------------------------------------------------------------------------------- /django_pwned/validators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import string 3 | 4 | from django.contrib.auth.password_validation import CommonPasswordValidator, MinimumLengthValidator 5 | from django.core.exceptions import ValidationError 6 | from django.utils.deconstruct import deconstructible 7 | from django.utils.translation import gettext_lazy as _ 8 | 9 | from django_pwned.exceptions import InvalidArgumentsError 10 | 11 | from . import api 12 | 13 | log = logging.getLogger(__name__) 14 | common_password_validator = CommonPasswordValidator() 15 | 16 | 17 | @deconstructible 18 | class PwnedPasswordValidator: 19 | """ 20 | Password validator which checks Django's list of common passwords and the Pwned Passwords database. 21 | """ 22 | 23 | def __init__(self, request_timeout: float = 1.5, count_threshold: int = 1): 24 | self.request_timeout = request_timeout 25 | self.count_threshold = count_threshold 26 | 27 | def validate(self, password: str, user=None): 28 | # First, check Django's list of common passwords 29 | common_password_validator.validate(password, user) 30 | 31 | # If password is not in Django's list, check Pwned API 32 | try: 33 | count = api.get_pwned_count(password, self.request_timeout) 34 | except api.PwnedRequestError as e: 35 | # Gracefully handle timeouts and HTTP error response codes. 36 | log.warning("Skipped Pwned Passwords check due to error: %r", e) 37 | return 38 | 39 | if count >= self.count_threshold: 40 | raise ValidationError( 41 | _("Password is in a list of passwords commonly used on other websites."), 42 | code="password_pwned", 43 | ) 44 | 45 | def get_help_text(self): 46 | return _("Your password can’t be in the list of commonly used passwords on other websites.") 47 | 48 | 49 | class GitHubLikePasswordValidator: 50 | """ 51 | Implements this rule by GitHub: 52 | 53 | Make sure password is at least 15 characters OR at least 8 characters including a number and a lowercase letter. 54 | """ 55 | 56 | def __init__(self, min_length: int = 8, safe_length: int = 15): 57 | if min_length < 6: 58 | raise InvalidArgumentsError("min_length must be at least 6") 59 | if safe_length <= min_length: 60 | raise InvalidArgumentsError("safe_length must be greater than min_length") 61 | self.min_length_validator = MinimumLengthValidator(min_length=min_length) 62 | self.safe_length = safe_length 63 | 64 | def validate(self, password: str, user=None): 65 | self.min_length_validator.validate(password, user) 66 | # password is at least "min_length" characters 67 | if len(password) >= self.safe_length: 68 | # password is at least "safe_length" characters 69 | return 70 | if len(set(password) & set(string.ascii_lowercase)) > 0 and len(set(password) & set(string.digits)) > 0: 71 | # password includes a number and a lowercase letter 72 | return 73 | raise ValidationError( 74 | _("Passwords shorter than %(safe_length)d characters must include a number and a lowercase letter.") 75 | % {"safe_length": self.safe_length}, 76 | code="password_github_like_validator", 77 | ) 78 | 79 | def get_help_text(self): 80 | return _("Passwords shorter than %(safe_length)d characters must include a number and a lowercase letter.") % { 81 | "safe_length": self.safe_length 82 | } 83 | 84 | 85 | class MinimumUniqueCharactersPasswordValidator: 86 | """ 87 | Make sure password contains enough unique characters. 88 | """ 89 | 90 | def __init__(self, min_unique_characters: int = 4): 91 | if min_unique_characters < 2: 92 | raise InvalidArgumentsError("min_unique_characters must be at least 2") 93 | self.min_unique_characters = min_unique_characters 94 | 95 | def validate(self, password: str, user=None): 96 | if len(set(password)) >= self.min_unique_characters: 97 | # password has at least "min_unique_characters" unique characters 98 | return 99 | raise ValidationError( 100 | _("Make sure password has at least %(min_unique_characters)d unique characters.") 101 | % {"min_unique_characters": self.min_unique_characters}, 102 | code="password_min_unique_characters_validator", 103 | ) 104 | 105 | def get_help_text(self): 106 | return _("Your password should contain at least %(min_unique_characters)d unique characters.") % { 107 | "min_unique_characters": self.min_unique_characters 108 | } 109 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | DJANGO_SETTINGS_MODULE = "tests.settings" 3 | norecursedirs = ".git" 4 | django_find_project = false 5 | pythonpath = ["."] 6 | 7 | [tool.black] 8 | line-length = 120 9 | include = '\.pyi?$' 10 | exclude = '/\..+/' 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | from django_pwned import __version__ 6 | 7 | with open(os.path.join(os.path.dirname(__file__), "README.md"), encoding="UTF-8") as readme: 8 | README = readme.read() 9 | 10 | # allow setup.py to be run from any path 11 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 12 | 13 | dev_requirements = ["pre-commit", "pytest", "pytest-cov", "pytest-django", "responses"] 14 | 15 | setup( 16 | name="django-pwned", 17 | version=__version__, 18 | description="A Django password validator using the Pwned Passwords API to check for compromised passwords.", 19 | long_description=README, 20 | long_description_content_type="text/markdown", 21 | author="Mohammad Javad Naderi", 22 | url="https://github.com/QueraTeam/django-pwned", 23 | download_url="https://pypi.org/project/django-pwned/", 24 | packages=find_packages(".", include=("django_pwned", "django_pwned.*")), 25 | include_package_data=True, 26 | install_requires=["Django>=3.2", "requests"], 27 | extras_require={"dev": dev_requirements}, 28 | classifiers=[ 29 | "Development Status :: 5 - Production/Stable", 30 | "Environment :: Web Environment", 31 | "Framework :: Django", 32 | "Framework :: Django :: 4.2", 33 | "Framework :: Django :: 5.0", 34 | "Intended Audience :: Developers", 35 | "Operating System :: OS Independent", 36 | "Programming Language :: Python", 37 | "Programming Language :: Python :: 3.8", 38 | "Programming Language :: Python :: 3.9", 39 | "Programming Language :: Python :: 3.10", 40 | "Programming Language :: Python :: 3.11", 41 | "Programming Language :: Python :: 3.12", 42 | "Topic :: Internet :: WWW/HTTP :: Session", 43 | "Topic :: Security", 44 | ], 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QueraTeam/django-pwned/904bc9bbfd3a352e99fc31e58e824082a3233f4a/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 4 | SECRET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" 5 | 6 | DEBUG = True 7 | USE_TZ = False 8 | 9 | INSTALLED_APPS = [ 10 | "django.contrib.admin", 11 | "django.contrib.auth", 12 | "django.contrib.contenttypes", 13 | "django.contrib.sessions", 14 | "django.contrib.messages", 15 | ] 16 | 17 | MIDDLEWARE = [ 18 | "django.contrib.sessions.middleware.SessionMiddleware", 19 | "django.middleware.common.CommonMiddleware", 20 | "django.middleware.csrf.CsrfViewMiddleware", 21 | "django.contrib.auth.middleware.AuthenticationMiddleware", 22 | "django.contrib.messages.middleware.MessageMiddleware", 23 | ] 24 | 25 | # ROOT_URLCONF = "tests.urls" 26 | 27 | TEMPLATES = [ 28 | { 29 | "BACKEND": "django.template.backends.django.DjangoTemplates", 30 | "DIRS": [], 31 | "APP_DIRS": True, 32 | "OPTIONS": { 33 | "context_processors": [ 34 | "django.template.context_processors.debug", 35 | "django.template.context_processors.request", 36 | "django.contrib.auth.context_processors.auth", 37 | "django.contrib.messages.context_processors.messages", 38 | ], 39 | }, 40 | }, 41 | ] 42 | 43 | DATABASES = { 44 | "default": { 45 | "ENGINE": "django.db.backends.sqlite3", 46 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 47 | } 48 | } 49 | 50 | STATIC_URL = "/static/" 51 | -------------------------------------------------------------------------------- /tests/test_github.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | 4 | from django_pwned.exceptions import InvalidArgumentsError 5 | from django_pwned.validators import GitHubLikePasswordValidator 6 | 7 | 8 | def test_empty_string(): 9 | validator = GitHubLikePasswordValidator() 10 | with pytest.raises(ValidationError): 11 | validator.validate("") 12 | 13 | validator = GitHubLikePasswordValidator(min_length=6, safe_length=7) 14 | with pytest.raises(ValidationError): 15 | validator.validate("") 16 | 17 | 18 | def test_default_arguments(): 19 | validator = GitHubLikePasswordValidator() 20 | 21 | with pytest.raises(ValidationError): 22 | validator.validate("aaaa777") 23 | validator.validate("aaaa7777") 24 | with pytest.raises(ValidationError): 25 | validator.validate("a" * 8) 26 | with pytest.raises(ValidationError): 27 | validator.validate("a" * 14) 28 | validator.validate("a" * 15) 29 | validator.validate("a" * 16) 30 | 31 | 32 | def test_invalid_arguments(): 33 | with pytest.raises(InvalidArgumentsError): 34 | GitHubLikePasswordValidator(min_length=5, safe_length=15) 35 | with pytest.raises(InvalidArgumentsError): 36 | GitHubLikePasswordValidator(min_length=8, safe_length=7) 37 | with pytest.raises(InvalidArgumentsError): 38 | GitHubLikePasswordValidator(min_length=8, safe_length=8) 39 | GitHubLikePasswordValidator(min_length=8, safe_length=9) 40 | GitHubLikePasswordValidator(min_length=8, safe_length=10) 41 | 42 | 43 | def test_valid_arguments_1(): 44 | validator = GitHubLikePasswordValidator(min_length=6, safe_length=12) 45 | 46 | with pytest.raises(ValidationError): 47 | validator.validate("aaa77") 48 | validator.validate("aaa777") 49 | with pytest.raises(ValidationError): 50 | validator.validate("a" * 6) 51 | with pytest.raises(ValidationError): 52 | validator.validate("a" * 11) 53 | validator.validate("a" * 12) 54 | validator.validate("a" * 13) 55 | 56 | 57 | def test_valid_arguments_2(): 58 | validator = GitHubLikePasswordValidator(min_length=7, safe_length=8) 59 | 60 | with pytest.raises(ValidationError): 61 | validator.validate("aaa777") 62 | validator.validate("aaaa777") 63 | with pytest.raises(ValidationError): 64 | validator.validate("a" * 7) 65 | validator.validate("a" * 8) 66 | validator.validate("a" * 9) 67 | -------------------------------------------------------------------------------- /tests/test_min_unique_chars.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | 4 | from django_pwned.exceptions import InvalidArgumentsError 5 | from django_pwned.validators import MinimumUniqueCharactersPasswordValidator 6 | 7 | 8 | def test_default_arguments(): 9 | validator = MinimumUniqueCharactersPasswordValidator() 10 | with pytest.raises(ValidationError): 11 | validator.validate("") 12 | with pytest.raises(ValidationError): 13 | validator.validate("a12") 14 | with pytest.raises(ValidationError): 15 | validator.validate("a12a12a12a12a12a12a12a12a12a12a12a12a12a12a12a12a12a12a12a12a12") 16 | validator.validate("1a2b") 17 | validator.validate("aAbB") 18 | 19 | 20 | def test_invalid_arguments(): 21 | with pytest.raises(InvalidArgumentsError): 22 | MinimumUniqueCharactersPasswordValidator(min_unique_characters=1) 23 | MinimumUniqueCharactersPasswordValidator(min_unique_characters=2) 24 | 25 | 26 | def test_valid_arguments(): 27 | validator = MinimumUniqueCharactersPasswordValidator(min_unique_characters=8) 28 | with pytest.raises(ValidationError): 29 | validator.validate("") 30 | with pytest.raises(ValidationError): 31 | validator.validate("1a2b3c4") 32 | with pytest.raises(ValidationError): 33 | validator.validate("1a2b3c41a2b3c41a2b3c41a2b3c41a2b3c41a2b3c41a2b3c41a2b3c4") 34 | validator.validate("1a2b3c4d") 35 | validator.validate("aAbBcCdD") 36 | -------------------------------------------------------------------------------- /tests/test_pwned.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | import responses 5 | from django.core.exceptions import ValidationError 6 | 7 | from django_pwned.api import API_ENDPOINT 8 | from django_pwned.validators import PwnedPasswordValidator 9 | 10 | 11 | @responses.activate 12 | def test_django_common_passwords(): 13 | responses.add(responses.GET, url=re.compile(r".*")) 14 | validator = PwnedPasswordValidator() 15 | with pytest.raises(ValidationError): 16 | validator.validate("123456") 17 | with pytest.raises(ValidationError): 18 | validator.validate("123456789") 19 | with pytest.raises(ValidationError): 20 | validator.validate("qwerty") 21 | with pytest.raises(ValidationError): 22 | validator.validate("password") 23 | with pytest.raises(ValidationError): 24 | validator.validate("qweasdzxc") 25 | with pytest.raises(ValidationError): 26 | validator.validate("startfinding") 27 | with pytest.raises(ValidationError): 28 | validator.validate("heyhey1") 29 | # it should not have called requests.get at all 30 | assert len(responses.calls) == 0 31 | 32 | 33 | @responses.activate 34 | def test_pwned_api__leaked_password(): 35 | leaked_password = r"haveibeenpwned" 36 | leaked_password_hash = "667245565F95194F23408B0EA21A0D02C4EEA81D" 37 | responses.add( 38 | responses.GET, 39 | API_ENDPOINT.format(leaked_password_hash[:5]), 40 | body="\n".join( 41 | [ 42 | "0BD86C0E684498894064E4AB86B9420CA0E:763", 43 | "2019C3022C39C4E5FD5A92ECD102E87476D:3", 44 | "3C56EAB73498B6A58EF5692C4E4937B4466:81", 45 | "5565F95194F23408B0EA21A0D02C4EEA81D:1", 46 | "6E9AC9194DA65040917139BA238B3900354:2,103", 47 | "BB48AB53E4E454BC487CA6400380C05D41A:5", 48 | "D55A6ED26C1DE9350D40771822316CC4B29:3", 49 | ] 50 | ), 51 | ) 52 | with pytest.raises(ValidationError): 53 | PwnedPasswordValidator().validate(leaked_password) 54 | assert len(responses.calls) == 1 55 | 56 | 57 | @responses.activate 58 | def test_pwned_api__strong_password(): 59 | strong_password = r"SGz=L.%U\;Os$,k]%U2m" 60 | strong_password_hash = "761E05BF4161AF6CE0DDA796C063B3B5F0F93A4D" 61 | responses.add( 62 | responses.GET, 63 | API_ENDPOINT.format(strong_password_hash[:5]), 64 | body="\n".join( 65 | [ 66 | "1C8ED662FF477F5EFB2F43BB5A772877FEF:1", 67 | "43DF3CB99A3C26781395C72B543E3D9B77A:1", 68 | "54B2CEEE3E59AACD9CED9BA6A3DAC33CA62:12", 69 | "6F97235868B9AB0F74702AF7BB5151DB8BE:8", 70 | "A61E8BD078A4246979AAE41721B795358D5:80", 71 | "C47A6ED73AD097E959D9417B5E66E22E8F2:5", 72 | "E82B1DF7CB04693452281037CEAFB29E037:2", 73 | "FFF5135901E0131D96F2D5B211ACEA61DAE:1", 74 | ] 75 | ), 76 | ) 77 | PwnedPasswordValidator().validate(strong_password) 78 | assert len(responses.calls) == 1 79 | 80 | 81 | @responses.activate 82 | def test_pwned_api__count_threshold(): 83 | leaked_password = r"pass-word" 84 | leaked_password_hash = "43BEF3EAB34187D71D7E1D9CC307C5E7C07665A8" 85 | responses.add( 86 | responses.GET, 87 | API_ENDPOINT.format(leaked_password_hash[:5]), 88 | body="\n".join( 89 | [ 90 | "0BD86C0E684498894064E4AB86B9420CA0E:763", 91 | "3EAB34187D71D7E1D9CC307C5E7C07665A8:3", 92 | "3C56EAB73498B6A58EF5692C4E4937B4466:81", 93 | "5565F95194F23408B0EA21A0D02C4EEA81D:1", 94 | "6E9AC9194DA65040917139BA238B3900354:2,103", 95 | "BB48AB53E4E454BC487CA6400380C05D41A:5", 96 | "D55A6ED26C1DE9350D40771822316CC4B29:3", 97 | ] 98 | ), 99 | ) 100 | with pytest.raises(ValidationError): 101 | PwnedPasswordValidator().validate(leaked_password) 102 | with pytest.raises(ValidationError): 103 | PwnedPasswordValidator(count_threshold=2).validate(leaked_password) 104 | with pytest.raises(ValidationError): 105 | PwnedPasswordValidator(count_threshold=3).validate(leaked_password) 106 | PwnedPasswordValidator(count_threshold=4).validate(leaked_password) 107 | PwnedPasswordValidator(count_threshold=5).validate(leaked_password) 108 | assert len(responses.calls) == 5 109 | 110 | 111 | @responses.activate 112 | def test_pwned_api__connection_error(): 113 | strong_password = r"SGz=L.%U\;Os$,k]%U2m" 114 | PwnedPasswordValidator().validate(strong_password) 115 | assert len(responses.calls) == 1 116 | 117 | 118 | @responses.activate 119 | def test_pwned_api__invalid_input(): 120 | binary_password = b"\x23\x25" 121 | with pytest.raises(TypeError): 122 | PwnedPasswordValidator().validate(binary_password) # noqa 123 | assert len(responses.calls) == 0 124 | --------------------------------------------------------------------------------