Merge pull request #19423 from edx/mikix/password-history-removal

Remove PasswordHistory
This commit is contained in:
Michael Terry
2018-12-19 09:22:39 -05:00
committed by GitHub
20 changed files with 121 additions and 712 deletions

View File

@@ -455,9 +455,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', {})
################ ADVANCED COMPONENT/PROBLEM TYPES ###############
ADVANCED_PROBLEM_TYPES = ENV_TOKENS.get('ADVANCED_PROBLEM_TYPES', ADVANCED_PROBLEM_TYPES)

View File

@@ -223,9 +223,6 @@ FEATURES = {
# Prevent concurrent logins per user
'PREVENT_CONCURRENT_LOGINS': False,
# Turn off Advanced Security by default
'ADVANCED_SECURITY': False,
# Turn off Video Upload Pipeline through Studio, by default
'ENABLE_VIDEO_UPLOAD_PIPELINE': False,
@@ -1317,10 +1314,6 @@ for app_name, insert_before in OPTIONAL_APPS:
INSTALLED_APPS.append(app_name)
### ADVANCED_SECURITY_CONFIG
# Empty by default
ADVANCED_SECURITY_CONFIG = {}
### External auth usage -- prefixes for ENROLLMENT_DOMAIN
SHIBBOLETH_DOMAIN_PREFIX = 'shib:'
OPENID_DOMAIN_PREFIX = 'openid:'

View File

@@ -458,9 +458,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', {})
################ ADVANCED COMPONENT/PROBLEM TYPES ###############
ADVANCED_PROBLEM_TYPES = ENV_TOKENS.get('ADVANCED_PROBLEM_TYPES', ADVANCED_PROBLEM_TYPES)

View File

@@ -39,7 +39,6 @@ from openedx.core.djangoapps.theming.helpers import get_themes
from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect
from student.models import (
LinkedInAddToProfileConfiguration,
PasswordHistory,
Registration,
UserAttribute,
UserProfile,
@@ -645,11 +644,6 @@ def do_create_account(form, custom_form=None):
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_fields = [

View File

@@ -792,204 +792,12 @@ EVENT_NAME_ENROLLMENT_MODE_CHANGED = 'edx.course.enrollment.mode_changed'
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)
This model is deprecated, no longer used, and slated for removal.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE)
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
"""
if not settings.FEATURES['ADVANCED_SECURITY']:
return False
min_diff_pw = settings.ADVANCED_SECURITY_CONFIG.get(
'MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE', 0
)
return min_diff_pw > 0
@classmethod
def is_staff_password_reuse_restricted(cls):
"""
Returns whether the configuration which limits password reuse has been turned on
"""
if not settings.FEATURES['ADVANCED_SECURITY']:
return False
min_diff_pw = settings.ADVANCED_SECURITY_CONFIG.get(
'MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE', 0
)
return min_diff_pw > 0
@classmethod
def is_password_reset_frequency_restricted(cls):
"""
Returns whether the configuration which limits the password reset frequency has been turned on
"""
if not settings.FEATURES['ADVANCED_SECURITY']:
return False
min_days_between_reset = settings.ADVANCED_SECURITY_CONFIG.get(
'MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'
)
return min_days_between_reset
@classmethod
def is_staff_forced_password_reset_enabled(cls):
"""
Returns whether the configuration which forces password resets to occur has been turned on
"""
if not settings.FEATURES['ADVANCED_SECURITY']:
return False
min_days_between_reset = settings.ADVANCED_SECURITY_CONFIG.get(
'MIN_DAYS_FOR_STAFF_ACCOUNTS_PASSWORD_RESETS'
)
return min_days_between_reset
@classmethod
def is_student_forced_password_reset_enabled(cls):
"""
Returns whether the configuration which forces password resets to occur has been turned on
"""
if not settings.FEATURES['ADVANCED_SECURITY']:
return False
min_days_pw_reset = settings.ADVANCED_SECURITY_CONFIG.get(
'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS'
)
return min_days_pw_reset
@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
"""
assert user
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
"""
assert user
if not settings.FEATURES['ADVANCED_SECURITY']:
return True
if user.is_staff and 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']
else:
min_diff_passwords_required = 0
# just limit the result set to the number of different
# password we need
history = PasswordHistory.objects.filter(user=user).order_by('-time_set')[:min_diff_passwords_required]
for entry in history:
# 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:
return False
return True
@classmethod
def retire_user(cls, user_id):
"""
Updates the password in all rows corresponding to a user
to an empty string as part of removing PII for user retirement.
"""
return cls.objects.filter(user_id=user_id).update(password="")
class LoginFailures(models.Model):
"""

View File

@@ -130,7 +130,7 @@ class TestUserEvents(UserSettingsEventTestMixin, TestCase):
"""
Verify that we don't emit events for related fields.
"""
self.user.passwordhistory_set.create(password='new_password')
self.user.loginfailures_set.create()
self.user.save()
self.assert_no_events_were_emitted()

View File

@@ -1,228 +0,0 @@
# -*- coding: utf-8 -*-
"""
This test file will verify proper password history enforcement
"""
from datetime import timedelta
from django.test import TestCase
from django.test.utils import override_settings
from django.utils import timezone
from freezegun import freeze_time
from mock import patch
from student.models import PasswordHistory
from student.tests.factories import AdminFactory, UserFactory
@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))
# We need some policy in place to create a history. It doesn't matter what it is.
@patch.dict("django.conf.settings.ADVANCED_SECURITY_CONFIG", {'MIN_DAYS_FOR_STUDENT_ACCOUNTS_PASSWORD_RESETS': 5})
def test_retirement(self):
"""
Verify that the user's password history contains no actual
passwords after retirement is called.
"""
user = self._user_factory_with_history()
# create multiple rows in the password history table
self._change_password(user, "different")
self._change_password(user, "differentagain")
# ensure the rows were actually created and stored the passwords
self.assertTrue(PasswordHistory.objects.filter(user_id=user.id).exists())
for row in PasswordHistory.objects.filter(user_id=user.id):
self.assertFalse(row.password == "")
# retire the user and ensure that the rows are still present, but with no passwords
PasswordHistory.retire_user(user.id)
self.assertTrue(PasswordHistory.objects.filter(user_id=user.id).exists())
for row in PasswordHistory.objects.filter(user_id=user.id):
self.assertEqual(row.password, "")

