diff --git a/edx_django_utils/user/__init__.py b/edx_django_utils/user/__init__.py index 2f0248c9..94ef6394 100644 --- a/edx_django_utils/user/__init__.py +++ b/edx_django_utils/user/__init__.py @@ -4,17 +4,95 @@ import random import string +import sys +import unicodedata + +from django.conf import settings + + +def _password_complexity(): + """ + Inspect AUTH_PASSWORD_VALIDATORS setting and generate a dict with the requirements for + usage in the `generate_password` function. + """ + password_validators = settings.AUTH_PASSWORD_VALIDATORS + known_validators = { + "common.djangoapps.util.password_policy_validators.MinimumLengthValidator": "min_length", + "common.djangoapps.util.password_policy_validators.MaximumLengthValidator": "max_length", + "common.djangoapps.util.password_policy_validators.AlphabeticValidator": "min_alphabetic", + "common.djangoapps.util.password_policy_validators.UppercaseValidator": "min_upper", + "common.djangoapps.util.password_policy_validators.LowercaseValidator": "min_lower", + "common.djangoapps.util.password_policy_validators.NumericValidator": "min_numeric", + "common.djangoapps.util.password_policy_validators.PunctuationValidator": "min_punctuation", + "common.djangoapps.util.password_policy_validators.SymbolValidator": "min_symbol", + } + complexity = {} + + for validator in password_validators: + param_name = known_validators.get(validator["NAME"], None) + if param_name is not None: + complexity[param_name] = validator["OPTIONS"].get(param_name, 0) + + # merge alphabetic with lower and uppercase + if complexity.get("min_alphabetic") and ( + complexity.get("min_lower") or complexity.get("min_upper") + ): + complexity["min_alphabetic"] = max( + 0, + complexity["min_alphabetic"] + - complexity.get("min_lower", 0) + - complexity.get("min_upper", 0), + ) + + return complexity + + +def _symbols(): + """Get all valid symbols""" + symbols = [] + for c in map(chr, range(sys.maxunicode + 1)): + if 'S' in unicodedata.category(c): + symbols.append(c) + return symbols def generate_password(length=12, chars=string.ascii_letters + string.digits): - """Generate a valid random password""" + """Generate a valid random password using the configured password validators, + `AUTH_PASSWORD_VALIDATORS` setting if defined.""" if length < 8: raise ValueError("password must be at least 8 characters") + password = '' choice = random.SystemRandom().choice - password = '' - password += choice(string.digits) - password += choice(string.ascii_letters) - password += ''.join([choice(chars) for _i in range(length - 2)]) + default_min_length = 8 + complexity = _password_complexity() + password_length = max(length, complexity.get('min_length', default_min_length)) + + password_rules = { + 'min_lower': list(string.ascii_lowercase), + 'min_upper': list(string.ascii_uppercase), + 'min_alphabetic': list(string.ascii_letters), + 'min_numeric': list(string.digits), + 'min_punctuation': list(string.punctuation), + 'min_symbol': list(_symbols()), + } + + for rule, elems in password_rules.items(): + needed = complexity.get(rule, 0) + for _ in range(needed): + next_char = choice(elems) + password += next_char + elems.remove(next_char) + + # fill the password to reach password_length + if len(password) < password_length: + password += ''.join( + [choice(chars) for _ in range(password_length - len(password))] + ) + + password_list = list(password) + random.shuffle(password_list) + + password = ''.join(password_list) return password diff --git a/edx_django_utils/user/tests/test_user.py b/edx_django_utils/user/tests/test_user.py index 973ff181..5622faed 100644 --- a/edx_django_utils/user/tests/test_user.py +++ b/edx_django_utils/user/tests/test_user.py @@ -1,7 +1,11 @@ """Test user functions""" +import string +import unicodedata + import pytest from django.test import TestCase +from django.test.utils import override_settings from edx_django_utils.user import generate_password @@ -30,3 +34,117 @@ def test_chars(self): def test_min_length(self): with pytest.raises(ValueError): generate_password(length=7) + + @override_settings( + AUTH_PASSWORD_VALIDATORS=[ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.MinimumLengthValidator", + "OPTIONS": {"min_length": 2}, + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.MaximumLengthValidator", + "OPTIONS": {"max_length": 75}, + }, + ] + ) + def test_third_party_auth_utils_generate_password_default_validators(self): + """ + Test the generate_password function using the default openedx edx-platform validators. + """ + self.assertEqual(len(generate_password(length=12)), 12) + + self.assertEqual(len(generate_password(length=50)), 50) + + with self.assertRaises(ValueError): + generate_password(length=5) + + @override_settings( + AUTH_PASSWORD_VALIDATORS=[ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.MinimumLengthValidator", + "OPTIONS": {"min_length": 8}, + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.MaximumLengthValidator", + "OPTIONS": {"max_length": 75}, + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.UppercaseValidator", + "OPTIONS": {"min_upper": 1}, + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.LowercaseValidator", + "OPTIONS": {"min_lower": 1}, + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.NumericValidator", + "OPTIONS": {"min_numeric": 1}, + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.PunctuationValidator", + "OPTIONS": {"min_punctuation": 1}, + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.SymbolValidator", + "OPTIONS": {"min_symbol": 1}, + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.AlphabeticValidator", + "OPTIONS": {"min_alphabetic": 3}, + }, + ] + ) + def test_third_party_auth_utils_generate_password_all(self): + """ + Test the `generate_password` function using a much stronger security validators. + It executes the same test multiple times, because of the random logic of the + generate_password function. + """ + self.assertEqual(len(generate_password(length=12)), 12) + + self.assertEqual(len(generate_password(length=8)), 8) + + with self.assertRaises(ValueError): + generate_password(length=5) + + # because of the random logic, we test the same code 10 times + for _ in range(10): + password = generate_password(length=12) + + self.assertEqual(len(password), 12) + + self.assertGreaterEqual( + sum(1 if char in string.ascii_uppercase else 0 for char in password), + 1, + msg=f"Password '{password}' should have at least 1 upper case character", + ) + + self.assertGreaterEqual( + sum(1 if char in string.ascii_lowercase else 0 for char in password), + 1, + msg=f"Password '{password}' should have at least 1 lower case character", + ) + + self.assertGreaterEqual( + sum(1 if char in string.digits else 0 for char in password), + 1, + msg=f"Password '{password}' should have at least 1 numeric character", + ) + + self.assertGreaterEqual( + sum(1 if char in string.punctuation else 0 for char in password), + 1, + msg=f"Password '{password}' should have at least 1 punctuation character", + ) + + self.assertGreaterEqual( + sum(1 if 'S' in unicodedata.category(char) else 0 for char in password), + 1, + msg=f"Password '{password}' should have at least 1 symbol character", + )