Merge pull request #2312 from edx/cdodge/password-complexity-policy
Optional Password Policy enforcement
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
239
common/djangoapps/student/tests/test_password_policy.py
Normal file
239
common/djangoapps/student/tests/test_password_policy.py
Normal file
@@ -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'])
|
||||
@@ -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
|
||||
|
||||
92
common/djangoapps/util/password_policy_validators.py
Normal file
92
common/djangoapps/util/password_policy_validators.py
Normal file
@@ -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")
|
||||
@@ -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", [])
|
||||
|
||||
@@ -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',)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user