diff --git a/cms/envs/aws.py b/cms/envs/aws.py index b2de92a738..67fc78e644 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -274,3 +274,6 @@ SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIME ##### X-Frame-Options response header settings ##### X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS) + +##### ADVANCED_SECURITY_CONFIG ##### +ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {}) diff --git a/cms/envs/common.py b/cms/envs/common.py index 434f534a27..16cc6107c5 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -96,6 +96,9 @@ FEATURES = { # Prevent concurrent logins per user 'PREVENT_CONCURRENT_LOGINS': False, + + # Turn off Advanced Security by default + 'ADVANCED_SECURITY': False, } ENABLE_JASMINE = False @@ -566,6 +569,7 @@ OPTIONAL_APPS = ( 'openassessment.xblock' ) + for app_name in OPTIONAL_APPS: # First attempt to only find the module rather than actually importing it, # to avoid circular references - only try to import if it can't be found @@ -578,3 +582,7 @@ for app_name in OPTIONAL_APPS: except ImportError: continue INSTALLED_APPS += (app_name,) + +### ADVANCED_SECURITY_CONFIG +# Empty by default +ADVANCED_SECURITY_CONFIG = {} diff --git a/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py b/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py index c39d2595be..70919c4198 100644 --- a/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py +++ b/common/djangoapps/student/migrations/0032_auto__add_loginfailures.py @@ -113,6 +113,8 @@ class Migration(SchemaMigration): }, 'student.userprofile': { 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), @@ -144,4 +146,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['student'] \ No newline at end of file + complete_apps = ['student'] diff --git a/common/djangoapps/student/migrations/0033_auto__add_passwordhistory.py b/common/djangoapps/student/migrations/0033_auto__add_passwordhistory.py new file mode 100644 index 0000000000..f46fd07894 --- /dev/null +++ b/common/djangoapps/student/migrations/0033_auto__add_passwordhistory.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'PasswordHistory' + db.create_table('student_passwordhistory', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('password', self.gf('django.db.models.fields.CharField')(max_length=128)), + ('time_set', self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now)), + )) + db.send_create_signal('student', ['PasswordHistory']) + + + def backwards(self, orm): + # Deleting model 'PasswordHistory' + db.delete_table('student_passwordhistory') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.anonymoususerid': { + 'Meta': {'object_name': 'AnonymousUserId'}, + 'anonymous_user_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", 'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.loginfailures': { + 'Meta': {'object_name': 'LoginFailures'}, + 'failure_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'lockout_until': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.passwordhistory': { + 'Meta': {'object_name': 'PasswordHistory'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'time_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'null': 'True', 'blank': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.userstanding': { + 'Meta': {'object_name': 'UserStanding'}, + 'account_status': ('django.db.models.fields.CharField', [], {'max_length': '31', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'standing_last_changed_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'standing'", 'unique': 'True', 'to': "orm['auth.User']"}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] \ No newline at end of file diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index c4fc45950d..dd7d2a2334 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -20,14 +20,15 @@ import uuid from collections import defaultdict from django.conf import settings +from django.utils import timezone from django.contrib.auth.models import User +from django.contrib.auth.hashers import make_password from django.contrib.auth.signals import user_logged_in, user_logged_out from django.db import models, IntegrityError from django.db.models import Count from django.db.models.signals import post_save from django.dispatch import receiver, Signal import django.dispatch -from django.forms import ModelForm, forms from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import ugettext_noop from django_countries import CountryField @@ -312,6 +313,187 @@ EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated' EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated' +class PasswordHistory(models.Model): + """ + This model will keep track of past passwords that a user has used + as well as providing contraints (e.g. can't reuse passwords) + """ + user = models.ForeignKey(User) + password = models.CharField(max_length=128) + time_set = models.DateTimeField(default=timezone.now) + + def create(self, user): + """ + This will copy over the current password, if any of the configuration has been turned on + """ + + if not (PasswordHistory.is_student_password_reuse_restricted() or + PasswordHistory.is_staff_password_reuse_restricted() or + PasswordHistory.is_password_reset_frequency_restricted() or + PasswordHistory.is_staff_forced_password_reset_enabled() or + PasswordHistory.is_student_forced_password_reset_enabled()): + + return + + self.user = user + self.password = user.password + self.save() + + @classmethod + def is_student_password_reuse_restricted(cls): + """ + Returns whether the configuration which limits password reuse has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE', 0 + ) > 0 + + @classmethod + def is_staff_password_reuse_restricted(cls): + """ + Returns whether the configuration which limits password reuse has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE', 0 + ) > 0 + + @classmethod + def is_password_reset_frequency_restricted(cls): + """ + Returns whether the configuration which limits the password reset frequency has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS', None + ) + + @classmethod + def is_staff_forced_password_reset_enabled(cls): + """ + Returns whether the configuration which forces password resets to occur has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS', None + ) + + @classmethod + def is_student_forced_password_reset_enabled(cls): + """ + Returns whether the configuration which forces password resets to occur has been turned on + """ + return settings.FEATURES['ADVANCED_SECURITY'] and \ + settings.ADVANCED_SECURITY_CONFIG.get( + 'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS', None + ) + + @classmethod + def should_user_reset_password_now(cls, user): + """ + Returns whether a password has 'expired' and should be reset. Note there are two different + expiry policies for staff and students + """ + if not settings.FEATURES['ADVANCED_SECURITY']: + return False + + days_before_password_reset = None + if user.is_staff: + if cls.is_staff_forced_password_reset_enabled(): + days_before_password_reset = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS'] + elif cls.is_student_forced_password_reset_enabled(): + days_before_password_reset = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS'] + + if days_before_password_reset: + history = PasswordHistory.objects.filter(user=user).order_by('-time_set') + time_last_reset = None + + if history: + # first element should be the last time we reset password + time_last_reset = history[0].time_set + else: + # no history, then let's take the date the user joined + time_last_reset = user.date_joined + + now = timezone.now() + + delta = now - time_last_reset + + return delta.days >= days_before_password_reset + + return False + + @classmethod + def is_password_reset_too_soon(cls, user): + """ + Verifies that the password is not getting reset too frequently + """ + if not cls.is_password_reset_frequency_restricted(): + return False + + history = PasswordHistory.objects.filter(user=user).order_by('-time_set') + + if not history: + return False + + now = timezone.now() + + delta = now - history[0].time_set + + return delta.days < settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] + + @classmethod + def is_allowable_password_reuse(cls, user, new_password): + """ + Verifies that the password adheres to the reuse policies + """ + if not settings.FEATURES['ADVANCED_SECURITY']: + return True + + min_diff_passwords_required = 0 + if user.is_staff: + if cls.is_staff_password_reuse_restricted(): + min_diff_passwords_required = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] + elif cls.is_student_password_reuse_restricted(): + min_diff_passwords_required = \ + settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] + + history = PasswordHistory.objects.filter(user=user).order_by('-time_set') + + reuse_distance = 0 + + for entry in history: + # did we reach the minimum amount of intermediate different passwords? + if reuse_distance >= min_diff_passwords_required: + return True + + # be sure to re-use the same salt + # NOTE, how the salt is serialized in the password field is dependent on the algorithm + # in pbkdf2_sha256 [LMS] it's the 3rd element, in sha1 [unit tests] it's the 2nd element + hash_elements = entry.password.split('$') + algorithm = hash_elements[0] + if algorithm == 'pbkdf2_sha256': + hashed_password = make_password(new_password, hash_elements[2]) + elif algorithm == 'sha1': + hashed_password = make_password(new_password, hash_elements[1]) + else: + # This means we got something unexpected. We don't want to throw an exception, but + # log as an error and basically allow any password reuse + AUDIT_LOG.error('Unknown password hashing algorithm "{0}" found in existing password hash, password reuse policy will not be enforced!!!'.format(algorithm)) + return True + + if entry.password != hashed_password: + reuse_distance += 1 + else: + return False + + return True + + class LoginFailures(models.Model): """ This model will keep track of failed login attempts diff --git a/common/djangoapps/student/tests/test_password_history.py b/common/djangoapps/student/tests/test_password_history.py new file mode 100644 index 0000000000..efa1e370ed --- /dev/null +++ b/common/djangoapps/student/tests/test_password_history.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +""" +This test file will verify proper password history enforcement +""" +from django.test import TestCase +from django.utils import timezone +from mock import patch +from student.tests.factories import UserFactory, AdminFactory + +from student.models import PasswordHistory +from freezegun import freeze_time +from datetime import timedelta + +from django.test.utils import override_settings + + +@patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True}) +class TestPasswordHistory(TestCase): + """ + All the tests that assert proper behavior regarding password history + """ + + def _change_password(self, user, password): + """ + Helper method to change password on user and record in the PasswordHistory + """ + user.set_password(password) + user.save() + history = PasswordHistory() + history.create(user) + + def _user_factory_with_history(self, is_staff=False, set_initial_history=True): + """ + Helper method to generate either an Admin or a User + """ + if is_staff: + user = AdminFactory() + else: + user = UserFactory() + + user.date_joined = timezone.now() + + if set_initial_history: + history = PasswordHistory() + history.create(user) + + return user + + @patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': False}) + def test_disabled_feature(self): + """ + Test that behavior is normal when this feature is not turned on + """ + user = UserFactory() + staff = AdminFactory() + + # if feature is disabled user can keep reusing same password + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "test")) + + self.assertFalse(PasswordHistory.should_user_reset_password_now(user)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE': 2}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE': 1}) + def test_accounts_password_reuse(self): + """ + Assert against the password reuse policy + """ + user = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + + # students need to user at least one different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(user, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "different")) + self._change_password(user, "different") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "test")) + + # staff needs to use at least two different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self._change_password(staff, "different") + + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "third")) + self._change_password(staff, "third") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "test")) + + @override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.PBKDF2PasswordHasher')) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE': 2}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE': 1}) + def test_pbkdf2_sha256_password_reuse(self): + """ + Assert against the password reuse policy but using the normal Django PBKDF2 + """ + user = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + + # students need to user at least one different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(user, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "different")) + self._change_password(user, "different") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(user, "test")) + + # staff needs to use at least two different passwords before reuse + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self._change_password(staff, "different") + + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "test")) + self.assertFalse(PasswordHistory.is_allowable_password_reuse(staff, "different")) + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "third")) + self._change_password(staff, "third") + + self.assertTrue(PasswordHistory.is_allowable_password_reuse(staff, "test")) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': 1}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': 5}) + def test_forced_password_change(self): + """ + Assert when passwords must be reset + """ + student = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + grandfathered_student = self._user_factory_with_history(set_initial_history=False) + + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + + staff_reset_time = timezone.now() + timedelta(days=1) + with freeze_time(staff_reset_time): + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + self.assertTrue(PasswordHistory.should_user_reset_password_now(staff)) + + self._change_password(staff, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + student_reset_time = timezone.now() + timedelta(days=5) + + with freeze_time(student_reset_time): + self.assertTrue(PasswordHistory.should_user_reset_password_now(student)) + self.assertTrue(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + self.assertTrue(PasswordHistory.should_user_reset_password_now(staff)) + + self._change_password(student, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + + self._change_password(grandfathered_student, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + + self._change_password(staff, 'Different') + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': None}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': None}) + def test_no_forced_password_change(self): + """ + Assert that if we skip configuration, then user will never have to force reset password + """ + student = self._user_factory_with_history() + staff = self._user_factory_with_history(is_staff=True) + + # also create a user who doesn't have any history + grandfathered_student = UserFactory() + grandfathered_student.date_joined = timezone.now() + + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + + staff_reset_time = timezone.now() + timedelta(days=100) + with freeze_time(staff_reset_time): + self.assertFalse(PasswordHistory.should_user_reset_password_now(student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(grandfathered_student)) + self.assertFalse(PasswordHistory.should_user_reset_password_now(staff)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS': 1}) + def test_too_frequent_password_resets(self): + """ + Assert that a user should not be able to password reset too frequently + """ + student = self._user_factory_with_history() + grandfathered_student = self._user_factory_with_history(set_initial_history=False) + + self.assertTrue(PasswordHistory.is_password_reset_too_soon(student)) + self.assertFalse(PasswordHistory.is_password_reset_too_soon(grandfathered_student)) + + staff_reset_time = timezone.now() + timedelta(days=100) + with freeze_time(staff_reset_time): + self.assertFalse(PasswordHistory.is_password_reset_too_soon(student)) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS': None}) + def test_disabled_too_frequent_password_resets(self): + """ + Verify properly default behavior when feature is disabled + """ + student = self._user_factory_with_history() + + self.assertFalse(PasswordHistory.is_password_reset_too_soon(student)) diff --git a/common/djangoapps/student/tests/test_password_policy.py b/common/djangoapps/student/tests/test_password_policy.py index 647288ad0f..a649c53544 100644 --- a/common/djangoapps/student/tests/test_password_policy.py +++ b/common/djangoapps/student/tests/test_password_policy.py @@ -87,7 +87,7 @@ class TestPasswordPolicy(TestCase): ) @patch.dict("django.conf.settings.PASSWORD_COMPLEXITY", {'LOWER': 3}) - def test_password_not_enough_lowercase(self): + def test_password_enough_lowercase(self): self.url_params['password'] = 'ThisShouldPass' response = self.client.post(self.url, self.url_params) self.assertEqual(response.status_code, 200) diff --git a/common/djangoapps/student/tests/test_reset_password.py b/common/djangoapps/student/tests/test_reset_password.py new file mode 100644 index 0000000000..d0f35bc9c7 --- /dev/null +++ b/common/djangoapps/student/tests/test_reset_password.py @@ -0,0 +1,158 @@ +""" +Test the various password reset flows +""" +import json +import re +import unittest + +from django.core.cache import cache +from django.conf import settings +from django.test import TestCase +from django.test.client import RequestFactory +from django.contrib.auth.models import User +from django.contrib.auth.hashers import UNUSABLE_PASSWORD +from django.contrib.auth.tokens import default_token_generator +from django.utils.http import int_to_base36 + +from mock import Mock, patch +from textwrap import dedent + +from student.views import password_reset, password_reset_confirm_wrapper +from student.tests.factories import UserFactory +from student.tests.test_email import mock_render_to_string + + +class ResetPasswordTests(TestCase): + """ Tests that clicking reset password sends email, and doesn't activate the user + """ + request_factory = RequestFactory() + + def setUp(self): + self.user = UserFactory.create() + self.user.is_active = False + self.user.save() + self.token = default_token_generator.make_token(self.user) + self.uidb36 = int_to_base36(self.user.id) + + self.user_bad_passwd = UserFactory.create() + self.user_bad_passwd.is_active = False + self.user_bad_passwd.password = UNUSABLE_PASSWORD + self.user_bad_passwd.save() + + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_user_bad_password_reset(self): + """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" + + bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) + bad_pwd_resp = password_reset(bad_pwd_req) + # If they've got an unusable password, we return a successful response code + self.assertEquals(bad_pwd_resp.status_code, 200) + obj = json.loads(bad_pwd_resp.content) + self.assertEquals(obj, { + 'success': True, + 'value': "('registration/password_reset_done.html', [])", + }) + + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_nonexist_email_password_reset(self): + """Now test the exception cases with of reset_password called with invalid email.""" + + bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email + "makeItFail"}) + bad_email_resp = password_reset(bad_email_req) + # Note: even if the email is bad, we return a successful response code + # This prevents someone potentially trying to "brute-force" find out which + # emails are and aren't registered with edX + self.assertEquals(bad_email_resp.status_code, 200) + obj = json.loads(bad_email_resp.content) + self.assertEquals(obj, { + 'success': True, + 'value': "('registration/password_reset_done.html', [])", + }) + + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_password_reset_ratelimited(self): + """ Try (and fail) resetting password 30 times in a row on an non-existant email address """ + cache.clear() + + for i in xrange(30): + good_req = self.request_factory.post('/password_reset/', { + 'email': 'thisdoesnotexist{0}@foo.com'.format(i) + }) + good_resp = password_reset(good_req) + self.assertEquals(good_resp.status_code, 200) + + # then the rate limiter should kick in and give a HttpForbidden response + bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) + bad_resp = password_reset(bad_req) + self.assertEquals(bad_resp.status_code, 403) + + cache.clear() + + @unittest.skipIf( + settings.FEATURES.get('DISABLE_RESET_EMAIL_TEST', False), + dedent(""" + Skipping Test because CMS has not provided necessary templates for password reset. + If LMS tests print this message, that needs to be fixed. + """) + ) + @patch('django.core.mail.send_mail') + @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) + def test_reset_password_email(self, send_email): + """Tests contents of reset password email, and that user is not active""" + + good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) + good_resp = password_reset(good_req) + self.assertEquals(good_resp.status_code, 200) + obj = json.loads(good_resp.content) + self.assertEquals(obj, { + 'success': True, + 'value': "('registration/password_reset_done.html', [])", + }) + + (subject, msg, from_addr, to_addrs) = send_email.call_args[0] + self.assertIn("Password reset", subject) + self.assertIn("You're receiving this e-mail because you requested a password reset", msg) + self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL) + self.assertEquals(len(to_addrs), 1) + self.assertIn(self.user.email, to_addrs) + + #test that the user is not active + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() + + @patch('student.views.password_reset_confirm') + def test_reset_password_bad_token(self, reset_confirm): + """Tests bad token and uidb36 in password reset""" + + bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/') + password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') + confirm_kwargs = reset_confirm.call_args[1] + self.assertEquals(confirm_kwargs['uidb36'], 'NO') + self.assertEquals(confirm_kwargs['token'], 'OP') + self.user = User.objects.get(pk=self.user.pk) + self.assertFalse(self.user.is_active) + + @patch('student.views.password_reset_confirm') + def test_reset_password_good_token(self, reset_confirm): + """Tests good token and uidb36 in password reset""" + + good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) + password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) + confirm_kwargs = reset_confirm.call_args[1] + self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) + self.assertEquals(confirm_kwargs['token'], self.token) + self.user = User.objects.get(pk=self.user.pk) + self.assertTrue(self.user.is_active) + + @patch('student.views.password_reset_confirm') + def test_reset_password_with_reused_password(self, reset_confirm): + """Tests good token and uidb36 in password reset""" + + good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) + password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) + confirm_kwargs = reset_confirm.call_args[1] + self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) + self.assertEquals(confirm_kwargs['token'], self.token) + self.user = User.objects.get(pk=self.user.pk) + self.assertTrue(self.user.is_active) diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index f7035db51c..48d8bb642e 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -5,21 +5,15 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ import logging -import json -import re import unittest from datetime import datetime, timedelta import pytz -from django.core.cache import cache from django.conf import settings from django.test import TestCase from django.test.utils import override_settings from django.test.client import RequestFactory from django.contrib.auth.models import User, AnonymousUser -from django.contrib.auth.hashers import UNUSABLE_PASSWORD -from django.contrib.auth.tokens import default_token_generator -from django.utils.http import int_to_base36 from django.core.urlresolvers import reverse from django.http import HttpResponse @@ -28,13 +22,11 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE from mock import Mock, patch, sentinel -from textwrap import dedent from student.models import anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, unique_id_for_user -from student.views import (process_survey_link, _cert_info, password_reset, password_reset_confirm_wrapper, - change_enrollment, complete_course_mode_info, token, course_from_id) +from student.views import (process_survey_link, _cert_info, + change_enrollment, complete_course_mode_info, token) from student.tests.factories import UserFactory, CourseModeFactory -from student.tests.test_email import mock_render_to_string import shoppingcart @@ -44,127 +36,6 @@ COURSE_2 = 'edx/full/6.002_Spring_2012' log = logging.getLogger(__name__) -class ResetPasswordTests(TestCase): - """ Tests that clicking reset password sends email, and doesn't activate the user - """ - request_factory = RequestFactory() - - def setUp(self): - self.user = UserFactory.create() - self.user.is_active = False - self.user.save() - self.token = default_token_generator.make_token(self.user) - self.uidb36 = int_to_base36(self.user.id) - - self.user_bad_passwd = UserFactory.create() - self.user_bad_passwd.is_active = False - self.user_bad_passwd.password = UNUSABLE_PASSWORD - self.user_bad_passwd.save() - - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_user_bad_password_reset(self): - """Tests password reset behavior for user with password marked UNUSABLE_PASSWORD""" - - bad_pwd_req = self.request_factory.post('/password_reset/', {'email': self.user_bad_passwd.email}) - bad_pwd_resp = password_reset(bad_pwd_req) - # If they've got an unusable password, we return a successful response code - self.assertEquals(bad_pwd_resp.status_code, 200) - obj = json.loads(bad_pwd_resp.content) - self.assertEquals(obj, { - 'success': True, - 'value': "('registration/password_reset_done.html', [])", - }) - - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_nonexist_email_password_reset(self): - """Now test the exception cases with of reset_password called with invalid email.""" - - bad_email_req = self.request_factory.post('/password_reset/', {'email': self.user.email+"makeItFail"}) - bad_email_resp = password_reset(bad_email_req) - # Note: even if the email is bad, we return a successful response code - # This prevents someone potentially trying to "brute-force" find out which emails are and aren't registered with edX - self.assertEquals(bad_email_resp.status_code, 200) - obj = json.loads(bad_email_resp.content) - self.assertEquals(obj, { - 'success': True, - 'value': "('registration/password_reset_done.html', [])", - }) - - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_password_reset_ratelimited(self): - """ Try (and fail) resetting password 30 times in a row on an non-existant email address """ - cache.clear() - - for i in xrange(30): - good_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) - good_resp = password_reset(good_req) - self.assertEquals(good_resp.status_code, 200) - - # then the rate limiter should kick in and give a HttpForbidden response - bad_req = self.request_factory.post('/password_reset/', {'email': 'thisdoesnotexist@foo.com'}) - bad_resp = password_reset(bad_req) - self.assertEquals(bad_resp.status_code, 403) - - cache.clear() - - @unittest.skipIf( - settings.FEATURES.get('DISABLE_RESET_EMAIL_TEST', False), - dedent(""" - Skipping Test because CMS has not provided necessary templates for password reset. - If LMS tests print this message, that needs to be fixed. - """) - ) - @patch('django.core.mail.send_mail') - @patch('student.views.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) - def test_reset_password_email(self, send_email): - """Tests contents of reset password email, and that user is not active""" - - good_req = self.request_factory.post('/password_reset/', {'email': self.user.email}) - good_resp = password_reset(good_req) - self.assertEquals(good_resp.status_code, 200) - obj = json.loads(good_resp.content) - self.assertEquals(obj, { - 'success': True, - 'value': "('registration/password_reset_done.html', [])", - }) - - ((subject, msg, from_addr, to_addrs), sm_kwargs) = send_email.call_args - self.assertIn("Password reset", subject) - self.assertIn("You're receiving this e-mail because you requested a password reset", msg) - self.assertEquals(from_addr, settings.DEFAULT_FROM_EMAIL) - self.assertEquals(len(to_addrs), 1) - self.assertIn(self.user.email, to_addrs) - - #test that the user is not active - self.user = User.objects.get(pk=self.user.pk) - self.assertFalse(self.user.is_active) - reset_match = re.search(r'password_reset_confirm/(?P[0-9A-Za-z]+)-(?P.+)/', msg).groupdict() - - @patch('student.views.password_reset_confirm') - def test_reset_password_bad_token(self, reset_confirm): - """Tests bad token and uidb36 in password reset""" - - bad_reset_req = self.request_factory.get('/password_reset_confirm/NO-OP/') - password_reset_confirm_wrapper(bad_reset_req, 'NO', 'OP') - (confirm_args, confirm_kwargs) = reset_confirm.call_args - self.assertEquals(confirm_kwargs['uidb36'], 'NO') - self.assertEquals(confirm_kwargs['token'], 'OP') - self.user = User.objects.get(pk=self.user.pk) - self.assertFalse(self.user.is_active) - - @patch('student.views.password_reset_confirm') - def test_reset_password_good_token(self, reset_confirm): - """Tests good token and uidb36 in password reset""" - - good_reset_req = self.request_factory.get('/password_reset_confirm/{0}-{1}/'.format(self.uidb36, self.token)) - password_reset_confirm_wrapper(good_reset_req, self.uidb36, self.token) - (confirm_args, confirm_kwargs) = reset_confirm.call_args - self.assertEquals(confirm_kwargs['uidb36'], self.uidb36) - self.assertEquals(confirm_kwargs['token'], self.token) - self.user = User.objects.get(pk=self.user.pk) - self.assertTrue(self.user.is_active) - - class CourseEndingTest(TestCase): """Test things related to course endings: certificates, surveys, etc""" diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 54f3e2bfb3..d41115a8b8 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -30,6 +30,8 @@ from django.utils.http import cookie_date, base36_to_int from django.utils.translation import ugettext as _, get_language from django.views.decorators.http import require_POST, require_GET +from django.template.response import TemplateResponse + from ratelimitbackend.exceptions import RateLimitException from edxmako.shortcuts import render_to_response, render_to_string @@ -39,7 +41,7 @@ from student.models import ( Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment, unique_id_for_user, CourseEnrollmentAllowed, UserStanding, LoginFailures, - create_comments_service_user + create_comments_service_user, PasswordHistory ) from student.forms import PasswordResetFormNoActive from student.firebase_token_generator import create_token @@ -747,6 +749,15 @@ def login_user(request, error=""): "value": _('This account has been temporarily locked due to excessive login failures. Try again later.'), }) # TODO: this should be status code 429 # pylint: disable=fixme + # see if the user must reset his/her password due to any policy settings + if PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup): + return JsonResponse({ + "success": False, + "value": _('Your password has expired due to password policy on this account. You must ' + 'reset your password before you can log in again. Please click the ' + 'Forgot Password" link on this page to reset your password before logging in again.'), + }) # TODO: this should be status code 403 # pylint: disable=fixme + # if the user doesn't exist, we want to set the username to an invalid # username so that authentication is guaranteed to fail and we can take # advantage of the ratelimited backend @@ -971,6 +982,7 @@ def _do_create_account(post_vars): is_active=False) user.set_password(post_vars['password']) registration = Registration() + # TODO: Rearrange so that if part of the process fails, the whole process fails. # Right now, we can have e.g. no registration e-mail sent out and a zombie account try: @@ -990,6 +1002,11 @@ def _do_create_account(post_vars): else: raise + # add this account creation to password history + # NOTE, this will be a NOP unless the feature has been turned on in configuration + password_history_entry = PasswordHistory() + password_history_entry.create(user) + registration.register(user) profile = UserProfile(user=user) @@ -1419,12 +1436,71 @@ def password_reset_confirm_wrapper( user.save() except (ValueError, User.DoesNotExist): pass - # we also want to pass settings.PLATFORM_NAME in as extra_context - extra_context = {"platform_name": settings.PLATFORM_NAME} - return password_reset_confirm( - request, uidb36=uidb36, token=token, extra_context=extra_context - ) + # tie in password strength enforcement as an optional level of + # security protection + err_msg = None + + if request.method == 'POST': + password = request.POST['new_password1'] + if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): + try: + validate_password_length(password) + validate_password_complexity(password) + validate_password_dictionary(password) + except ValidationError, err: + err_msg = _('Password: ') + '; '.join(err.messages) + + # also, check the password reuse policy + if not PasswordHistory.is_allowable_password_reuse(user, password): + if user.is_staff: + num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] + else: + num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] + err_msg = _("You are re-using a password that you have used recently. You must " + "have {0} distinct password(s) before reusing a previous password.").format(num_distinct) + + # also, check to see if passwords are getting reset too frequent + if PasswordHistory.is_password_reset_too_soon(user): + num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] + err_msg = _("You are resetting passwords too frequently. Due to security policies, " + "{0} day(s) must elapse between password resets").format(num_days) + + if err_msg: + # We have an password reset attempt which violates some security policy, use the + # existing Django template to communicate this back to the user + context = { + 'validlink': True, + 'form': None, + 'title': _('Password reset unsuccessful'), + 'err_msg': err_msg, + } + return TemplateResponse(request, 'registration/password_reset_confirm.html', context) + else: + # we also want to pass settings.PLATFORM_NAME in as extra_context + extra_context = {"platform_name": settings.PLATFORM_NAME} + + if request.method == 'POST': + # remember what the old password hash is before we call down + old_password_hash = user.password + + result = password_reset_confirm( + request, uidb36=uidb36, token=token, extra_context=extra_context + ) + + # get the updated user + updated_user = User.objects.get(id=uid_int) + + # did the password hash change, if so record it in the PasswordHistory + if updated_user.password != old_password_hash: + entry = PasswordHistory() + entry.create(updated_user) + + return result + else: + return password_reset_confirm( + request, uidb36=uidb36, token=token, extra_context=extra_context + ) def reactivation_email_for_user(user): diff --git a/lms/djangoapps/courseware/tests/test_password_history.py b/lms/djangoapps/courseware/tests/test_password_history.py new file mode 100644 index 0000000000..de9befecd1 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_password_history.py @@ -0,0 +1,354 @@ +""" +This file will test through the LMS some of the PasswordHistory features +""" +import json +from mock import patch +from uuid import uuid4 + +from django.contrib.auth.models import User +from django.utils import timezone +from datetime import timedelta +from django.test.utils import override_settings + +from django.core.urlresolvers import reverse +from django.contrib.auth.tokens import default_token_generator +from django.utils.http import int_to_base36 + +from freezegun import freeze_time + +from student.models import PasswordHistory +from courseware.tests.helpers import LoginEnrollmentTestCase + + +@patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True}) +class TestPasswordHistory(LoginEnrollmentTestCase): + """ + Go through some of the PasswordHistory use cases + """ + + def _login(self, email, password, should_succeed=True, err_msg_check=None): + """ + Override the base implementation so we can do appropriate asserts + """ + resp = self.client.post(reverse('login'), {'email': email, 'password': password}) + data = json.loads(resp.content) + + self.assertEqual(resp.status_code, 200) + if should_succeed: + self.assertTrue(data['success']) + else: + self.assertFalse(data['success']) + if err_msg_check: + self.assertIn(err_msg_check, data['value']) + + def _setup_user(self, is_staff=False, password=None): + """ + Override the base implementation to randomize the email + """ + email = 'foo_{0}@test.com'.format(uuid4().hex[:8]) + password = password if password else 'foo' + username = 'test_{0}'.format(uuid4().hex[:8]) + self.create_account(username, email, password) + self.activate_user(email) + + # manually twiddle the is_staff bit, if needed + if is_staff: + user = User.objects.get(email=email) + user.is_staff = True + user.save() + + return email, password + + def _update_password(self, email, new_password): + """ + Helper method to reset a password + """ + user = User.objects.get(email=email) + user.set_password(new_password) + user.save() + history = PasswordHistory() + history.create(user) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': None}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': None}) + def test_no_forced_password_change(self): + """ + Makes sure default behavior is correct when we don't have this turned on + """ + + email, password = self._setup_user() + self._login(email, password) + + email, password = self._setup_user(is_staff=True) + self._login(email, password) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS': 1}) + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': 5}) + def test_forced_password_change(self): + """ + Make sure password are viewed as expired in LMS after the policy time has elapsed + """ + + student_email, student_password = self._setup_user() + staff_email, staff_password = self._setup_user(is_staff=True) + + self._login(student_email, student_password) + self._login(staff_email, staff_password) + + staff_reset_time = timezone.now() + timedelta(days=1) + with freeze_time(staff_reset_time): + self._login(student_email, student_password) + + # staff should fail because password expired + self._login(staff_email, staff_password, should_succeed=False, + err_msg_check="Your password has expired due to password policy on this account") + + # if we reset the password, we should be able to log in + self._update_password(staff_email, "updated") + self._login(staff_email, "updated") + + student_reset_time = timezone.now() + timedelta(days=5) + with freeze_time(student_reset_time): + # Both staff and student logins should fail because user must + # reset the password + + self._login(student_email, student_password, should_succeed=False, + err_msg_check="Your password has expired due to password policy on this account") + self._update_password(student_email, "updated") + self._login(student_email, "updated") + + self._login(staff_email, staff_password, should_succeed=False, + err_msg_check="Your password has expired due to password policy on this account") + self._update_password(staff_email, "updated2") + self._login(staff_email, "updated2") + + def test_allow_all_password_reuse(self): + """ + Tests that password_reset flows work as expected if reuse config is missing, meaning + passwords can always be reused + """ + student_email, _ = self._setup_user() + user = User.objects.get(email=student_email) + + err_msg = 'You are re-using a password that you have used recently.' + + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo' + }, follow=True) + + self.assertNotIn( + err_msg, + resp.content + ) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE': 1}) + def test_student_password_reset_reuse(self): + """ + Goes through the password reset flows to make sure the various password reuse policies are enforced + """ + student_email, _ = self._setup_user() + user = User.objects.get(email=student_email) + + err_msg = 'You are re-using a password that you have used recently. You must have 1 distinct password(s)' + success_msg = 'Your Password Reset is Complete' + + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo' + }, follow=True) + + self.assertIn( + err_msg, + resp.content + ) + + # now retry with a different password + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'bar', + 'new_password2': 'bar' + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE': 2}) + def test_staff_password_reset_reuse(self): + """ + Goes through the password reset flows to make sure the various password reuse policies are enforced + """ + staff_email, _ = self._setup_user(is_staff=True) + user = User.objects.get(email=staff_email) + + err_msg = 'You are re-using a password that you have used recently. You must have 2 distinct password(s)' + success_msg = 'Your Password Reset is Complete' + + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertIn( + err_msg, + resp.content + ) + + # now use different one + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'bar', + 'new_password2': 'bar', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + # now try again with the first one + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + # should be rejected + self.assertIn( + err_msg, + resp.content + ) + + # now use different one + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'baz', + 'new_password2': 'baz', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + # now we should be able to reuse the first one + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + @patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS': 1}) + def test_password_reset_frequency_limit(self): + """ + Asserts the frequency limit on how often we can change passwords + """ + staff_email, _ = self._setup_user(is_staff=True) + + success_msg = 'Your Password Reset is Complete' + + # try to reset password, it should fail + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertNotIn( + success_msg, + resp.content + ) + + # pretend we're in the future + staff_reset_time = timezone.now() + timedelta(days=1) + with freeze_time(staff_reset_time): + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) + + @patch.dict("django.conf.settings.FEATURES", {'ENFORCE_PASSWORD_POLICY': True}) + @override_settings(PASSWORD_MIN_LENGTH=6) + def test_password_policy_on_password_reset(self): + """ + This makes sure the proper asserts on password policy also works on password reset + """ + staff_email, _ = self._setup_user(is_staff=True, password='foofoo') + + success_msg = 'Your Password Reset is Complete' + + # try to reset password, it should fail + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foo', + 'new_password2': 'foo', + }, follow=True) + + self.assertNotIn( + success_msg, + resp.content + ) + + # try to reset password with a long enough password + user = User.objects.get(email=staff_email) + token = default_token_generator.make_token(user) + uidb36 = int_to_base36(user.id) + + # try to do a password reset with the same password as before + resp = self.client.post('/password_reset_confirm/{0}-{1}/'.format(uidb36, token), { + 'new_password1': 'foofoo', + 'new_password2': 'foofoo', + }, follow=True) + + self.assertIn( + success_msg, + resp.content + ) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 3e38521c9e..4593269314 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -391,5 +391,9 @@ TIME_ZONE_DISPLAYED_FOR_DEADLINES = ENV_TOKENS.get("TIME_ZONE_DISPLAYED_FOR_DEAD ##### X-Frame-Options response header settings ##### X_FRAME_OPTIONS = ENV_TOKENS.get('X_FRAME_OPTIONS', X_FRAME_OPTIONS) + ##### Third-party auth options ################################################ THIRD_PARTY_AUTH = AUTH_TOKENS.get('THIRD_PARTY_AUTH', THIRD_PARTY_AUTH) + +##### ADVANCED_SECURITY_CONFIG ##### +ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {}) diff --git a/lms/envs/common.py b/lms/envs/common.py index 2bfd891464..f271fda5f5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -240,6 +240,9 @@ FEATURES = { # Prevent concurrent logins per user 'PREVENT_CONCURRENT_LOGINS': False, + + # Turn off Advanced Security by default + 'ADVANCED_SECURITY': False, } # Used for A/B testing @@ -1517,3 +1520,7 @@ for app_name in OPTIONAL_APPS: # Stub for third_party_auth options. # See common/djangoapps/third_party_auth/settings.py for configuration details. THIRD_PARTY_AUTH = {} + +### ADVANCED_SECURITY_CONFIG +# Empty by default +ADVANCED_SECURITY_CONFIG = {} diff --git a/lms/templates/registration/password_reset_confirm.html b/lms/templates/registration/password_reset_confirm.html index 4a2188bdb0..0b1b7570e7 100644 --- a/lms/templates/registration/password_reset_confirm.html +++ b/lms/templates/registration/password_reset_confirm.html @@ -86,11 +86,15 @@ -