Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: generate password with configured validators #343

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
)
Loading