Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions common/djangoapps/student/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions openedx/core/djangoapps/user_authn/views/tests/test_login.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,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):
"""
Expand Down
Loading