Skip to content

Commit

Permalink
Feat: generate password based on AUTH_PASSWORD_VALIDATORS
Browse files Browse the repository at this point in the history
  • Loading branch information
Waleed-Mujahid committed Nov 7, 2024
1 parent 5233540 commit 39c92ad
Show file tree
Hide file tree
Showing 5 changed files with 119 additions and 2 deletions.
4 changes: 3 additions & 1 deletion common/djangoapps/util/password_policy_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
# characters. The point of this restriction is to restrict the login page password field to prevent
# any sort of attacks involving sending massive passwords.
DEFAULT_MAX_PASSWORD_LENGTH = 5000
SPECIAL_CHARACTERS = "!@#$%^&*"
COMMON_SYMBOLS = "$+<=>^`|~"


def create_validator_config(name, options={}):
Expand Down Expand Up @@ -496,7 +498,7 @@ def __init__(self, min_symbol=0):
self.min_symbol = min_symbol

def validate(self, password, user=None):
if _validate_condition(password, lambda c: c in '!@#$%^&*', self.min_symbol):
if _validate_condition(password, lambda c: c in SPECIAL_CHARACTERS, self.min_symbol):
return
raise ValidationError(
ungettext(
Expand Down
4 changes: 3 additions & 1 deletion openedx/core/djangoapps/user_authn/views/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
)
from openedx.core.djangoapps.user_api.preferences import api as preferences_api
from openedx.core.djangoapps.user_authn.cookies import set_logged_in_cookies
from openedx.core.djangoapps.user_authn.utils import generate_password, is_registration_api_v1
from openedx.core.djangoapps.user_authn.utils import is_registration_api_v1
from openedx.core.djangoapps.user_authn.views.registration_form import (
AccountCreationForm,
RegistrationFormFactory,
Expand All @@ -67,6 +67,7 @@
create_user_unsubscribe_url,
has_not_unsubscribe_user_email,
is_config_enabled,
generate_password,
get_edly_sub_org_from_request,
get_username_and_name_by_email
)
Expand Down Expand Up @@ -184,6 +185,7 @@ def create_account_with_params(request, params):

if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api):
params["password"] = generate_password()
params["confirm_password"] = params["password"]

# in case user is registering via third party (Google, Facebook) and pipeline has expired, show appropriate
# error message
Expand Down
12 changes: 12 additions & 0 deletions openedx/core/djangoapps/user_authn/views/registration_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,18 @@ def _apply_third_party_auth_overrides(self, request, form_desc):
instructions="",
restrictions={}
)

# Hide the confirm password field
form_desc.override_field_properties(
"confirm_password",
default="",
field_type="hidden",
required=False,
label="",
instructions="",
restrictions={}
)

# used to identify that request is running third party social auth
form_desc.add_field(
"social_auth_provider",
Expand Down
48 changes: 48 additions & 0 deletions openedx/features/edly/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Tests for Edly Utils Functions.
"""
import string
from urllib.parse import urljoin
import json
import jwt
Expand Down Expand Up @@ -35,6 +36,7 @@
decode_edly_user_info_cookie,
edly_panel_user_has_edly_org_access,
encode_edly_user_info_cookie,
generate_password,
get_edly_sub_org_from_cookie,
get_edx_org_from_cookie,
get_marketing_link,
Expand All @@ -47,6 +49,7 @@
filter_courses_based_on_org,
get_current_site_invalid_certificate_context,
)
from common.djangoapps.util.password_policy_validators import SPECIAL_CHARACTERS
from organizations.tests.factories import OrganizationFactory
from student import auth
from student.roles import (
Expand Down Expand Up @@ -97,6 +100,28 @@ def setUp(self):
}
self.course = CourseOverviewFactory()
RequestCache.clear_all_namespaces()
self.AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {'min_length': 8}
},
{
'NAME': 'util.password_policy_validators.LowercaseValidator',
'OPTIONS': {'min_lower': 1}
},
{
'NAME': 'util.password_policy_validators.UppercaseValidator',
'OPTIONS': {'min_upper': 1}
},
{
'NAME': 'util.password_policy_validators.NumericValidator',
'OPTIONS': {'min_numeric': 1}
},
{
'NAME': 'util.password_policy_validators.SpecialCharactersValidator',
'OPTIONS': {'min_symbol': 1}
}
]

def _get_stub_session(self, expire_at_browser_close=False, max_age=604800):
return MagicMock(
Expand Down Expand Up @@ -611,3 +636,26 @@ def test_is_course_org_same_as_site_org_for_invalid_course_id(self):
)
course_id = CourseKey.from_string('course-v1:edX+Test+Test_Course')
assert not is_course_org_same_as_site_org(self.request.site, course_id)

def test_generate_password_meets_validators(self):
password = generate_password(length=12)

self.assertGreaterEqual(len(password), 8)
self.assertLessEqual(len(password), 75)
self.assertTrue(any(c.islower() for c in password))
self.assertTrue(any(c.isupper() for c in password))
self.assertTrue(any(c.isdigit() for c in password))
self.assertTrue(any(c in SPECIAL_CHARACTERS for c in password))

def test_generate_password_with_minimum_requirements(self):
password = generate_password(length=16, min_digits=2, min_lowercase=3, min_uppercase=2, min_symbols=2)

self.assertEqual(len(password), 16)
self.assertGreaterEqual(sum(c.isdigit() for c in password), 2)
self.assertGreaterEqual(sum(c.islower() for c in password), 3)
self.assertGreaterEqual(sum(c.isupper() for c in password), 2)
self.assertGreaterEqual(sum(c in string.punctuation for c in password), 2)

def test_min_length_validation(self):
with self.assertRaises(ValueError):
generate_password(length=7)
53 changes: 53 additions & 0 deletions openedx/features/edly/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import logging
from functools import partial
from datetime import datetime
import random
import string
from urllib.parse import urljoin, urlparse

import jwt
Expand Down Expand Up @@ -40,6 +42,7 @@
from openedx.features.edly.context_processor import Colour
from openedx.features.edly.models import EdlyMultiSiteAccess, EdlySubOrganization
from common.djangoapps.student.models import UserProfile
from common.djangoapps.util.password_policy_validators import SPECIAL_CHARACTERS, COMMON_SYMBOLS

LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -816,3 +819,53 @@ def create_user_unsubscribe_url(email, site):
url = 'https://' + url

return url


def generate_password(length=12, min_digits=1, min_lowercase=1, min_uppercase=1, min_symbols=1, min_special=1):
"""Generate a password that meets the configured password policy."""
if length < 8:
raise ValueError("Password must be at least 8 characters long.")

# Retrieve password validator settings from Django settings
password_validators = getattr(settings, 'AUTH_PASSWORD_VALIDATORS', [])
for validator in password_validators:
validator_name = validator.get('NAME')
validator_options = validator.get('OPTIONS', {})
if validator_name == 'django.contrib.auth.password_validation.MinimumLengthValidator':
length = max(validator_options.get('min_length', length), length)
elif validator_name == 'util.password_policy_validators.NumericValidator':
min_digits = validator_options.get('min_numeric', 1)
elif validator_name == 'util.password_policy_validators.UppercaseValidator':
min_uppercase = validator_options.get('min_upper', 1)
elif validator_name == 'util.password_policy_validators.LowercaseValidator':
min_lowercase = validator_options.get('min_lower', 1)
elif validator_name == 'util.password_policy_validators.SpecialCharactersValidator':
min_special = validator_options.get('min_special', 1)
elif validator_name == 'util.password_policy_validators.SymbolValidator':
min_symbols = validator_options.get('min_symbol', 1)

digits = string.digits
uppercase = string.ascii_uppercase
lowercase = string.ascii_lowercase
symbols = COMMON_SYMBOLS
special = SPECIAL_CHARACTERS

total_required = min_digits + min_lowercase + min_uppercase + min_symbols + min_special
if length < total_required:
raise ValueError("Password length is too short for the specified requirements.")

password = []

password.extend(random.choice(digits) for _ in range(min_digits))
password.extend(random.choice(uppercase) for _ in range(min_uppercase))
password.extend(random.choice(lowercase) for _ in range(min_lowercase))
password.extend(random.choice(symbols) for _ in range(min_symbols))
password.extend(random.choice(special) for _ in range(min_special))

remaining_length = length - len(password)
all_characters = digits + uppercase + lowercase + symbols
password.extend(random.choices(all_characters, k=remaining_length))

random.shuffle(password)

return ''.join(password)

0 comments on commit 39c92ad

Please sign in to comment.