View File

@@ -79,7 +79,6 @@ from student.message_types import EmailChange, PasswordReset
from student.models import (
AccountRecovery,
CourseEnrollment,
PasswordHistory,
PendingEmailChange,
Registration,
RegistrationCookieConfiguration,
@@ -935,11 +934,6 @@ def password_reset_confirm_wrapper(request, uidb36=None, token=None):
# 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)
else:
response = password_reset_confirm(
request, uidb64=uidb64, token=token, extra_context=platform_name

View File

@@ -1,227 +0,0 @@
"""
This file will test through the LMS some of the PasswordHistory features
"""
import json
from datetime import timedelta
from uuid import uuid4
import ddt
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.urls import reverse
from django.test.utils import override_settings
from django.utils import timezone
from django.utils.http import int_to_base36
from freezegun import freeze_time
from mock import patch
from courseware.tests.helpers import LoginEnrollmentTestCase
from student.models import PasswordHistory
from util.password_policy_validators import create_validator_config
@patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True})
@ddt.ddt
class TestPasswordHistory(LoginEnrollmentTestCase):
"""
Go through some of the PasswordHistory use cases
"""
shard = 1
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)
def assertPasswordResetError(self, response, error_message, valid_link=True):
"""
This method is a custom assertion that verifies that a password reset
view returns an error response as expected.
Args:
response: response from calling a password reset endpoint
error_message: message we expect to see in the response
valid_link: if the current password reset link is still valid
"""
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context_data['validlink'], valid_link)
self.assertIn(error_message, response.content)
@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
)
@override_settings(AUTH_PASSWORD_VALIDATORS=[
create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'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)
@ddt.data(
('foo', 'foobar', 'Error in resetting your password. Please try again.'),
('', '', 'This password is too short. It must contain at least'),
)
@ddt.unpack
def test_password_reset_form_invalid(self, password1, password2, err_msg):
"""
Tests that password reset fail when providing bad passwords and error message is displayed
to the user.
"""
user_email, _ = self._setup_user()
# try to reset password, it should fail
user = User.objects.get(email=user_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': password1,
'new_password2': password2,
}, follow=True)
self.assertPasswordResetError(resp, err_msg)

View File

@@ -0,0 +1,117 @@
"""
This file will test through the LMS some of the password reset features
"""
from uuid import uuid4
import ddt
from django.contrib.auth.models import User
from django.contrib.auth.tokens import default_token_generator
from django.test.utils import override_settings
from django.utils.http import int_to_base36
from courseware.tests.helpers import LoginEnrollmentTestCase
from util.password_policy_validators import create_validator_config
@ddt.ddt
class TestPasswordReset(LoginEnrollmentTestCase):
"""
Go through some of the password reset use cases
"""
shard = 1
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 assertPasswordResetError(self, response, error_message, valid_link=True):
"""
This method is a custom assertion that verifies that a password reset
view returns an error response as expected.
Args:
response: response from calling a password reset endpoint
error_message: message we expect to see in the response
valid_link: if the current password reset link is still valid
"""
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context_data['validlink'], valid_link)
self.assertIn(error_message, response.content)
@override_settings(AUTH_PASSWORD_VALIDATORS=[
create_validator_config('util.password_policy_validators.MinimumLengthValidator', {'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)
@ddt.data(
('foo', 'foobar', 'Error in resetting your password. Please try again.'),
('', '', 'This password is too short. It must contain at least'),
)
@ddt.unpack
def test_password_reset_form_invalid(self, password1, password2, err_msg):
"""
Tests that password reset fail when providing bad passwords and error message is displayed
to the user.
"""
user_email, _ = self._setup_user()
# try to reset password, it should fail
user = User.objects.get(email=user_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': password1,
'new_password2': password2,
}, follow=True)
self.assertPasswordResetError(resp, err_msg)

View File

@@ -733,9 +733,6 @@ if FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
TPA_PROVIDER_BURST_THROTTLE = ENV_TOKENS.get('TPA_PROVIDER_BURST_THROTTLE', TPA_PROVIDER_BURST_THROTTLE)
TPA_PROVIDER_SUSTAINED_THROTTLE = ENV_TOKENS.get('TPA_PROVIDER_SUSTAINED_THROTTLE', TPA_PROVIDER_SUSTAINED_THROTTLE)
##### ADVANCED_SECURITY_CONFIG #####
ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {})
##### GOOGLE ANALYTICS IDS #####
GOOGLE_ANALYTICS_ACCOUNT = AUTH_TOKENS.get('GOOGLE_ANALYTICS_ACCOUNT')
GOOGLE_ANALYTICS_TRACKING_ID = AUTH_TOKENS.get('GOOGLE_ANALYTICS_TRACKING_ID')

View File

@@ -181,7 +181,6 @@ YOUTUBE['TEXT_API']['url'] = "{0}:{1}/test_transcripts_youtube/".format(YOUTUBE_
FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS'] = False
FEATURES['SQUELCH_PII_IN_LOGS'] = False
FEATURES['PREVENT_CONCURRENT_LOGINS'] = False
FEATURES['ADVANCED_SECURITY'] = False
FEATURES['ENABLE_MOBILE_REST_API'] = True # Show video bumper in LMS
FEATURES['ENABLE_VIDEO_BUMPER'] = True # Show video bumper in LMS

View File

@@ -238,9 +238,6 @@ FEATURES = {
# Prevent concurrent logins per user
'PREVENT_CONCURRENT_LOGINS': True,
# Turn on Advanced Security by default
'ADVANCED_SECURITY': True,
# When a logged in user goes to the homepage ('/') should the user be
# redirected to the dashboard - this is default Open edX behavior. Set to
# False to not redirect the user
@@ -2919,10 +2916,6 @@ for app_name, insert_before in OPTIONAL_APPS:
except (IndexError, ValueError):
INSTALLED_APPS.append(app_name)
### ADVANCED_SECURITY_CONFIG
# Empty by default
ADVANCED_SECURITY_CONFIG = {}
### External auth usage -- prefixes for ENROLLMENT_DOMAIN
SHIBBOLETH_DOMAIN_PREFIX = 'shib:'
OPENID_DOMAIN_PREFIX = 'openid:'

View File

@@ -142,7 +142,6 @@ FEATURES['ENABLE_VIDEO_ABSTRACTION_LAYER_API'] = True
FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS'] = False
FEATURES['SQUELCH_PII_IN_LOGS'] = False
FEATURES['PREVENT_CONCURRENT_LOGINS'] = False
FEATURES['ADVANCED_SECURITY'] = False
########################### Milestones #################################
FEATURES['MILESTONES_APP'] = True

View File

@@ -729,9 +729,6 @@ if FEATURES.get('ENABLE_OAUTH2_PROVIDER'):
OAUTH_ID_TOKEN_EXPIRATION = ENV_TOKENS.get('OAUTH_ID_TOKEN_EXPIRATION', OAUTH_ID_TOKEN_EXPIRATION)
OAUTH_DELETE_EXPIRED = ENV_TOKENS.get('OAUTH_DELETE_EXPIRED', OAUTH_DELETE_EXPIRED)
##### ADVANCED_SECURITY_CONFIG #####
ADVANCED_SECURITY_CONFIG = ENV_TOKENS.get('ADVANCED_SECURITY_CONFIG', {})
##### GOOGLE ANALYTICS IDS #####
GOOGLE_ANALYTICS_ACCOUNT = AUTH_TOKENS.get('GOOGLE_ANALYTICS_ACCOUNT')
GOOGLE_ANALYTICS_TRACKING_ID = AUTH_TOKENS.get('GOOGLE_ANALYTICS_TRACKING_ID')

View File

@@ -228,7 +228,6 @@ FEATURES['ENFORCE_PASSWORD_POLICY'] = False
FEATURES['ENABLE_MAX_FAILED_LOGIN_ATTEMPTS'] = False
FEATURES['SQUELCH_PII_IN_LOGS'] = False
FEATURES['PREVENT_CONCURRENT_LOGINS'] = False
FEATURES['ADVANCED_SECURITY'] = False
######### Third-party auth ##########
FEATURES['ENABLE_THIRD_PARTY_AUTH'] = True

View File

@@ -57,7 +57,6 @@ from student.models import (
CourseEnrollment,
CourseEnrollmentAllowed,
ManualEnrollmentAudit,
PasswordHistory,
PendingEmailChange,
PendingNameChange,
Registration,
@@ -1533,7 +1532,6 @@ class TestLMSAccountRetirementPost(RetirementTestCase, ModuleStoreTestCase):
# other setup
PendingNameChange.objects.create(user=self.test_user, new_name=self.pii_standin, rationale=self.pii_standin)
PasswordHistory.objects.create(user=self.test_user, password=self.pii_standin)
# setup for doing POST from test client
self.headers = build_jwt_headers(self.test_superuser)
@@ -1563,7 +1561,6 @@ class TestLMSAccountRetirementPost(RetirementTestCase, ModuleStoreTestCase):
self.assertEqual(RevisionPluginRevision.objects.get(user=self.test_user).ip_address, None)
self.assertEqual(ArticleRevision.objects.get(user=self.test_user).ip_address, None)
self.assertFalse(PendingNameChange.objects.filter(user=self.test_user).exists())
self.assertEqual(PasswordHistory.objects.get(user=self.test_user).password, '')
self.assertEqual(
ManualEnrollmentAudit.objects.get(

View File

@@ -47,7 +47,6 @@ from openedx.core.lib.api.parsers import MergePatchParser
from student.models import (
CourseEnrollment,
ManualEnrollmentAudit,
PasswordHistory,
PendingNameChange,
CourseEnrollmentAllowed,
LoginFailures,
@@ -825,7 +824,6 @@ class LMSAccountRetirementView(ViewSet):
RevisionPluginRevision.retire_user(retirement.user)
ArticleRevision.retire_user(retirement.user)
PendingNameChange.delete_by_user_value(retirement.user, field='user')
PasswordHistory.retire_user(retirement.user.id)
ManualEnrollmentAudit.retire_manual_enrollments(retirement.user, retirement.retired_email)
CreditRequest.retire_user(retirement)

View File

@@ -26,10 +26,7 @@ from openedx.core.djangoapps.password_policy import compliance as password_polic
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.markup import HTML, Text
from student.models import (
LoginFailures,
PasswordHistory,
)
from student.models import LoginFailures
from student.views import send_reactivation_email_for_user
from student.forms import send_password_reset_email_for_user
from track import segment
@@ -134,16 +131,6 @@ def _check_excessive_login_attempts(user):
'to excessive login failures. Try again later.'))
def _check_forced_password_reset(user):
"""
See if the user must reset his/her password due to any policy settings
"""
if user and PasswordHistory.should_user_reset_password_now(user):
raise AuthFailedError(_('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.'))
def _enforce_password_policy_compliance(request, user):
try:
password_policy_compliance.enforce_compliance_on_login(user, request.POST.get('password'))
@@ -359,7 +346,6 @@ def login_user(request):
_check_shib_redirect(email_user)
_check_excessive_login_attempts(email_user)
_check_forced_password_reset(email_user)
possibly_authenticated_user = email_user

View File

@@ -110,8 +110,7 @@ class LoginTest(CacheIsolationTestCase):
value='Email or password is incorrect')
self._assert_audit_log(mock_audit_log, 'warning', [u'Login failed', u'Unknown user email', nonexistent_email])
@patch.dict("django.conf.settings.FEATURES", {'ADVANCED_SECURITY': True})
def test_login_fail_incorrect_email_with_advanced_security(self):
def test_login_fail_incorrect_email(self):
nonexistent_email = u'not_a_user@edx.org'
response, mock_audit_log = self._login_response(
nonexistent_email,