diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index e769e11f3890..4607cb40a8f7 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -1338,6 +1338,31 @@ def log_successful_logout(sender, request, user, **kwargs): # pylint: disable=u segment.track(request.user.id, 'edx.bi.user.account.logout') +def _is_single_login_exempt(user): + """ + Return True if ``user`` is exempt from single-login enforcement. + + ``PREVENT_CONCURRENT_LOGINS`` keeps a single active session per user and + deletes the previously registered session on each login. That is correct for + human accounts, but it breaks shared service/automation accounts (for + example the xqueue-watcher grader account) whose many concurrent workers all + authenticate as one user and would otherwise continually evict each other's + sessions. + + Exemptions are opt-in and default to empty, so behaviour is unchanged unless + configured: + + * ``SINGLE_LOGIN_EXEMPT_USERNAMES`` -- iterable of exact usernames. + * ``SINGLE_LOGIN_EXEMPT_GROUPS`` -- iterable of group names; a user in any of + these groups is exempt. + """ + exempt_usernames = getattr(settings, 'SINGLE_LOGIN_EXEMPT_USERNAMES', None) or () + if user.username in exempt_usernames: + return True + exempt_groups = getattr(settings, 'SINGLE_LOGIN_EXEMPT_GROUPS', None) or () + return bool(exempt_groups) and user.groups.filter(name__in=exempt_groups).exists() + + @receiver(user_logged_in) @receiver(user_logged_out) def enforce_single_login(sender, request, user, signal, **kwargs): # pylint: disable=unused-argument @@ -1355,7 +1380,8 @@ def enforce_single_login(sender, request, user, signal, **kwargs): # pylint: di user=user, defaults={'name': user.username} ) - if user_profile: + if user_profile and not _is_single_login_exempt(user): + # Shared service/automation accounts may hold concurrent sessions. user.profile.set_login_session(key) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index 98db117aca20..027d583e6a96 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -12,7 +12,8 @@ import ddt from django.conf import settings -from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from django.contrib.auth.models import Group, User # pylint: disable=imported-auth-user +from django.contrib.sessions.models import Session from django.core import mail from django.core.cache import cache from django.http import HttpResponse @@ -658,6 +659,62 @@ def test_single_session(self): # client1 will be logged out assert response.status_code == 302 + @patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True}) + def test_single_session_exempt_user(self): + """ + A user whose username is in SINGLE_LOGIN_EXEMPT_USERNAMES is not subject + to single-login enforcement: a concurrent login does not record the + single-session slot and therefore does not evict the first session. + """ + creds = {'email': self.user_email, 'password': self.password} + client1 = Client() + client2 = Client() + + with override_settings(SINGLE_LOGIN_EXEMPT_USERNAMES=[self.user.username]): + response = client1.post(self.url, creds) + self._assert_response(response, success=True) + + # A second login must NOT evict the exempt user's first session. + response = client2.post(self.url, creds) + self._assert_response(response, success=True) + + self.user = User.objects.get(pk=self.user.pk) + # No single-session slot is recorded for exempt users, so neither + # session is ever deleted. + assert 'session_id' not in self.user.profile.get_meta() + + # client1's Django session itself was never evicted -- this is + # the actual mechanism set_login_session uses to end a session, + # so it stays valid independent of what profile meta records. + assert Session.objects.filter(session_key=client1.session.session_key).exists() + + @patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True}) + def test_single_session_exempt_group(self): + """ + A user in a group listed in SINGLE_LOGIN_EXEMPT_GROUPS is not subject + to single-login enforcement: a concurrent login does not record the + single-session slot and therefore does not evict the first session. + """ + group = Group.objects.create(name='exempt-service-accounts') + self.user.groups.add(group) + + creds = {'email': self.user_email, 'password': self.password} + client1 = Client() + client2 = Client() + + with override_settings(SINGLE_LOGIN_EXEMPT_GROUPS=[group.name]): + response = client1.post(self.url, creds) + self._assert_response(response, success=True) + + # A second login must NOT evict the exempt user's first session. + response = client2.post(self.url, creds) + self._assert_response(response, success=True) + + self.user = User.objects.get(pk=self.user.pk) + # No single-session slot is recorded for exempt users, so neither + # session is ever deleted. + assert 'session_id' not in self.user.profile.get_meta() + @patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True}) def test_single_session_with_no_user_profile(self): """