Commit 1ff4671b by chrisndodge

Merge pull request #2312 from edx/cdodge/password-complexity-policy

Optional Password Policy enforcement
parents 1c4d852e 0d49305c
......@@ -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
......
# -*- 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
......
# 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',)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment