From 39c92ad3d40e565b1f0be9987552833d4f9124f8 Mon Sep 17 00:00:00 2001 From: waleed mujahid Date: Wed, 30 Oct 2024 19:40:19 +0500 Subject: [PATCH] Feat: generate password based on AUTH_PASSWORD_VALIDATORS --- .../util/password_policy_validators.py | 4 +- .../djangoapps/user_authn/views/register.py | 4 +- .../user_authn/views/registration_form.py | 12 +++++ openedx/features/edly/tests/test_utils.py | 48 +++++++++++++++++ openedx/features/edly/utils.py | 53 +++++++++++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) diff --git a/common/djangoapps/util/password_policy_validators.py b/common/djangoapps/util/password_policy_validators.py index 43d7aa10558d..b410c94781b4 100644 --- a/common/djangoapps/util/password_policy_validators.py +++ b/common/djangoapps/util/password_policy_validators.py @@ -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={}): @@ -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( diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index 35b1c6cf1755..b8d6bed2f7cd 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -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, @@ -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 ) @@ -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 diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index 9df3c558094f..ba10c2cacd99 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -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", diff --git a/openedx/features/edly/tests/test_utils.py b/openedx/features/edly/tests/test_utils.py index 220d3fedaada..c4eaaeeb8f94 100644 --- a/openedx/features/edly/tests/test_utils.py +++ b/openedx/features/edly/tests/test_utils.py @@ -1,6 +1,7 @@ """ Tests for Edly Utils Functions. """ +import string from urllib.parse import urljoin import json import jwt @@ -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, @@ -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 ( @@ -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( @@ -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) diff --git a/openedx/features/edly/utils.py b/openedx/features/edly/utils.py index 6f454785781a..7046c7d8ebe2 100644 --- a/openedx/features/edly/utils.py +++ b/openedx/features/edly/utils.py @@ -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 @@ -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__) @@ -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)