From dd43483ce6086e984eb596f89b215ffc29c2f3b8 Mon Sep 17 00:00:00 2001 From: Quentin BEY Date: Fri, 28 Mar 2025 14:09:01 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(passwords)=20add=20valida?= =?UTF-8?q?tors=20for=20production?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This enabled various password validators to enforce password complexity. --- CHANGELOG.md | 1 + .../core/tests/test_password_validators.py | 50 ++++++++++++++++ .../tests/test_password_validators.py | 59 +++++++++++++++++++ src/backend/people/settings.py | 42 +++++++++++++ src/backend/pyproject.toml | 1 + 5 files changed, 153 insertions(+) create mode 100644 src/backend/core/tests/test_password_validators.py create mode 100644 src/backend/mailbox_manager/tests/test_password_validators.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b29aed..2526e00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- 🔒️(passwords) add validators for production #850 - ✨(domains) allow to re-run check on domain if status is failed - ✨(organization) add `is_active` field - ✨(domains) notify support when domain status changes #668 diff --git a/src/backend/core/tests/test_password_validators.py b/src/backend/core/tests/test_password_validators.py new file mode 100644 index 0000000..4d34fbf --- /dev/null +++ b/src/backend/core/tests/test_password_validators.py @@ -0,0 +1,50 @@ +"""Test the production settings for password validation is correct.""" + +from django.contrib.auth.password_validation import ( + get_default_password_validators, + validate_password, +) + +import pytest + +from core.factories import UserFactory + +from people.settings import Production + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="use_production_password_validators") +def use_production_password_validators_fixture(settings): + """Set the production password validators.""" + settings.AUTH_PASSWORD_VALIDATORS = Production.AUTH_PASSWORD_VALIDATORS + + get_default_password_validators.cache_clear() + assert len(get_default_password_validators()) == 5 + + yield + + get_default_password_validators.cache_clear() + + +@pytest.mark.parametrize( + "password, error", + [ + ("password", "This password is too common."), + ("password123", "This password is too common."), + ("123", "This password is too common."), + ("coucou", "This password is too common."), + ("john doe 123", "The password is too similar to the name"), + ], +) +def test_validate_password_validator( + use_production_password_validators, # pylint: disable=unused-argument + password, + error, +): + """Test the Mailbox password validation.""" + user = UserFactory(name="John Doe") + + with pytest.raises(Exception) as excinfo: + validate_password(password, user) + assert error in str(excinfo.value) diff --git a/src/backend/mailbox_manager/tests/test_password_validators.py b/src/backend/mailbox_manager/tests/test_password_validators.py new file mode 100644 index 0000000..97b749c --- /dev/null +++ b/src/backend/mailbox_manager/tests/test_password_validators.py @@ -0,0 +1,59 @@ +"""Test the production settings for password validation is correct.""" + +from django.contrib.auth.password_validation import ( + get_default_password_validators, + validate_password, +) + +import pytest + +from mailbox_manager.factories import MailboxFactory +from people.settings import Production + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(name="use_production_password_validators") +def use_production_password_validators_fixture(settings): + """Set the production password validators.""" + settings.AUTH_PASSWORD_VALIDATORS = Production.AUTH_PASSWORD_VALIDATORS + + get_default_password_validators.cache_clear() + assert len(get_default_password_validators()) == 5 + + yield + + get_default_password_validators.cache_clear() + + +@pytest.mark.parametrize( + "password, error", + [ + ("password", "This password is too common."), + ("password123", "This password is too common."), + ("123", "This password is too common."), + ("coucou", "This password is too common."), + ("john doe 123", "The password is too similar to the"), + ], +) +def test_validate_password_validator( + use_production_password_validators, # pylint: disable=unused-argument + password, + error, +): + """Test the Mailbox password validation.""" + mailbox_1 = MailboxFactory( + first_name="John", + last_name="Doe", + ) + mailbox_2 = MailboxFactory( + local_part="john.doe", + ) + + with pytest.raises(Exception) as excinfo: + validate_password(password, mailbox_1) + assert error in str(excinfo.value) + + with pytest.raises(Exception) as excinfo: + validate_password(password, mailbox_2) + assert error in str(excinfo.value) diff --git a/src/backend/people/settings.py b/src/backend/people/settings.py index 8dcacb5..36183c9 100755 --- a/src/backend/people/settings.py +++ b/src/backend/people/settings.py @@ -231,6 +231,7 @@ class Base(Configuration): "mailbox_oauth2", *INSTALLED_PLUGINS, # Third party apps + "django_zxcvbn_password_validator", "drf_spectacular", "drf_spectacular_sidecar", # required for Django collectstatic discovery "corsheaders", @@ -915,6 +916,47 @@ class Production(Base): CSRF_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True + # Password management + + # - Password strength for ZxcvbnPasswordValidator + # 0 too guessable: risky password. (guesses < 10^3) + # 1 very guessable: protection from throttled online attacks. (guesses < 10^6) + # 2 somewhat guessable: protection from unthrottled online attacks. (guesses < 10^8) + # 3 safely unguessable: moderate protection from offline slow-hash scenario. (guesses < 10^10) + # 4 very unguessable: strong protection from offline slow-hash scenario. (guesses >= 10^10) + PASSWORD_MINIMAL_STRENGTH = values.IntegerValue( + default=3, + environ_name="PASSWORD_MINIMAL_STRENGTH", + environ_prefix=None, + ) + + AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + "OPTIONS": { + "user_attributes": ( + "email", # for core.User + "name", # for core.User + "first_name", # for mailbox_manager.Mailbox + "last_name", # for mailbox_manager.Mailbox + "local_part", # for mailbox_manager.Mailbox + ), + }, + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, + { + "NAME": "django_zxcvbn_password_validator.ZxcvbnPasswordValidator", + }, + ] + # For static files in production, we want to use a backend that includes a hash in # the filename, that is calculated from the file content, so that browsers always # get the updated version of each file. diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index 356460d..5303334 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "django-storages==1.14.5", "django-timezone-field>=5.1", "django-treebeard==4.7.1", + "django-zxcvbn-password-validator==1.4.5", "django==5.1.7", "djangorestframework==3.15.2", "dockerflow==2024.4.2",