From cda72e9071a22bcd437d539add93854fb8c3bfe4 Mon Sep 17 00:00:00 2001 From: hasnain-naveed Date: Mon, 11 Nov 2019 14:26:41 +0500 Subject: [PATCH] ENT-2460 | Added a model for whitelist emails for authentication. --- common/djangoapps/student/admin.py | 43 ++++++++ .../migrations/0026_allowedauthuser.py | 32 ++++++ common/djangoapps/student/models.py | 12 +++ .../student/tests/test_admin_views.py | 98 ++++++++++++++++++- 4 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 common/djangoapps/student/migrations/0026_allowedauthuser.py diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index b24d395ed4..1220c90a7d 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -26,6 +26,7 @@ from openedx.core.lib.courses import clean_course_id from student import STUDENT_WAFFLE_NAMESPACE from student.models import ( AccountRecovery, + AllowedAuthUser, CourseAccessRole, CourseEnrollment, CourseEnrollmentAllowed, @@ -441,6 +442,48 @@ class LoginFailuresAdmin(admin.ModelAdmin): self.model.clear_lockout_counter(obj.user) +class AllowedAuthUserForm(forms.ModelForm): + """Model Form for AllowedAuthUser model's admin interface.""" + + class Meta(object): + model = AllowedAuthUser + fields = ('site', 'email', ) + + def clean_email(self): + """ + Validate the email field. + """ + email = self.cleaned_data['email'] + email_domain = email.split('@')[-1] + allowed_site_email_domain = self.cleaned_data['site'].configuration.get_value('THIRD_PARTY_AUTH_ONLY_DOMAIN') + + if not allowed_site_email_domain: + raise forms.ValidationError( + _("Please add a key/value 'THIRD_PARTY_AUTH_ONLY_DOMAIN/{site_email_domain}' in SiteConfiguration " + "model's values field.") + ) + elif email_domain != allowed_site_email_domain: + raise forms.ValidationError( + _("Email doesn't have {domain_name} domain name.".format(domain_name=allowed_site_email_domain)) + ) + elif not User.objects.filter(email=email).exists(): + raise forms.ValidationError(_("User with this email doesn't exist in system.")) + else: + return email + + +@admin.register(AllowedAuthUser) +class AllowedAuthUserAdmin(admin.ModelAdmin): + """ Admin interface for the AllowedAuthUser model. """ + form = AllowedAuthUserForm + list_display = ('email', 'site',) + search_fields = ('email',) + ordering = ('-created',) + + class Meta(object): + model = AllowedAuthUser + + admin.site.register(UserTestGroup) admin.site.register(Registration) admin.site.register(PendingNameChange) diff --git a/common/djangoapps/student/migrations/0026_allowedauthuser.py b/common/djangoapps/student/migrations/0026_allowedauthuser.py new file mode 100644 index 0000000000..e3238dafb4 --- /dev/null +++ b/common/djangoapps/student/migrations/0026_allowedauthuser.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.26 on 2019-11-14 14:12 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('student', '0025_auto_20191101_1846'), + ] + + operations = [ + migrations.CreateModel( + name='AllowedAuthUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('email', models.EmailField(help_text="An employee (a user whose email has current site's domain name) whose email exists in this model, can be able to login from login screen through email and password. And if any employee's email doesn't exist in this model then that employee can login via third party authentication backend only.", max_length=254, unique=True)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_auth_users', to='sites.Site')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 44352ab77c..3b419593b2 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -29,6 +29,7 @@ from django.conf import settings from django.contrib.auth.hashers import make_password from django.contrib.auth.models import User from django.contrib.auth.signals import user_logged_in, user_logged_out +from django.contrib.sites.models import Site from django.core.cache import cache from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.db import IntegrityError, models @@ -2982,3 +2983,14 @@ class AccountRecovery(models.Model): pass return True + + +class AllowedAuthUser(TimeStampedModel): + site = models.ForeignKey(Site, related_name='allowed_auth_users', on_delete=models.CASCADE) + email = models.EmailField( + help_text=_( + "An employee (a user whose email has current site's domain name) whose email exists in this model, can be " + "able to login from login screen through email and password. And if any employee's email doesn't exist in " + "this model then that employee can login via third party authentication backend only."), + unique=True, + ) diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py index c3b57e6d6d..3a62b428d3 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -16,12 +16,13 @@ from django.urls import reverse from django.utils.timezone import now from mock import Mock -from student.admin import COURSE_ENROLLMENT_ADMIN_SWITCH, UserAdmin, CourseEnrollmentForm -from student.models import CourseEnrollment, LoginFailures +from student.admin import AllowedAuthUserForm, COURSE_ENROLLMENT_ADMIN_SWITCH, UserAdmin, CourseEnrollmentForm +from student.models import AllowedAuthUser, CourseEnrollment, LoginFailures from student.tests.factories import CourseEnrollmentFactory, UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin class AdminCourseRolesPageTest(SharedModuleStoreTestCase): @@ -422,3 +423,96 @@ class CourseEnrollmentAdminFormTest(SharedModuleStoreTestCase): self.assertEqual(count, CourseEnrollment.objects.count()) self.assertFalse(course_enrollment.is_active) self.assertEqual(enrollment.id, course_enrollment.id) + + +class AllowedAuthUserFormTest(SiteMixin, TestCase): + """ + Unit test for AllowedAuthUserAdmin's ModelForm. + """ + @classmethod + def setUpClass(cls): + super(AllowedAuthUserFormTest, cls).setUpClass() + cls.email_domain_name = "dummy.com" + cls.email_with_wrong_domain = "dummy@example.com" + cls.valid_email = "dummy@{email_domain_name}".format(email_domain_name=cls.email_domain_name) + cls.other_valid_email = "dummy1@{email_domain_name}".format(email_domain_name=cls.email_domain_name) + UserFactory(email=cls.valid_email) + UserFactory(email=cls.email_with_wrong_domain) + + def _update_site_configuration(self): + """ Updates the site's configuration """ + self.site.configuration.values = {'THIRD_PARTY_AUTH_ONLY_DOMAIN': self.email_domain_name} + self.site.configuration.save() + + def _assert_form(self, site, email, is_valid_form=False): + """ + Asserts the form and returns the error if its not valid and instance if its valid + """ + error = '' + instance = None + form = AllowedAuthUserForm({'site': site.id, 'email': email}) + if is_valid_form: + self.assertTrue(form.is_valid()) + instance = form.save() + else: + self.assertFalse(form.is_valid()) + error = form.errors['email'][0] + return error, instance + + def test_form_with_invalid_site_configuration(self): + """ + Test form with wrong site's configuration. + """ + error, _ = self._assert_form(self.site, self.valid_email) + self.assertEqual( + error, + "Please add a key/value 'THIRD_PARTY_AUTH_ONLY_DOMAIN/{site_email_domain}' in SiteConfiguration " + "model's values field." + ) + + def test_form_with_invalid_domain_name(self): + """ + Test form with email which has wrong email domain. + """ + self._update_site_configuration() + error, _ = self._assert_form(self.site, self.email_with_wrong_domain) + self.assertEqual( + error, + "Email doesn't have {email_domain_name} domain name.".format(email_domain_name=self.email_domain_name) + ) + + def test_form_with_invalid_user(self): + """ + Test form with an email which is not associated with any user. + """ + self._update_site_configuration() + error, _ = self._assert_form(self.site, self.other_valid_email) + self.assertEqual(error, "User with this email doesn't exist in system.") + + def test_form_creation(self): + """ + Test AllowedAuthUserForm creation. + """ + self._update_site_configuration() + _, allowed_auth_user = self._assert_form(self.site, self.valid_email, is_valid_form=True) + db_allowed_auth_user = AllowedAuthUser.objects.all().first() + self.assertEqual(db_allowed_auth_user.site.id, allowed_auth_user.site.id) + self.assertEqual(db_allowed_auth_user.email, allowed_auth_user.email) + + def test_form_update(self): + """ + Test AllowedAuthUserForm update. + """ + self._update_site_configuration() + UserFactory(email=self.other_valid_email) + _, allowed_auth_user = self._assert_form(self.site, self.valid_email, is_valid_form=True) + self.assertEqual(AllowedAuthUser.objects.all().count(), 1) + + # update the object with new instance. + form = AllowedAuthUserForm({'site': self.site.id, 'email': self.other_valid_email}, instance=allowed_auth_user) + self.assertTrue(form.is_valid()) + form.save() + + db_allowed_auth_user = AllowedAuthUser.objects.all().first() + self.assertEqual(AllowedAuthUser.objects.all().count(), 1) + self.assertEqual(db_allowed_auth_user.email, self.other_valid_email)