Skip to content

Commit

Permalink
fix: generate password with configured validators
Browse files Browse the repository at this point in the history
Fix the `generate_password` function, that generates a random password,
by honoring the configured password policy validators
`AUTH_PASSWORD_VALIDATORS` setting.
  • Loading branch information
igobranco committed Sep 13, 2023
1 parent f4b363d commit 1758b77
Show file tree
Hide file tree
Showing 2 changed files with 201 additions and 5 deletions.
88 changes: 83 additions & 5 deletions edx_django_utils/user/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
118 changes: 118 additions & 0 deletions edx_django_utils/user/tests/test_user.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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",
)

0 comments on commit 1758b77

Please sign in to comment.