From 526dee13bf373925c38fb32c43ac78e894ff7baa Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Mon, 29 Jun 2026 17:18:13 -0400 Subject: [PATCH 1/4] feat: allow exempting service accounts from PREVENT_CONCURRENT_LOGINS --- common/djangoapps/student/models/user.py | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index e769e11f3890..a1b9f4e8aa01 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 @@ -1346,6 +1371,9 @@ def enforce_single_login(sender, request, user, signal, **kwargs): # pylint: di to prevent concurrent logins. """ if settings.FEATURES.get('PREVENT_CONCURRENT_LOGINS', False): + if user and _is_single_login_exempt(user): + # Shared service/automation accounts may hold concurrent sessions. + return if signal == user_logged_in: key = request.session.session_key else: From 00a19de289e83d205e61a44ec32908a6a2e0ab89 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Mon, 29 Jun 2026 17:18:16 -0400 Subject: [PATCH 2/4] test: cover SINGLE_LOGIN_EXEMPT_USERNAMES exemption --- .../user_authn/views/tests/test_login.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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..c07183b0af1d 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -658,6 +658,30 @@ 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() + @patch.dict("django.conf.settings.FEATURES", {'PREVENT_CONCURRENT_LOGINS': True}) def test_single_session_with_no_user_profile(self): """ From a2041189637cd1829fe1510290f3e8b2521cc844 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Wed, 15 Jul 2026 09:32:18 -0400 Subject: [PATCH 3/4] fix: create profile for single-login-exempt users; add group exemption test Exempt users returned early before UserProfile.objects.get_or_create, so users without a profile never got one created on login while PREVENT_CONCURRENT_LOGINS was enabled. Move the exemption check to only skip set_login_session, and add coverage for SINGLE_LOGIN_EXEMPT_GROUPS and for client1 remaining authenticated after a concurrent login. --- common/djangoapps/student/models/user.py | 6 +-- .../user_authn/views/tests/test_login.py | 41 ++++++++++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index a1b9f4e8aa01..4607cb40a8f7 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -1371,9 +1371,6 @@ def enforce_single_login(sender, request, user, signal, **kwargs): # pylint: di to prevent concurrent logins. """ if settings.FEATURES.get('PREVENT_CONCURRENT_LOGINS', False): - if user and _is_single_login_exempt(user): - # Shared service/automation accounts may hold concurrent sessions. - return if signal == user_logged_in: key = request.session.session_key else: @@ -1383,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 c07183b0af1d..6d6369a39981 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,7 @@ 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.core import mail from django.core.cache import cache from django.http import HttpResponse @@ -682,6 +682,45 @@ def test_single_session_exempt_user(self): # session is ever deleted. assert 'session_id' not in self.user.profile.get_meta() + 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 remains authenticated; the exempt user's first session + # was not evicted by the second login. + assert response.status_code == 200 + + @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): """ From 0cc19efc4780cf68535d42d528be580580018969 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Wed, 15 Jul 2026 11:25:58 -0400 Subject: [PATCH 4/4] test: verify exempt-user session persistence via Session model, not dashboard view The dashboard-view GET added to test_single_session_exempt_user passed locally but failed in CI's shared-with-cms-1 job (400 instead of 200), likely from unrelated view-layer state (MFE redirect flags, site config) picked up when run alongside the rest of that shard. Assert against Session.objects directly instead -- that's the actual mechanism set_login_session uses to evict a session, so it verifies the same thing without depending on the dashboard view rendering successfully. --- .../user_authn/views/tests/test_login.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) 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 6d6369a39981..027d583e6a96 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -13,6 +13,7 @@ import ddt from django.conf import settings 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 @@ -682,17 +683,10 @@ def test_single_session_exempt_user(self): # session is ever deleted. assert 'session_id' not in self.user.profile.get_meta() - 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 remains authenticated; the exempt user's first session - # was not evicted by the second login. - assert response.status_code == 200 + # 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):