Add some optional policies around password resets, such as password reuse, forced password resets, allowed frequency of password resets
This commit is contained in:
@@ -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', {})
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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']
|
||||
complete_apps = ['student']
|
||||
|
||||
@@ -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']
|
||||
@@ -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
|
||||
|
||||
205
common/djangoapps/student/tests/test_password_history.py
Normal file
205
common/djangoapps/student/tests/test_password_history.py
Normal file
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
158
common/djangoapps/student/tests/test_reset_password.py
Normal file
158
common/djangoapps/student/tests/test_reset_password.py
Normal file
@@ -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<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/', 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)
|
||||
@@ -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<uidb36>[0-9A-Za-z]+)-(?P<token>.+)/', 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"""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
354
lms/djangoapps/courseware/tests/test_password_history.py
Normal file
354
lms/djangoapps/courseware/tests/test_password_history.py
Normal file
@@ -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
|
||||
)
|
||||
@@ -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', {})
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -86,11 +86,15 @@
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div role="alert" class="status message submission-error">
|
||||
<div role="alert" class="status message submission-error" style="display: {% if err_msg %}block{% else %}None{% endif %}">
|
||||
<h3 class="message-title">{% trans "The following errors occurred while processing your registration: " %}</h3>
|
||||
<ul class="message-copy">
|
||||
<li>{% trans "You must complete all fields." %}</li>
|
||||
<li>{% trans "The two password fields didn't match." %}</li>
|
||||
{% if err_msg %}
|
||||
<li>{{err_msg}}</li>
|
||||
{% else %}
|
||||
<li>{% trans "You must complete all fields." %}</li>
|
||||
<li>{% trans "The two password fields didn't match." %}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user