├── django_advanced_password_validation ├── tests │ ├── __init__.py │ ├── settings.py │ └── test_validators.py ├── __init__.py └── advanced_password_validation.py ├── .gitignore ├── pytest.ini ├── mypy.ini ├── requirements_test.txt ├── .flake8 ├── LICENSE.txt ├── pyproject.toml ├── HISTORY.md ├── .github └── workflows │ ├── test.yaml │ └── deploy_to_pypi.yaml └── README.md /django_advanced_password_validation/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | *.egg-info/ 4 | .vs/ 5 | __pycache__ 6 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = django_advanced_password_validation.tests.settings 3 | -------------------------------------------------------------------------------- /django_advanced_password_validation/__init__.py: -------------------------------------------------------------------------------- 1 | from . import advanced_password_validation 2 | 3 | __all__ = ["advanced_password_validation"] 4 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = 3 | mypy_django_plugin.main 4 | 5 | [mypy.plugins.django-stubs] 6 | django_settings_module = "django_advanced_password_validation.tests.settings" 7 | -------------------------------------------------------------------------------- /requirements_test.txt: -------------------------------------------------------------------------------- 1 | pytest==7.4.3 2 | pytest-django==4.7.0 3 | mypy==1.7.0 4 | django-stubs==4.2.6 5 | setuptools==68.2.2 6 | types-setuptools==68.2.0.1 7 | interrogate==1.5.0 8 | coverage==7.3.2 9 | flake8==6.1.0 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | per-file-ignores = 4 | django_advanced_password_validation/tests/settings.py: E501 5 | exclude = 6 | dist, 7 | build, 8 | env, 9 | venv, 10 | .env, 11 | .venv, 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020 Ezra Jacob Rice 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=65", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django_advanced_password_validation" 7 | description = "Extends Django password validation options in an attempt to keep up with industry standards for strong user passwords." 8 | readme = "README.md" 9 | license = {file = "LICENSE.txt"} 10 | keywords = ["django", "password", "validator"] 11 | authors = [ 12 | {name = "Ezra Rice", email = "ezra.j.rice@gmail.com"}, 13 | ] 14 | classifiers = [ 15 | "Environment :: Web Environment", 16 | "Framework :: Django", 17 | "Intended Audience :: Developers", 18 | "License :: OSI Approved :: MIT License", 19 | "Operating System :: OS Independent", 20 | "Programming Language :: Python", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.12", 23 | "Topic :: Internet :: WWW/HTTP", 24 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 25 | ] 26 | version = "1.2.0" 27 | 28 | [project.urls] 29 | repository = "https://github.com/ezrajrice/django_advanced_password_validation" -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # VERSION HISTORY 2 | 3 | ### VERSION 1.2.0 - 2023-11-17 4 | 5 | **Added** 6 | 7 | - N/A 8 | 9 | **Removed** 10 | 11 | - Django 2.2 no longer supported as it has been deprecated by the Django Project 12 | 13 | **Edited** 14 | 15 | - N/A 16 | 17 | **Bug Fix** 18 | 19 | - Fixed improper use of alias "_" for plural messages with ngettext 20 | - Changed gettext usage to lazy translations 21 | 22 | ### VERSION 1.1.0 - 2022-05-23 23 | 24 | **Added** 25 | 26 | - MaximumLengthValidator 27 | - MaxConsecutiveCharactersValidator 28 | - ConsecutivelyIncreasingDigitValidator 29 | - ConsecutivelyDecreasingDigitValidator 30 | 31 | **Removed** 32 | 33 | - N/A 34 | 35 | **Edited** 36 | 37 | - Updated the Options list to show inputs and default values for the new methods. 38 | 39 | **Bug Fix** 40 | 41 | - package has been renamed from "django-advanced_password_validation" to "django_advanced_password_validation" to fix the *django.core.exceptions.improperlyconfigured: the app label 'django-advanced_password_validation' is not a valid python identifier* error 42 | 43 | ### VERSION 1.0.4 - 2020-03-25 44 | 45 | **Added** 46 | 47 | - N/A 48 | 49 | **Removed** 50 | 51 | - Unused import gettext has been removed. 52 | 53 | **Edited** 54 | 55 | - *ContainsNumeralsValidator* has been modified to *ContainsDigitsValidator* to be a more intuitive naming convention (i.e. 15 is one numeral, but two digits) 56 | - Option *min_numerals* has been changed to *min_digits* 57 | 58 | **Bug Fix** 59 | 60 | - ContainsSpecialValidator was only checking for one (1) special character instead of the minimum parameter. 61 | 62 | ### VERSION 1.0.3 - 2020-03-20 63 | 64 | **Added** 65 | 66 | - ContainsNumeralsValidator 67 | - ContainsUppercaseValidator 68 | - ContainsLowercaseValidator 69 | - ContainsSpecialCharactersValidator -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | - "release" 8 | pull_request: 9 | branches: 10 | - "*" 11 | 12 | env: 13 | GITHUB-TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | 15 | jobs: 16 | black-lint: 17 | name: Lint with Black 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: psf/black@stable 22 | with: 23 | version: "~=23.0" 24 | 25 | test: 26 | name: Test django-advanced-password-validation 27 | runs-on: ubuntu-latest 28 | strategy: 29 | matrix: 30 | versions: 31 | - { "djangoVersion": "3.2.16", "pythonVersion": "3.9" } 32 | - { "djangoVersion": "3.2.16", "pythonVersion": "3.10" } 33 | - { "djangoVersion": "3.2.16", "pythonVersion": "3.11" } 34 | - { "djangoVersion": "3.2.16", "pythonVersion": "3.12" } 35 | - { "djangoVersion": "4.0.8", "pythonVersion": "3.9" } 36 | - { "djangoVersion": "4.0.8", "pythonVersion": "3.10" } 37 | - { "djangoVersion": "4.0.8", "pythonVersion": "3.11" } 38 | - { "djangoVersion": "4.0.8", "pythonVersion": "3.12" } 39 | - { "djangoVersion": "4.1.2", "pythonVersion": "3.9" } 40 | - { "djangoVersion": "4.1.2", "pythonVersion": "3.10" } 41 | - { "djangoVersion": "4.1.2", "pythonVersion": "3.11" } 42 | - { "djangoVersion": "4.1.2", "pythonVersion": "3.12" } 43 | - { "djangoVersion": "4.2.7", "pythonVersion": "3.9" } 44 | - { "djangoVersion": "4.2.7", "pythonVersion": "3.10" } 45 | - { "djangoVersion": "4.2.7", "pythonVersion": "3.11" } 46 | - { "djangoVersion": "4.2.7", "pythonVersion": "3.12" } 47 | steps: 48 | # Checkout the source 49 | - name: Checkout 50 | uses: actions/checkout@v4 51 | # Setup Python 52 | - name: Set up Python 53 | uses: actions/setup-python@v4 54 | with: 55 | python-version: ${{ matrix.versions.pythonVersion }} 56 | # Install Dependencies 57 | - name: Install dependencies 58 | run: python -m pip install --upgrade pip && python -m pip install -r requirements_test.txt && python -m pip install -e . 59 | # Install Django 60 | - name: Install Django ${{ matrix.versions.djangoVersion }} 61 | run: python -m pip install Django==${{ matrix.versions.djangoVersion }} 62 | # Check syntax 63 | - name: Check types, syntax and duckstrings 64 | run: | 65 | mypy --exclude=setup.py . 66 | flake8 . 67 | interrogate --quiet --fail-under=90 . 68 | # Test package 69 | - name: Test Django ${{ matrix.versions.djangoVersion }} with coverage 70 | run: coverage run --source=django_advanced_password_validation -m pytest . && coverage lcov -o coverage.lcov 71 | # Generate Coverage Report 72 | - name: Submit coverage report to Coveralls 73 | if: ${{ success() }} 74 | uses: coverallsapp/github-action@v2 75 | with: 76 | github-token: ${{ secrets.GITHUB_TOKEN }} 77 | path-to-lcov: ./coverage.lcov 78 | -------------------------------------------------------------------------------- /django_advanced_password_validation/tests/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for tests. 3 | """ 4 | 5 | import os 6 | from typing import List 7 | 8 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 9 | SECRET_KEY = "SECRET" 10 | DEBUG = True 11 | ALLOWED_HOSTS: List[str] = [] 12 | INSTALLED_APPS = [ 13 | "django.contrib.admin", 14 | "django.contrib.auth", 15 | "django.contrib.contenttypes", 16 | "django.contrib.sessions", 17 | "django.contrib.messages", 18 | "django.contrib.staticfiles", 19 | "django_advanced_password_validation", 20 | ] 21 | MIDDLEWARE = [ 22 | "django.middleware.security.SecurityMiddleware", 23 | "django.contrib.sessions.middleware.SessionMiddleware", 24 | "django.middleware.common.CommonMiddleware", 25 | "django.middleware.csrf.CsrfViewMiddleware", 26 | "django.contrib.auth.middleware.AuthenticationMiddleware", 27 | "django.contrib.messages.middleware.MessageMiddleware", 28 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 29 | ] 30 | ROOT_URLCONF = "urls" 31 | TEMPLATES = [ 32 | { 33 | "BACKEND": "django.template.backends.django.DjangoTemplates", 34 | "DIRS": [BASE_DIR + "/templates"], 35 | "APP_DIRS": True, 36 | "OPTIONS": { 37 | "context_processors": [ 38 | "django.template.context_processors.debug", 39 | "django.template.context_processors.request", 40 | "django.contrib.auth.context_processors.auth", 41 | "django.contrib.messages.context_processors.messages", 42 | ], 43 | }, 44 | }, 45 | ] 46 | WSGI_APPLICATION = "django_advanced_password_validation.wsgi.application" 47 | DATABASES = { 48 | "default": { 49 | "ENGINE": "django.db.backends.sqlite3", 50 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 51 | } 52 | } 53 | AUTH_PASSWORD_VALIDATORS = [ 54 | { 55 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 56 | }, 57 | { 58 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 59 | "OPTIONS": { 60 | "min_length": 10, 61 | }, 62 | }, 63 | { 64 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 65 | }, 66 | { 67 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 68 | }, 69 | { 70 | "NAME": "django_advanced_password_validation.advanced_password_validation.ContainsDigitsValidator", 71 | "OPTIONS": {"min_digits": 1}, 72 | }, 73 | { 74 | "NAME": "django_advanced_password_validation.advanced_password_validation.ContainsUppercaseValidator", 75 | "OPTIONS": {"min_uppercase": 1}, 76 | }, 77 | { 78 | "NAME": "django_advanced_password_validation.advanced_password_validation.ContainsLowercaseValidator", 79 | "OPTIONS": {"min_lowercase": 1}, 80 | }, 81 | { 82 | "NAME": "django_advanced_password_validation.advanced_password_validation.ContainsSpecialCharactersValidator", 83 | "OPTIONS": {"min_characters": 1}, 84 | }, 85 | { 86 | "NAME": "django_advanced_password_validation.advanced_password_validation.MaximumLengthValidator", 87 | "OPTIONS": {"max_length": 128}, 88 | }, 89 | ] 90 | 91 | LANGUAGE_CODE = "en-us" 92 | TIME_ZONE = "UTC" 93 | USE_I18N = True 94 | USE_L10N = True 95 | USE_TZ = True 96 | STATIC_URL = "/static/" 97 | STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") 98 | -------------------------------------------------------------------------------- /.github/workflows/deploy_to_pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.x" 16 | 17 | - name: Install pypa/build 18 | run: >- 19 | python3 -m 20 | pip install 21 | build 22 | --user 23 | - name: Build a binary wheel and a source tarball 24 | run: python3 -m build 25 | - name: Store the distribution packages 26 | uses: actions/upload-artifact@v3 27 | with: 28 | name: python-package-distributions 29 | path: dist/ 30 | 31 | publish-to-pypi: 32 | name: >- 33 | Publish Python 🐍 distribution 📦 to PyPI 34 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 35 | needs: 36 | - build 37 | runs-on: ubuntu-latest 38 | environment: 39 | name: pypi 40 | url: https://pypi.org/p/django-advanced-password-validation 41 | permissions: 42 | id-token: write 43 | 44 | steps: 45 | - name: Download all the dists 46 | uses: actions/download-artifact@v3 47 | with: 48 | name: python-package-distributions 49 | path: dist/ 50 | - name: Publish distribution to PyPI 51 | uses: pypa/gh-action-pypi-publish@release/v1 52 | 53 | github-release: 54 | name: >- 55 | Sign the Python 🐍 distribution 📦 with Sigstore 56 | and upload them to GitHub Release 57 | needs: 58 | - publish-to-pypi 59 | runs-on: ubuntu-latest 60 | 61 | permissions: 62 | contents: write # IMPORTANT: mandatory for making GitHub Releases 63 | id-token: write # IMPORTANT: mandatory for sigstore 64 | 65 | steps: 66 | - name: Download all the dists 67 | uses: actions/download-artifact@v3 68 | with: 69 | name: python-package-distributions 70 | path: dist/ 71 | - name: Sign the dists with Sigstore 72 | uses: sigstore/gh-action-sigstore-python@v1.2.3 73 | with: 74 | inputs: >- 75 | ./dist/*.tar.gz 76 | ./dist/*.whl 77 | - name: Create GitHub Release 78 | env: 79 | GITHUB_TOKEN: ${{ github.token }} 80 | run: >- 81 | gh release create 82 | '${{ github.ref_name }}' 83 | --repo '${{ github.repository }}' 84 | --notes "" 85 | - name: Upload artifact signatures to GitHub Release 86 | env: 87 | GITHUB_TOKEN: ${{ github.token }} 88 | # Upload to GitHub Release using the `gh` CLI. 89 | # `dist/` contains the built packages, and the 90 | # sigstore-produced signatures and certificates. 91 | run: >- 92 | gh release upload 93 | '${{ github.ref_name }}' dist/** 94 | --repo '${{ github.repository }}' 95 | 96 | publish-to-testpypi: 97 | name: Publish Python 🐍 distribution 📦 to TestPyPI 98 | if: startsWith(github.ref, 'refs/tags/') 99 | needs: 100 | - build 101 | runs-on: ubuntu-latest 102 | 103 | environment: 104 | name: testpypi 105 | url: https://test.pypi.org/p/django-advanced-password-validation 106 | 107 | permissions: 108 | id-token: write # IMPORTANT: mandatory for trusted publishing 109 | 110 | steps: 111 | - name: Download all the dists 112 | uses: actions/download-artifact@v3 113 | with: 114 | name: python-package-distributions 115 | path: dist/ 116 | - name: Publish distribution 📦 to TestPyPI 117 | uses: pypa/gh-action-pypi-publish@release/v1 118 | with: 119 | repository-url: https://test.pypi.org/legacy/ 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django_advanced_password_validation 2 | 3 | [![test](https://github.com/ezrajrice/django_advanced_password_validation/actions/workflows/test.yaml/badge.svg?branch=main)](https://github.com/ezrajrice/django_advanced_password_validation/actions/workflows/test.yaml) 4 | [![Coverage Status](https://coveralls.io/repos/github/ezrajrice/django_advanced_password_validation/badge.svg?branch=main)](https://coveralls.io/github/ezrajrice/django_advanced_password_validation?branch=main) 5 | 6 | Extends Django password validation options to include minimum uppercase, minimum lowercase, minimum numerical, and minimum special characters. This was created in an attempt to keep up with industry standards for strong user passwords. 7 | 8 | This package has been tested with python 3.9+. 9 | 10 | ## Prerequisites 11 | 12 | Requires Django 3.2 or later. 13 | You can install the latest version of Django via pip: 14 | 15 | ```bash 16 | pip install django 17 | ``` 18 | 19 | Alternatively, you can install a specific version of Django via pip: 20 | 21 | ```bash 22 | pip install django=3.2 23 | ``` 24 | 25 | > **_NOTE:_** See the [django-project](https://docs.djangoproject.com) documentation for information on non-deprecated Django versions. 26 | 27 | ## Installation 28 | 29 | ### Normal installation 30 | 31 | Install django_advanced_password_validation via pip: 32 | 33 | ```bash 34 | pip install django_advanced_password_validation 35 | ``` 36 | 37 | ### Development installation 38 | 39 | ```bash 40 | git clone https://github.com/ezrajrice/django_advanced_password_validation.git 41 | cd django_advanced_password_validation 42 | pip install --editable . 43 | ``` 44 | 45 | ### Usage 46 | 47 | The optional validators must be configured in the settings.py file of your django project to be actively used in your project. 48 | 49 | #### /my-cool-project/settings.py 50 | 51 | ```python 52 | INSTALLED_APPS = [ 53 | ... 54 | 'django_advanced_password_validation', 55 | ... 56 | ] 57 | 58 | AUTH_PASSWORD_VALIDATORS = [ 59 | ... 60 | { 61 | 'NAME': 'django_advanced_password_validation.advanced_password_validation.ContainsDigitsValidator', 62 | 'OPTIONS': { 63 | 'min_digits': 1 64 | } 65 | }, 66 | { 67 | 'NAME': 'django_advanced_password_validation.advanced_password_validation.ContainsUppercaseValidator', 68 | 'OPTIONS': { 69 | 'min_uppercase': 1 70 | } 71 | }, 72 | { 73 | 'NAME': 'django_advanced_password_validation.advanced_password_validation.ContainsLowercaseValidator', 74 | 'OPTIONS': { 75 | 'min_lowercase': 1 76 | } 77 | }, 78 | { 79 | 'NAME': 'django_advanced_password_validation.advanced_password_validation.ContainsSpecialCharactersValidator', 80 | 'OPTIONS': { 81 | 'min_characters': 1 82 | } 83 | }, 84 | ... 85 | ] 86 | ``` 87 | 88 | ### Options 89 | 90 | Here is a list of the available options with their default values. 91 | 92 | | Validator | Option | Default | 93 | | --- |:---:| ---:| 94 | | ContainsDigitsValidator | min_digits | 1 | 95 | | ContainsUppercaseValidator | min_uppercase | 1 | 96 | | ContainsLowercaseValidator | min_lowercase | 1 | 97 | | ContainsSpecialCharactersValidator | min_characters | 1 | 98 | | MaximumLengthValidator | max_length | 128 | 99 | | MaxConsecutiveCharactersValidator | max_consecutive | 3 | 100 | | ConsecutivelyIncreasingDigitValidator | max_consecutive | 3 | 101 | | ConsecutivelyDecreasingDigitValidator | max_consecutive | 3 | 102 | 103 | ## Authors 104 | 105 | * **Ezra Rice** - _Initial work_ - [ezrajrice](https://github.com/ezrajrice) 106 | 107 | ## License 108 | 109 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 110 | 111 | ## Acknowledgments 112 | 113 | * **Victor Semionov** - _Contributor_ - [vsemionov](https://github.com/vsemionov) 114 | * **Mostafa Moradian** - _Contributor_ - [mostafa](https://github.com/mostafa) 115 | -------------------------------------------------------------------------------- /django_advanced_password_validation/tests/test_validators.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the advanced_password_validation module. 3 | """ 4 | 5 | import pytest 6 | from django.contrib.auth.password_validation import validate_password 7 | from django.core.exceptions import ValidationError 8 | 9 | from ..advanced_password_validation import ( 10 | ConsecutivelyDecreasingDigitValidator, 11 | ConsecutivelyIncreasingDigitValidator, 12 | ContainsDigitsValidator, 13 | ContainsLowercaseValidator, 14 | ContainsSpecialCharactersValidator, 15 | ContainsUppercaseValidator, 16 | MaxConsecutiveCharactersValidator, 17 | MaximumLengthValidator, 18 | ) 19 | 20 | 21 | def test_contains_digits_validator(): 22 | """ 23 | Test that the ContainsDigitsValidator works as expected and raises a 24 | ValidationError when the password does not contain any digits. 25 | """ 26 | validator = ContainsDigitsValidator() 27 | # None means that the password is valid. Otherwise it returns a ValidationError. 28 | assert validator.validate("1234567890") is None 29 | with pytest.raises(ValidationError) as exc: 30 | validator.validate("abcdefghij") 31 | assert exc.value.code == "password_too_weak" 32 | assert exc.value.message == "Password must contain at least 1 number." 33 | 34 | 35 | def test_contains_digits_get_help_text(): 36 | """ 37 | Test that the get_help_text string works as expected. 38 | """ 39 | validator = ContainsDigitsValidator(min_digits=1) 40 | assert validator.get_help_text() == "Your password must contain at least 1 number." 41 | validator = ContainsDigitsValidator(min_digits=2) 42 | assert validator.get_help_text() == "Your password must contain at least 2 numbers." 43 | 44 | 45 | def test_contains_uppercase_validator(): 46 | """ 47 | Test that the ContainsUppercaseValidator works as expected and raises a 48 | ValidationError when the password does not contain any uppercase characters. 49 | """ 50 | validator = ContainsUppercaseValidator() 51 | assert validator.validate("ABCDEFGHIJ") is None 52 | with pytest.raises(ValidationError) as exc: 53 | validator.validate("abcdefghij") 54 | assert exc.value.code == "password_too_weak" 55 | assert exc.value.message == "Password must contain at least 1 uppercase character." 56 | 57 | 58 | def test_contains_uppercase_get_help_text(): 59 | """ 60 | Test that the get_help_text string works as expected. 61 | """ 62 | validator = ContainsUppercaseValidator(min_uppercase=1) 63 | assert ( 64 | validator.get_help_text() 65 | == "Your password must contain at least 1 uppercase character." 66 | ) 67 | validator = ContainsUppercaseValidator(min_uppercase=2) 68 | assert ( 69 | validator.get_help_text() 70 | == "Your password must contain at least 2 uppercase characters." 71 | ) 72 | 73 | 74 | def test_contains_lowercase_validator(): 75 | """ 76 | Test that the ContainsLowercaseValidator works as expected and raises a 77 | ValidationError when the password does not contain any lowercase characters. 78 | """ 79 | validator = ContainsLowercaseValidator() 80 | assert validator.validate("abcdefghij") is None 81 | with pytest.raises(ValidationError) as exc: 82 | validator.validate("ABCDEFGHIJ") 83 | assert exc.value.code == "password_too_weak" 84 | assert exc.value.message == "Password must contain at least 1 lowercase character." 85 | 86 | 87 | def test_contains_lowercase_get_help_text(): 88 | """ 89 | Test that the get_help_text string works as expected. 90 | """ 91 | validator = ContainsLowercaseValidator(min_lowercase=1) 92 | assert ( 93 | validator.get_help_text() 94 | == "Your password must contain at least 1 lowercase character." 95 | ) 96 | validator = ContainsLowercaseValidator(min_lowercase=2) 97 | assert ( 98 | validator.get_help_text() 99 | == "Your password must contain at least 2 lowercase characters." 100 | ) 101 | 102 | 103 | def test_contains_special_characters_validator(): 104 | """ 105 | Test that the ContainsSpecialCharactersValidator works as expected and raises a 106 | ValidationError when the password does not contain any special characters. 107 | """ 108 | validator = ContainsSpecialCharactersValidator() 109 | assert validator.validate("!@#$%^&*()") is None 110 | with pytest.raises(ValidationError) as exc: 111 | validator.validate("abcdefghij") 112 | assert exc.value.code == "password_too_weak" 113 | assert ( 114 | exc.value.message == "Password must contain at least 1 special character (" 115 | " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~)." 116 | ) 117 | 118 | 119 | def test_contains_special_characters_get_help_text(): 120 | """ 121 | Test that the get_help_text string works as expected. 122 | """ 123 | validator = ContainsSpecialCharactersValidator(min_characters=1) 124 | assert ( 125 | validator.get_help_text() 126 | == "Your password must contain at least 1 special character (" 127 | " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~)." 128 | ) 129 | validator = ContainsSpecialCharactersValidator(min_characters=2) 130 | assert ( 131 | validator.get_help_text() 132 | == "Your password must contain at least 2 special characters (" 133 | " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~)." 134 | ) 135 | 136 | 137 | def test_maximum_length_validator(): 138 | """ 139 | Test that the MaximumLengthValidator works as expected and raises a 140 | ValidationError when the password is longer than the maximum length. 141 | """ 142 | validator = MaximumLengthValidator(max_length=10) 143 | assert validator.validate("abcdefghij") is None 144 | with pytest.raises(ValidationError) as exc: 145 | validator.validate("abcdefghijk") 146 | assert exc.value.message == "Password must contain at maximum 10 characters." 147 | 148 | 149 | def test_maximum_length_get_help_text(): 150 | """ 151 | Test that the get_help_text string works as expected. 152 | """ 153 | validator = MaximumLengthValidator(max_length=12) 154 | assert ( 155 | validator.get_help_text() == "Password must contain at maximum 12 characters." 156 | ) 157 | 158 | 159 | def test_max_consecutive_characters_validator(): 160 | """ 161 | Test that the MaxConsecutiveCharactersValidator works as expected and raises a 162 | ValidationError when the password contains more than the maximum number of 163 | consecutive characters. 164 | """ 165 | validator = MaxConsecutiveCharactersValidator() 166 | assert validator.validate("abcdefghij") is None 167 | assert validator.validate("aaabbbccc") is None 168 | with pytest.raises(ValidationError) as exc: 169 | validator.validate("aaaabbbccc") 170 | assert ( 171 | exc.value.message 172 | == "Password contains consecutively repeating characters. e.g 'aaa' or '111'" 173 | ) 174 | 175 | 176 | def test_max_consecutive_characters_get_help_text(): 177 | """ 178 | Test that the get_help_text string works as expected. 179 | """ 180 | validator = MaxConsecutiveCharactersValidator() 181 | assert ( 182 | validator.get_help_text() 183 | == "Password cannot contain consecutively repeating characters. e.g 'aaa' or" 184 | " '111'" 185 | ) 186 | 187 | 188 | def test_consecutively_increasing_digit_validator(): 189 | """ 190 | Test that the ConsecutivelyIncreasingDigitValidator works as expected and raises a 191 | ValidationError when the password contains more than the maximum number of 192 | consecutive characters. 193 | """ 194 | validator = ConsecutivelyIncreasingDigitValidator(max_consecutive=3) 195 | assert validator.validate("abcdefghij") is None 196 | assert validator.validate("abcdefg123") is None 197 | with pytest.raises(ValidationError) as exc: 198 | validator.validate("1234567890") 199 | assert ( 200 | exc.value.message 201 | == "Password contains consecutively increasing digits. e.g '12345'" 202 | ) 203 | 204 | 205 | def test_consecutively_increasing_digit_get_help_text(): 206 | """ 207 | Test that the get_help_text string works as expected. 208 | """ 209 | validator = ConsecutivelyIncreasingDigitValidator(max_consecutive=3) 210 | assert ( 211 | validator.get_help_text() 212 | == "Password cannot contain consecutively increasing digits. e.g '12345'" 213 | ) 214 | 215 | 216 | def test_consecutively_decreasing_digit_validator(): 217 | """ 218 | Test that the ConsecutivelyDecreasingDigitValidator works as expected and raises a 219 | ValidationError when the password contains more than the maximum number of 220 | consecutive characters. 221 | """ 222 | validator = ConsecutivelyDecreasingDigitValidator(max_consecutive=3) 223 | assert validator.validate("abcdefghij") is None 224 | assert validator.validate("abcdefg321") is None 225 | with pytest.raises(ValidationError) as exc: 226 | validator.validate("9876543210") 227 | assert ( 228 | exc.value.message 229 | == "Password contains consecutively decreasing digits. e.g '54321'" 230 | ) 231 | 232 | 233 | def test_consecutively_decreasing_digit_get_help_text(): 234 | """ 235 | Test that the get_help_text string works as expected. 236 | """ 237 | validator = ConsecutivelyDecreasingDigitValidator(max_consecutive=3) 238 | assert ( 239 | validator.get_help_text() 240 | == "Password cannot contain consecutively decreasing digits. e.g '54321'" 241 | ) 242 | 243 | 244 | def test_valid_password(): 245 | """ 246 | Test that the validate_password function works as expected. 247 | """ 248 | assert validate_password("Abc$d1234!") is None 249 | 250 | 251 | def test_invalid_password(): 252 | """ 253 | Test that the validate_password function works as expected and raises all 254 | relevant ValidationErrors when the password is too weak and doesn't conform 255 | to the password validation rules. 256 | """ 257 | with pytest.raises(ValidationError) as exc: 258 | validate_password("") 259 | assert exc.value.messages == [ 260 | "This password is too short. It must contain at least 10 characters.", 261 | "Password must contain at least 1 number.", 262 | "Password must contain at least 1 uppercase character.", 263 | "Password must contain at least 1 lowercase character.", 264 | ( 265 | "Password must contain at least 1 special character (" 266 | " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~)." 267 | ), 268 | ] 269 | -------------------------------------------------------------------------------- /django_advanced_password_validation/advanced_password_validation.py: -------------------------------------------------------------------------------- 1 | """ 2 | Advanced password validation 3 | """ 4 | 5 | from django.core.exceptions import ValidationError 6 | from django.utils.translation import ngettext_lazy 7 | from django.utils.translation import gettext_lazy 8 | 9 | 10 | class ContainsDigitsValidator: 11 | """ 12 | Validates whether the password contains at least min_digits digits. 13 | """ 14 | 15 | def __init__(self, min_digits=1): 16 | """Initializes the validator. 17 | 18 | Args: 19 | min_digits (int, optional): Minimum number of digits to validate password 20 | against. Defaults to 1. 21 | """ 22 | self.min_digits = min_digits 23 | 24 | def validate(self, password, user=None): 25 | """ 26 | Validates whether the password contains at least min_digits digits. 27 | 28 | Args: 29 | password (str): The password to validate. 30 | user (User): The user to validate the password for. (unused) 31 | 32 | Raises: 33 | ValidationError: Password must contain at least {self.min_digits} number(s). 34 | """ 35 | if sum(c.isdigit() for c in password) < self.min_digits: 36 | raise ValidationError( 37 | ngettext_lazy( 38 | "Password must contain at least %(min_digits)s number.", 39 | "Password must contain at least %(min_digits)s numbers.", 40 | self.min_digits, 41 | ) 42 | % {"min_digits": self.min_digits}, 43 | code="password_too_weak", 44 | ) 45 | 46 | def get_help_text(self): 47 | """ 48 | Get the help text for the validator. 49 | """ 50 | return ngettext_lazy( 51 | "Your password must contain at least %(min_digits)s number.", 52 | "Your password must contain at least %(min_digits)s numbers.", 53 | self.min_digits, 54 | ) % {"min_digits": self.min_digits} 55 | 56 | 57 | class ContainsUppercaseValidator: 58 | """ 59 | Validates whether the password contains at least min_uppercase uppercase characters. 60 | """ 61 | 62 | def __init__(self, min_uppercase=1): 63 | """Initializes the validator. 64 | 65 | Args: 66 | min_uppercase (int, optional): Minimum number of uppercase characters to 67 | validate password against. Defaults to 1. 68 | """ 69 | self.min_uppercase = min_uppercase 70 | 71 | def validate(self, password, user=None): 72 | """ 73 | Validates whether the password contains at least min_uppercase uppercase characters. 74 | 75 | Args: 76 | password (str): The password to validate. 77 | user (User): The user to validate the password for. (unused) 78 | 79 | Raises: 80 | ValidationError: Password must contain at least {self.min_uppercase} uppercase 81 | character(s). 82 | """ 83 | if sum(c.isupper() for c in password) < self.min_uppercase: 84 | raise ValidationError( 85 | ngettext_lazy( 86 | "Password must contain at least %(min_uppercase)s uppercase" 87 | " character.", 88 | "Password must contain at least %(min_uppercase)s uppercase" 89 | " characters.", 90 | self.min_uppercase, 91 | ) 92 | % {"min_uppercase": self.min_uppercase}, 93 | code="password_too_weak", 94 | ) 95 | 96 | def get_help_text(self): 97 | """ 98 | Get the help text for the validator. 99 | """ 100 | return ngettext_lazy( 101 | "Your password must contain at least %(min_uppercase)s uppercase" 102 | " character.", 103 | "Your password must contain at least %(min_uppercase)s uppercase" 104 | " characters.", 105 | self.min_uppercase, 106 | ) % {"min_uppercase": self.min_uppercase} 107 | 108 | 109 | class ContainsLowercaseValidator: 110 | """ 111 | Validates whether the password contains at least min_lowercase lowercase characters. 112 | """ 113 | 114 | def __init__(self, min_lowercase=1): 115 | """Initializes the validator. 116 | 117 | Args: 118 | min_lowercase (int, optional): Minimum number of lowercase characters to 119 | validate password against. Defaults to 1. 120 | """ 121 | self.min_lowercase = min_lowercase 122 | 123 | def validate(self, password, user=None): 124 | """ 125 | Validates whether the password contains at least min_lowercase lowercase characters. 126 | 127 | Args: 128 | password (str): The password to validate. 129 | user (User): The user to validate the password for. (unused) 130 | 131 | Raises: 132 | ValidationError: Password must contain at least {self.min_lowercase} lowercase 133 | character(s). 134 | """ 135 | if sum(c.islower() for c in password) < self.min_lowercase: 136 | raise ValidationError( 137 | ngettext_lazy( 138 | "Password must contain at least %(min_lowercase)s lowercase" 139 | " character.", 140 | "Password must contain at least %(min_lowercase)s lowercase" 141 | " characters.", 142 | self.min_lowercase, 143 | ) 144 | % {"min_lowercase": self.min_lowercase}, 145 | code="password_too_weak", 146 | ) 147 | 148 | def get_help_text(self): 149 | """ 150 | Get the help text for the validator. 151 | """ 152 | return ngettext_lazy( 153 | "Your password must contain at least %(min_lowercase)s lowercase" 154 | " character.", 155 | "Your password must contain at least %(min_lowercase)s lowercase" 156 | " characters.", 157 | self.min_lowercase, 158 | ) % {"min_lowercase": self.min_lowercase} 159 | 160 | 161 | class ContainsSpecialCharactersValidator: 162 | """ 163 | Validates whether the password contains at least min_characters special characters. 164 | """ 165 | 166 | def __init__(self, min_characters=1): 167 | """Initializes the validator. 168 | 169 | Args: 170 | min_characters (int, optional): Minimum number of special characters to 171 | validate password against. Defaults to 1. 172 | """ 173 | self.min_characters = min_characters 174 | self.characters = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" 175 | 176 | def validate(self, password, user=None): 177 | """ 178 | Validates whether the password contains at least min_characters special characters. 179 | 180 | Args: 181 | password (str): The password to validate. 182 | user (User): The user to validate the password for. (unused) 183 | 184 | Raises: 185 | ValidationError: Password must contain at least {self.min_characters} special 186 | character(s). 187 | """ 188 | if sum(c in set(self.characters) for c in password) < self.min_characters: 189 | raise ValidationError( 190 | ngettext_lazy( 191 | "Password must contain at least %(min_characters)s special" 192 | " character (%(special_characters)s).", 193 | "Password must contain at least %(min_characters)s special" 194 | " characters (%(special_characters)s).", 195 | self.min_characters, 196 | ) 197 | % { 198 | "min_characters": self.min_characters, 199 | "special_characters": "".join(self.characters), 200 | }, 201 | code="password_too_weak", 202 | ) 203 | 204 | def get_help_text(self): 205 | """ 206 | Get the help text for the validator. 207 | """ 208 | return ngettext_lazy( 209 | "Your password must contain at least %(min_characters)s special character" 210 | " (%(special_characters)s).", 211 | "Your password must contain at least %(min_characters)s special characters" 212 | " (%(special_characters)s).", 213 | self.min_characters, 214 | ) % { 215 | "min_characters": self.min_characters, 216 | "special_characters": "".join(self.characters), 217 | } 218 | 219 | 220 | class MaximumLengthValidator: 221 | """ 222 | OWASP recommends setting a maximum password length, typically 128 characters, to prevent 223 | 'long password Denial of Service attacks'. 224 | """ 225 | 226 | def __init__(self, max_length=128): 227 | """Initializes the validator. 228 | 229 | Args: 230 | max_length (int, optional): Maximum length of the password. Defaults to 128. 231 | """ 232 | self.max_length = max_length 233 | 234 | def validate(self, password, user=None): 235 | """ 236 | Validates whether the password contains at least min_characters special characters. 237 | 238 | Args: 239 | password (str): The password to validate. 240 | user (User): The user to validate the password for. (unused) 241 | 242 | Raises: 243 | ValidationError: Password must contain at least {self.min_characters} special 244 | character(s). 245 | """ 246 | if len(password) > self.max_length: 247 | raise ValidationError( 248 | ngettext_lazy( 249 | "Password must contain at maximum %(max_length)s character.", 250 | "Password must contain at maximum %(max_length)s characters.", 251 | self.max_length, 252 | ) 253 | % {"max_length": self.max_length} 254 | ) 255 | 256 | def get_help_text(self): 257 | """ 258 | Get the help text for the validator. 259 | """ 260 | return ngettext_lazy( 261 | "Password must contain at maximum %(max_length)s character.", 262 | "Password must contain at maximum %(max_length)s characters.", 263 | self.max_length, 264 | ) % {"max_length": self.max_length} 265 | 266 | 267 | class MaxConsecutiveCharactersValidator: 268 | """ 269 | Validates whether the password contains more than max_consecutive consecutive characters. 270 | """ 271 | 272 | def __init__(self, max_consecutive=3): 273 | """Initializes the validator. 274 | 275 | Args: 276 | max_consecutive (int, optional): Maximum number of consecutive characters to 277 | validate password against. Defaults to 3. 278 | """ 279 | self.max_consecutive = max_consecutive 280 | 281 | def validate(self, password, user=None): 282 | """ 283 | Validates whether the password contains more than max_consecutive consecutive characters. 284 | 285 | Args: 286 | password (str): The password to validate. 287 | user (User): The user to validate the password for. (unused) 288 | 289 | Raises: 290 | ValidationError: Password cannot contain consecutively repeating 291 | characters. e.g 'aaa' or '111' 292 | """ 293 | for c in password: 294 | if password.count(c) >= self.max_consecutive: 295 | check = c * (self.max_consecutive + 1) 296 | if check in password: 297 | raise ValidationError( 298 | gettext_lazy( 299 | "Password contains consecutively repeating characters. " 300 | "e.g 'aaa' or '111'" 301 | ) 302 | ) 303 | 304 | def get_help_text(self): 305 | """ 306 | Get the help text for the validator. 307 | """ 308 | return gettext_lazy( 309 | "Password cannot contain consecutively repeating characters. e.g 'aaa' or" 310 | " '111'" 311 | ) 312 | 313 | 314 | class ConsecutivelyIncreasingDigitValidator: 315 | """ 316 | Validates whether the password contains consecutively increasing digits. 317 | """ 318 | 319 | def __init__(self, max_consecutive=3): 320 | """Initializes the validator. 321 | 322 | Args: 323 | max_consecutive (int, optional): Maximum number of consecutive digits to 324 | validate password against. Defaults to 3. 325 | """ 326 | self.max_consecutive = max_consecutive 327 | 328 | def validate(self, password, user=None): 329 | """ 330 | Validates whether the password contains consecutively increasing digits. 331 | 332 | Args: 333 | password (str): The password to validate. 334 | user (User): The user to validate the password for. (unused) 335 | 336 | Raises: 337 | ValidationError: Password contains consecutively increasing digits. e.g '12345' 338 | """ 339 | for c in password: 340 | if c.isdigit(): 341 | count = 0 342 | digit = int(c) 343 | index = password.index(c) 344 | 345 | try: 346 | for i in range(1, self.max_consecutive + 1): 347 | if password[index + i].isdigit(): 348 | if int(password[index + i]) == digit + 1: 349 | count += 1 350 | digit += 1 351 | 352 | while count >= self.max_consecutive: 353 | raise ValidationError( 354 | gettext_lazy( 355 | "Password contains consecutively increasing" 356 | " digits. e.g '12345'" 357 | ) 358 | ) 359 | except IndexError: 360 | pass 361 | 362 | def get_help_text(self): 363 | """ 364 | Get the help text for the validator. 365 | """ 366 | return gettext_lazy( 367 | "Password cannot contain consecutively increasing digits. e.g '12345'" 368 | ) 369 | 370 | 371 | class ConsecutivelyDecreasingDigitValidator: 372 | """ 373 | Validates whether the password contains consecutively decreasing digits. 374 | """ 375 | 376 | def __init__(self, max_consecutive=3): 377 | """Initializes the validator. 378 | 379 | Args: 380 | max_consecutive (int, optional): Maximum number of consecutive digits to 381 | validate password against. Defaults to 3. 382 | """ 383 | self.max_consecutive = max_consecutive 384 | 385 | def validate(self, password, user=None): 386 | """ 387 | Validates whether the password contains consecutively decreasing digits. 388 | 389 | Args: 390 | password (str): The password to validate. 391 | user (User): The user to validate the password for. (unused) 392 | 393 | Raises: 394 | ValidationError: Password contains consecutively decreasing digits. e.g '54321' 395 | """ 396 | for c in password: 397 | if c.isdigit(): 398 | count = 0 399 | digit = int(c) 400 | index = password.index(c) 401 | 402 | try: 403 | for i in range(1, self.max_consecutive + 1): 404 | if password[index + i].isdigit(): 405 | if int(password[index + i]) == digit - 1: 406 | count += 1 407 | digit -= 1 408 | 409 | while count >= self.max_consecutive: 410 | raise ValidationError( 411 | gettext_lazy( 412 | "Password contains consecutively decreasing" 413 | " digits. e.g '54321'" 414 | ) 415 | ) 416 | except IndexError: 417 | pass 418 | 419 | def get_help_text(self): 420 | """ 421 | Get the help text for the validator. 422 | """ 423 | return gettext_lazy( 424 | "Password cannot contain consecutively decreasing digits. e.g '54321'" 425 | ) 426 | --------------------------------------------------------------------------------