From 0501775d6419003c9c24e3f16d14d7d7222e2c73 Mon Sep 17 00:00:00 2001 From: "Dave St.Germain" Date: Mon, 3 Mar 2014 11:11:38 -0500 Subject: [PATCH] Add an option to prevent multiple logins of the same user. --- cms/envs/common.py | 3 ++ common/djangoapps/student/models.py | 33 +++++++++++++++++-- common/djangoapps/student/tests/test_login.py | 26 +++++++++++++++ lms/envs/common.py | 3 ++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index d6ca688f5b..6e2618a65e 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -91,6 +91,9 @@ FEATURES = { # Allow creating courses with non-ascii characters in the course id 'ALLOW_UNICODE_COURSE_ID': False, + + # Prevent concurrent logins per user + 'PREVENT_CONCURRENT_LOGINS': False, } ENABLE_JASMINE = False diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 8f2952fdfe..6138085540 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -34,6 +34,7 @@ from django_countries import CountryField from track import contexts from track.views import server_track from eventtracking import tracker +from importlib import import_module from course_modes.models import CourseMode import lms.lib.comment_client as cc @@ -42,6 +43,7 @@ from util.query import use_read_replica_if_available unenroll_done = Signal(providing_args=["course_enrollment"]) log = logging.getLogger(__name__) AUDIT_LOG = logging.getLogger("audit") +SessionStore = import_module(settings.SESSION_ENGINE).SessionStore class AnonymousUserId(models.Model): @@ -239,6 +241,20 @@ class UserProfile(models.Model): def set_meta(self, js): self.meta = json.dumps(js) + def set_login_session(self, session_id=None): + """ + Sets the current session id for the logged-in user. + If session_id doesn't match the existing session, + deletes the old session object. + """ + meta = self.get_meta() + old_login = meta.get('session_id', None) + if old_login: + SessionStore(session_key=old_login).delete() + meta['session_id'] = session_id + self.set_meta(meta) + self.save() + def unique_id_for_user(user): """ @@ -292,7 +308,6 @@ class PendingEmailChange(models.Model): activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True) - EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated' EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated' @@ -383,7 +398,6 @@ class CourseEnrollment(models.Model): # list of possible values. mode = models.CharField(default="honor", max_length=100) - class Meta: unique_together = (('user', 'course_id'),) ordering = ('user', 'course_id') @@ -866,3 +880,18 @@ def log_successful_logout(sender, request, user, **kwargs): AUDIT_LOG.info(u"Logout - user.id: {0}".format(request.user.id)) else: AUDIT_LOG.info(u"Logout - {0}".format(request.user)) + + +@receiver(user_logged_in) +@receiver(user_logged_out) +def enforce_single_login(sender, request, user, signal, **kwargs): + """ + Sets the current session id in the user profile, + to prevent concurrent logins. + """ + if settings.FEATURES.get('PREVENT_CONCURRENT_LOGINS', False): + if signal == user_logged_in: + key = request.session.session_key + else: + key = None + user.profile.set_login_session(key) diff --git a/common/djangoapps/student/tests/test_login.py b/common/djangoapps/student/tests/test_login.py index d7f3cfad99..b58ffc61eb 100644 --- a/common/djangoapps/student/tests/test_login.py +++ b/common/djangoapps/student/tests/test_login.py @@ -179,6 +179,32 @@ class LoginTest(TestCase): response, _audit_log = self._login_response('test@edx.org', 'wrong_password') self._assert_response(response, success=False, value='Too many failed login attempts') + @patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True}) + def test_single_session(self): + creds = {'email': 'test@edx.org', 'password': 'test_password'} + client1 = Client() + client2 = Client() + + response = client1.post(self.url, creds) + self._assert_response(response, success=True) + + self.assertEqual(self.user.profile.get_meta()['session_id'], self.client.session.session_key) + + # second login should log out the first + response = client2.post(self.url, creds) + self._assert_response(response, success=True) + + try: + # this test can be run with either lms or studio settings + # since studio does not have a dashboard url, we should + # look for another url that is login_required, in that case + url = reverse('dashboard') + except NoReverseMatch: + url = reverse('upload_transcripts') + response = client1.get(url) + # client1 will be logged out + self.assertEqual(response.status_code, 302) + def _login_response(self, email, password, patched_audit_log='student.views.AUDIT_LOG'): ''' Post the login info ''' post_params = {'email': email, 'password': password} diff --git a/lms/envs/common.py b/lms/envs/common.py index d482e7a551..4cef6a88d6 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -239,6 +239,9 @@ FEATURES = { # Toggle to enable alternate urls for marketing links 'ENABLE_MKTG_SITE': False, + + # Prevent concurrent logins per user + 'PREVENT_CONCURRENT_LOGINS': False, } # Used for A/B testing