diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fea8fd127ae19f9925291a9236c2d57de8d4b83b..fc52c5156e43f114c2d73a36482c595877537ddc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -333,6 +333,8 @@ assessors to edit the original submitter's work. LMS: Fixed a bug that caused links from forum user profile pages to threads to lead to 404s if the course id contained a '-' character. +Studio/LMS: Add password policy enforcement to new account creation + Studio/LMS: Added ability to set due date formatting through Studio's Advanced Settings. The key is due_date_display_format, and the value should be a format supported by Python's strftime function. diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 555fa39ec0615d48d606469821eb42a02d393777..a2c95be367f6009058078d2b66a3ad41e6c32606 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -238,3 +238,10 @@ if len(MICROSITE_CONFIGURATION.keys()) > 0: VIRTUAL_UNIVERSITIES, microsites_root=path(MICROSITE_ROOT_DIR) ) + +#### PASSWORD POLICY SETTINGS ##### +PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH") +PASSWORD_MAX_LENGTH = ENV_TOKENS.get("PASSWORD_MAX_LENGTH") +PASSWORD_COMPLEXITY = ENV_TOKENS.get("PASSWORD_COMPLEXITY", {}) +PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = ENV_TOKENS.get("PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD") +PASSWORD_DICTIONARY = ENV_TOKENS.get("PASSWORD_DICTIONARY", []) diff --git a/cms/envs/common.py b/cms/envs/common.py index 4cde93669ef38c5423db1fd8f8bc5359cd4d1a7e..7417ed458992ba3a2a8079a677643bc348e15f4e 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -63,6 +63,9 @@ FEATURES = { # edX has explicitly added them to the course creator group. 'ENABLE_CREATOR_GROUP': False, + # whether to use password policy enforcement or not + 'ENFORCE_PASSWORD_POLICY': False, + # If set to True, Studio won't restrict the set of advanced components # to just those pre-approved by edX 'ALLOW_ALL_ADVANCED_COMPONENTS': False, @@ -477,6 +480,14 @@ TRACKING_BACKENDS = { } } +#### PASSWORD POLICY SETTINGS ##### + +PASSWORD_MIN_LENGTH = None +PASSWORD_MAX_LENGTH = None +PASSWORD_COMPLEXITY = {} +PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = None +PASSWORD_DICTIONARY = [] + # We're already logging events, and we don't want to capture user # names/passwords. Heartbeat events are likely not interesting. TRACKING_IGNORE_URL_PATTERNS = [r'^/event', r'^/login', r'^/heartbeat'] diff --git a/cms/urls.py b/cms/urls.py index 8b6b18ef71bf59860bab9880f181ec8dd08477c5..d21f0a22227b404f928bce6352c53e4ecd9ae398 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -42,7 +42,7 @@ urlpatterns = patterns('', # nopep8 urlpatterns += patterns( '', - url(r'^create_account$', 'student.views.create_account'), + url(r'^create_account$', 'student.views.create_account', name='create_account'), url(r'^activate/(?P<key>[^/]*)$', 'student.views.activate_account', name='activate'), # ajax view that actually does the work diff --git a/common/djangoapps/student/tests/test_password_policy.py b/common/djangoapps/student/tests/test_password_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..eaf296f7c278ba9733e7a90c82102bac95e0d9fc --- /dev/null +++ b/common/djangoapps/student/tests/test_password_policy.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- +""" +This test file will verify proper password policy enforcement, which is an option feature +""" +import json +import uuid + +from django.test import TestCase +from django.core.urlresolvers import reverse +from mock import patch +from django.test.utils import override_settings + + +@patch.dict("django.conf.settings.FEATURES", {'ENFORCE_PASSWORD_POLICY': True}) +class TestPasswordPolicy(TestCase): + """ + Go through some password policy tests to make sure things are properly working + """ + def setUp(self): + super(TestPasswordPolicy, self).setUp() + self.url = reverse('create_account') + self.url_params = { + 'username': 'foo_bar' + uuid.uuid4().hex, + 'email': 'foo' + uuid.uuid4().hex + '@bar.com', + 'name': 'username', + 'terms_of_service': 'true', + 'honor_code': 'true', + } + + @override_settings(PASSWORD_MIN_LENGTH=6) + def test_password_length_too_short(self): + self.url_params['password'] = 'aaa' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Invalid Length (must be 6 characters or more)", + ) + + @override_settings(PASSWORD_MIN_LENGTH=6) + def test_password_length_long_enough(self): + self.url_params['password'] = 'ThisIsALongerPassword' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) + + @override_settings(PASSWORD_MAX_LENGTH=12) + def test_password_length_too_long(self): + self.url_params['password'] = 'ThisPasswordIsWayTooLong' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Invalid Length (must be 12 characters or less)", + ) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'UPPER': 3}) + def test_password_not_enough_uppercase(self): + self.url_params['password'] = 'thisshouldfail' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Must be more complex (must contain 3 or more uppercase characters)", + ) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'UPPER': 3}) + def test_password_enough_uppercase(self): + self.url_params['password'] = 'ThisShouldPass' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'LOWER': 3}) + def test_password_not_enough_lowercase(self): + self.url_params['password'] = 'THISSHOULDFAIL' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Must be more complex (must contain 3 or more lowercase characters)", + ) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'LOWER': 3}) + def test_password_not_enough_lowercase(self): + self.url_params['password'] = 'ThisShouldPass' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'DIGITS': 3}) + def test_not_enough_digits(self): + self.url_params['password'] = 'thishasnodigits' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Must be more complex (must contain 3 or more digits)", + ) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'DIGITS': 3}) + def test_enough_digits(self): + self.url_params['password'] = 'Th1sSh0uldPa88' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'PUNCTUATION': 3}) + def test_not_enough_punctuations(self): + self.url_params['password'] = 'thisshouldfail' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Must be more complex (must contain 3 or more punctuation characters)", + ) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'PUNCTUATION': 3}) + def test_enough_punctuations(self): + self.url_params['password'] = 'Th!sSh.uldPa$*' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'WORDS': 3}) + def test_not_enough_words(self): + self.url_params['password'] = 'thisshouldfail' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Must be more complex (must contain 3 or more unique words)", + ) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'WORDS': 3}) + def test_enough_wordss(self): + self.url_params['password'] = u'this should pass' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", { + 'PUNCTUATION': 3, + 'WORDS': 3, + 'DIGITS': 3, + 'LOWER': 3, + 'UPPER': 3, + }) + def test_multiple_errors_fail(self): + self.url_params['password'] = 'thisshouldfail' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + errstring = ("Password: Must be more complex (" + "must contain 3 or more uppercase characters, " + "must contain 3 or more digits, " + "must contain 3 or more punctuation characters, " + "must contain 3 or more unique words" + ")") + self.assertEqual(obj['value'], errstring) + + @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", { + 'PUNCTUATION': 3, + 'WORDS': 3, + 'DIGITS': 3, + 'LOWER': 3, + 'UPPER': 3, + }) + def test_multiple_errors_pass(self): + self.url_params['password'] = u'tH1s Sh0u!d P3#$' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) + + @override_settings(PASSWORD_DICTIONARY=['foo', 'bar']) + @override_settings(PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD=1) + def test_dictionary_similarity_fail1(self): + self.url_params['password'] = 'foo' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Too similar to a restricted dictionary word.", + ) + + @override_settings(PASSWORD_DICTIONARY=['foo', 'bar']) + @override_settings(PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD=1) + def test_dictionary_similarity_fail2(self): + self.url_params['password'] = 'bar' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Too similar to a restricted dictionary word.", + ) + + @override_settings(PASSWORD_DICTIONARY=['foo', 'bar']) + @override_settings(PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD=1) + def test_dictionary_similarity_fail3(self): + self.url_params['password'] = 'fo0' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 400) + obj = json.loads(response.content) + self.assertEqual( + obj['value'], + "Password: Too similar to a restricted dictionary word.", + ) + + @override_settings(PASSWORD_DICTIONARY=['foo', 'bar']) + @override_settings(PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD=1) + def test_dictionary_similarity_pass(self): + self.url_params['password'] = 'this_is_ok' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) + + def test_with_unicode(self): + self.url_params['password'] = u'四節比分和七年å‰' + response = self.client.post(self.url, self.url_params) + self.assertEqual(response.status_code, 200) + obj = json.loads(response.content) + self.assertTrue(obj['success']) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index b745e2da671cffac9e2846741bf035c9decf5b77..007b894900da0b4f1fa2f1d20b85f930beb12efb 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -73,6 +73,11 @@ from util.json_request import JsonResponse from microsite_configuration.middleware import MicrositeConfiguration +from util.password_policy_validators import ( + validate_password_length, validate_password_complexity, + validate_password_dictionary +) + log = logging.getLogger("edx.student") AUDIT_LOG = logging.getLogger("audit") @@ -973,6 +978,19 @@ def create_account(request, post_override=None): js['field'] = 'username' return JsonResponse(js, status=400) + # enforce password complexity as an optional feature + if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): + try: + password = post_vars['password'] + + validate_password_length(password) + validate_password_complexity(password) + validate_password_dictionary(password) + except ValidationError, err: + js['value'] = _('Password: ') + '; '.join(err.messages) + js['field'] = 'password' + return JsonResponse(js, status=400) + # Ok, looks like everything is legit. Create the account. ret = _do_create_account(post_vars) if isinstance(ret, HttpResponse): # if there was an error then return that diff --git a/common/djangoapps/util/password_policy_validators.py b/common/djangoapps/util/password_policy_validators.py new file mode 100644 index 0000000000000000000000000000000000000000..987ec30a0294f61b286ba4a413b51e9a312ec245 --- /dev/null +++ b/common/djangoapps/util/password_policy_validators.py @@ -0,0 +1,92 @@ +# pylint: disable=E1101 +""" +This file exposes a number of password complexity validators which can be optionally added to +account creation + +This file was inspired by the django-passwords project at https://github.com/dstufft/django-passwords +authored by dstufft (https://github.com/dstufft) +""" +from __future__ import division +import string # pylint: disable=W0402 + +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + +import nltk + + +def validate_password_length(value): + """ + Validator that enforces minimum length of a password + """ + message = _("Invalid Length ({0})") + code = "length" + + min_length = getattr(settings, 'PASSWORD_MIN_LENGTH', None) + max_length = getattr(settings, 'PASSWORD_MAX_LENGTH', None) + + if min_length and len(value) < min_length: + raise ValidationError(message.format(_("must be {0} characters or more").format(min_length)), code=code) + elif max_length and len(value) > max_length: + raise ValidationError(message.format(_("must be {0} characters or less").format(max_length)), code=code) + + +def validate_password_complexity(value): + """ + Validator that enforces minimum complexity + """ + message = _("Must be more complex ({0})") + code = "complexity" + + complexities = getattr(settings, "PASSWORD_COMPLEXITY", None) + + if complexities is None: + return + + uppercase, lowercase, digits, non_ascii, punctuation = set(), set(), set(), set(), set() + + for character in value: + if character.isupper(): + uppercase.add(character) + elif character.islower(): + lowercase.add(character) + elif character.isdigit(): + digits.add(character) + elif character in string.punctuation: + punctuation.add(character) + else: + non_ascii.add(character) + + words = set(value.split()) + + errors = [] + if len(uppercase) < complexities.get("UPPER", 0): + errors.append(_("must contain {0} or more uppercase characters").format(complexities["UPPER"])) + if len(lowercase) < complexities.get("LOWER", 0): + errors.append(_("must contain {0} or more lowercase characters").format(complexities["LOWER"])) + if len(digits) < complexities.get("DIGITS", 0): + errors.append(_("must contain {0} or more digits").format(complexities["DIGITS"])) + if len(punctuation) < complexities.get("PUNCTUATION", 0): + errors.append(_("must contain {0} or more punctuation characters").format(complexities["PUNCTUATION"])) + if len(non_ascii) < complexities.get("NON ASCII", 0): + errors.append(_("must contain {0} or more non ascii characters").format(complexities["NON ASCII"])) + if len(words) < complexities.get("WORDS", 0): + errors.append(_("must contain {0} or more unique words").format(complexities["WORDS"])) + + if errors: + raise ValidationError(message.format(u', '.join(errors)), code=code) + + +def validate_password_dictionary(value): + """ + Insures that the password is not too similar to a defined set of dictionary words + """ + password_max_edit_distance = getattr(settings, "PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD", None) + password_dictionary = getattr(settings, "PASSWORD_DICTIONARY", None) + + if password_max_edit_distance and password_dictionary: + for word in password_dictionary: + distance = nltk.metrics.distance.edit_distance(value, word) + if distance <= password_max_edit_distance: + raise ValidationError(_("Too similar to a restricted dictionary word."), code="dictionary_word") diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 10ba3d70bb9acf6ffe39ead82f293eca0325f687..f759f9173d455be0617b9bea76b8bbd13483d124 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -357,3 +357,10 @@ if MICROSITE_CONFIGURATION: VIRTUAL_UNIVERSITIES, microsites_root=path(MICROSITE_ROOT_DIR) ) + +#### PASSWORD POLICY SETTINGS ##### +PASSWORD_MIN_LENGTH = ENV_TOKENS.get("PASSWORD_MIN_LENGTH") +PASSWORD_MAX_LENGTH = ENV_TOKENS.get("PASSWORD_MAX_LENGTH") +PASSWORD_COMPLEXITY = ENV_TOKENS.get("PASSWORD_COMPLEXITY", {}) +PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = ENV_TOKENS.get("PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD") +PASSWORD_DICTIONARY = ENV_TOKENS.get("PASSWORD_DICTIONARY", []) diff --git a/lms/envs/common.py b/lms/envs/common.py index 000b7c87a59e38615dfb8556b5cc44d937b562b2..24e6d26936a87ad3545640023b118506dd25e006 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -203,6 +203,9 @@ FEATURES = { # grades CSV files to S3 and give links for downloads. 'ENABLE_S3_GRADE_DOWNLOADS': False, + # whether to use password policy enforcement or not + 'ENFORCE_PASSWORD_POLICY': False, + # Give course staff unrestricted access to grade downloads (if set to False, # only edX superusers can perform the downloads) 'ALLOW_COURSE_STAFF_GRADE_DOWNLOADS': False, @@ -1188,6 +1191,14 @@ GRADES_DOWNLOAD = { 'ROOT_PATH': '/tmp/edx-s3/grades', } +#### PASSWORD POLICY SETTINGS ##### + +PASSWORD_MIN_LENGTH = None +PASSWORD_MAX_LENGTH = None +PASSWORD_COMPLEXITY = {} +PASSWORD_DICTIONARY_EDIT_DISTANCE_THRESHOLD = None +PASSWORD_DICTIONARY = [] + ##################### LinkedIn ##################### INSTALLED_APPS += ('django_openid_auth',)