Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
567bd91
fix: session ID stability while maintaining inactivity timeout
ttak-apphelix Jun 12, 2025
f4a3d74
fix: linter issue
ttak-apphelix Jun 13, 2025
79dd8f9
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Jun 17, 2025
b5f4baf
fix!: session ID stability while maintaining inactivity timeout
ttak-apphelix Jun 17, 2025
8dd5bd2
fix!: session ID stability while maintaining inactivity timeout
ttak-apphelix Jun 17, 2025
f953ed9
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Jun 20, 2025
4f2ca29
fix: update session inactivity middleware to use consistent naming an…
ttak-apphelix Jun 20, 2025
50a212c
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Jun 25, 2025
02c0114
fix: correct logging variable name and improve log levels in session …
ttak-apphelix Jun 25, 2025
e50ede8
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Jun 26, 2025
43e0842
fix: enhance session inactivity tracking with TieredCache and custom …
ttak-apphelix Jun 26, 2025
8b935b1
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Jul 3, 2025
81f043f
fix: update session inactivity tracking to use user-specific cache ke…
ttak-apphelix Jul 3, 2025
9f42295
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Jul 8, 2025
c801c23
fix: simplify session save time key handling and improve logging for …
ttak-apphelix Jul 8, 2025
25a339f
fix: remove unused last session save time key and streamline session …
ttak-apphelix Jul 8, 2025
988d8c2
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Jul 25, 2025
495e2d6
fix: enhance session inactivity tracking with improved logging and er…
ttak-apphelix Jul 25, 2025
cdb462c
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Jul 28, 2025
6bdf73f
fix: linting fix
ttak-apphelix Jul 28, 2025
4334bac
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Jul 30, 2025
4cf4f90
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Aug 1, 2025
c666449
fix: enhance session inactivity monitoring with improved error handling
ttak-apphelix Aug 1, 2025
47ca3b4
fix: fix linter and import
ttak-apphelix Aug 1, 2025
385eb3f
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Aug 8, 2025
21d1b29
fix: incorporated review comment
ttak-apphelix Aug 14, 2025
3adcbe0
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Aug 14, 2025
b18b107
fix: removed unwanted comments
ttak-apphelix Aug 14, 2025
567dd9a
fix: updated mock for log
ttak-apphelix Aug 14, 2025
e282383
Merge branch 'openedx:master' into ttak/session_id_fix
ttak-apphelix Aug 18, 2025
68f1514
fix!: session ID stability while maintaining inactivity timeout
ttak-apphelix Aug 18, 2025
390fd8b
fixup! Update openedx/core/djangoapps/session_inactivity_timeout/test…
robrap Aug 21, 2025
040cfef
Merge branch 'master' into ttak/session_id_fix
robrap Aug 21, 2025
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
86 changes: 70 additions & 16 deletions openedx/core/djangoapps/session_inactivity_timeout/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,24 @@


from datetime import datetime, timedelta
import logging

from django.conf import settings
from django.contrib import auth
from django.utils.deprecation import MiddlewareMixin
from edx_django_utils import monitoring as monitoring_utils

LAST_TOUCH_KEYNAME = 'SessionInactivityTimeout:last_touch'

log = logging.getLogger(__name__)

LAST_TOUCH_KEYNAME = 'SessionInactivityTimeout:last_touch_str'


class SessionInactivityTimeout(MiddlewareMixin):
"""
Middleware class to keep track of activity on a given session
"""

def process_request(self, request):
"""
Standard entry point for processing requests in Django
Expand All @@ -34,27 +40,75 @@ def process_request(self, request):
#Can't log out if not logged in
return

# .. setting_name: SESSION_INACTIVITY_TIMEOUT_IN_SECONDS
# .. setting_default: None
# .. setting_description: If set, this is used to end the session when there is no activity for N seconds.
# .. setting_warning: Keep in sync with SESSION_COOKIE_AGE and must be larger than SESSION_ACTIVITY_SAVE_DELAY_SECONDS.
timeout_in_seconds = getattr(settings, "SESSION_INACTIVITY_TIMEOUT_IN_SECONDS", None)

current_time = datetime.utcnow()
# Do we have this feature enabled?
if timeout_in_seconds:
# what time is it now?
utc_now = datetime.utcnow()
# .. setting_name: SESSION_ACTIVITY_SAVE_DELAY_SECONDS
# .. setting_default: 900 (15 minutes in seconds)
# .. setting_description: How often to allow a full session save (in seconds).
# This controls how frequently the session ID might change.
# A user could be inactive for almost SESSION_ACTIVITY_SAVE_DELAY_SECONDS but since their session
# isn't being saved during that time, their last activity timestamp isn't being updated.
# When they hit the inactivity timeout, it will be based on the last saved activity time.
# So the effective timeout could be as short as:
# SESSION_INACTIVITY_TIMEOUT_IN_SECONDS - SESSION_ACTIVITY_SAVE_DELAY_SECONDS.
# This means users might be logged out earlier than expected in some edge cases.
# .. setting_warning: Must be smaller than SESSION_INACTIVITY_TIMEOUT_IN_SECONDS.
frequency_time_in_seconds = getattr(settings, "SESSION_ACTIVITY_SAVE_DELAY_SECONDS", 900)

# Get the last time user made a request to server, which is stored in session data
last_touch = request.session.get(LAST_TOUCH_KEYNAME)
last_touch_str = request.session.get(LAST_TOUCH_KEYNAME)
Comment thread
robrap marked this conversation as resolved.

# have we stored a 'last visited' in session? NOTE: first time access after login
# this key will not be present in the session data
if last_touch:
# compute the delta since last time user came to the server
time_since_last_activity = utc_now - last_touch

# did we exceed the timeout limit?
if time_since_last_activity > timedelta(seconds=timeout_in_seconds):
# yes? Then log the user out
del request.session[LAST_TOUCH_KEYNAME]
auth.logout(request)
return

request.session[LAST_TOUCH_KEYNAME] = utc_now
if last_touch_str:
try:
last_touch = datetime.fromisoformat(last_touch_str)
time_since_last_activity = current_time - last_touch

has_exceeded_timeout_limit = time_since_last_activity > timedelta(seconds=timeout_in_seconds)
Comment thread
robrap marked this conversation as resolved.
if has_exceeded_timeout_limit:
del request.session[LAST_TOUCH_KEYNAME]
auth.logout(request)
return
except (ValueError, TypeError) as e:
# If parsing fails, log warning and then treat as if no timestamp exists
log.warning("Parsing last touch time failed: %s", e)
Comment thread
robrap marked this conversation as resolved.
# .. custom_attribute_name: session_inactivity.last_touch_error
# .. custom_attribute_description: Boolean. True if parsing the last activity timestamp failed for this request, indicating a session data error.
monitoring_utils.set_custom_attribute('session_inactivity.last_touch_error', str(e))
monitoring_utils.record_exception()

else:
# .. custom_attribute_name: session_inactivity.first_login
# .. custom_attribute_description: Boolean. True if the user has no stored activity timestamp for this request.
monitoring_utils.set_custom_attribute('session_inactivity.first_login', True)
Comment thread
robrap marked this conversation as resolved.
Comment thread
robrap marked this conversation as resolved.
log.debug("No previous activity timestamp found (first login)")

current_time_str = current_time.isoformat()

# .. custom_attribute_name: session_inactivity.activity_seen
# .. custom_attribute_description: The current timestamp when user activity was detected for this request.
monitoring_utils.set_custom_attribute('session_inactivity.activity_seen', current_time_str)
Comment thread
ttak-apphelix marked this conversation as resolved.
Outdated
Comment thread
robrap marked this conversation as resolved.
Outdated
has_save_delay_been_exceeded = (
last_touch_str and
datetime.fromisoformat(last_touch_str) + timedelta(seconds=frequency_time_in_seconds) < current_time
)
proceed_with_period_save = not last_touch_str or has_save_delay_been_exceeded
# .. custom_attribute_name: session_inactivity.proceed_with_period_save
# .. custom_attribute_description: Boolean indicating whether a session save should proceed based on the
# save delay frequency. True when either no previous timestamp exists (first login) or the save delay
# period has been exceeded since the last timestamp update.
monitoring_utils.set_custom_attribute(
'session_inactivity.proceed_with_period_save',
proceed_with_period_save
)
if proceed_with_period_save:
# Allow a full session save periodically
request.session[LAST_TOUCH_KEYNAME] = current_time_str
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
Tests for session inactivity timeout middleware.
"""
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
"""
Unit tests for SessionInactivityTimeout middleware.
"""

from datetime import datetime, timedelta
from unittest.mock import patch

import ddt
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.backends.db import SessionStore
from django.test import TestCase, override_settings

from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangolib.testing.utils import get_mock_request
from unittest.mock import call

from openedx.core.djangoapps.session_inactivity_timeout.middleware import (
SessionInactivityTimeout,
LAST_TOUCH_KEYNAME,
)


@ddt.ddt
class SessionInactivityTimeoutTestCase(TestCase):
"""
Test case for SessionInactivityTimeout middleware
"""
def setUp(self):
super().setUp()
self.user = UserFactory.create()
self.middleware = SessionInactivityTimeout(get_response=lambda request: None)
self.request = get_mock_request(self.user)

self.request.session = SessionStore()
self.request.session.create()
self.request.session.modified = False

def test_process_request_unauthenticated_user_does_nothing(self):
self.request.user = AnonymousUser()
response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none
assert response is None
assert LAST_TOUCH_KEYNAME not in self.request.session

@ddt.data(None, 0)
def test_process_request_timeout_disabled_does_nothing(self, timeout_value):
with override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=timeout_value):
response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none

assert response is None
assert LAST_TOUCH_KEYNAME not in self.request.session
Comment thread
robrap marked this conversation as resolved.
Outdated

@ddt.data(
None, # No timestamp key in session
"", # Empty string timestamp
Comment thread
robrap marked this conversation as resolved.
Outdated
)
@override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=300)
@patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.datetime")
@patch(
"openedx.core.djangoapps.session_inactivity_timeout.middleware.monitoring_utils"
)
def test_process_request_first_visit_sets_timestamp(
self, timestamp_value, mock_monitoring, mock_datetime
):
if timestamp_value is not None:
self.request.session[LAST_TOUCH_KEYNAME] = timestamp_value
# else: leave the session without the timestamp key (None case)

mock_now = datetime(2025, 6, 16, 12, 0, 0)
mock_datetime.utcnow.return_value = mock_now

with self.assertLogs(
"openedx.core.djangoapps.session_inactivity_timeout.middleware",
level="DEBUG",
) as log:
Comment thread
robrap marked this conversation as resolved.
Outdated
response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none

assert response is None
assert self.request.session[LAST_TOUCH_KEYNAME] == mock_now.isoformat()

mock_monitoring.set_custom_attribute.assert_has_calls([
call("session_inactivity.first_login", True),
call("session_inactivity.activity_seen", mock_now.isoformat()),
call("session_inactivity.proceed_with_period_save", True),
], any_order=True)

@ddt.data(
# (timeout_seconds, save_delay_seconds, seconds_elapsed, should_logout)
# Test timeout behavior
(300, 900, 240, False), # 4 minutes, no timeout
(300, 900, 300, False), # 5 minutes, no timeout
(300, 900, 360, True), # 6 minutes, timeout occurs
(600, 900, 540, False), # 9 minutes, no timeout
(600, 900, 660, True), # 11 minutes, timeout occurs
# Test save delay behavior (with long timeout to avoid logout)
(3600, 900, 600, False), # 10 min < 15 min save delay, no save
(3600, 900, 1200, False), # 20 min > 15 min save delay, save occurs
(3600, 600, 480, False), # 8 min < 10 min save delay, no save
(3600, 600, 720, False), # 12 min > 10 min save delay, save occurs
Comment thread
robrap marked this conversation as resolved.
Outdated
)
@ddt.unpack
@patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.datetime")
@patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.auth")
@patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.monitoring_utils")
def test_process_request_timeout_and_save_behavior(
self, timeout_seconds, save_delay_seconds, seconds_elapsed, should_logout,
mock_monitoring, mock_auth, mock_datetime
):
with override_settings(
SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=timeout_seconds,
SESSION_ACTIVITY_SAVE_DELAY_SECONDS=save_delay_seconds
):
last_touch = datetime(2025, 6, 16, 12, 0, 0)
current_time = last_touch + timedelta(seconds=seconds_elapsed)
self.request.session[LAST_TOUCH_KEYNAME] = last_touch.isoformat()
mock_datetime.utcnow.return_value = current_time
mock_datetime.fromisoformat = datetime.fromisoformat

response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none

assert response is None

if should_logout:
assert LAST_TOUCH_KEYNAME not in self.request.session
mock_auth.logout.assert_called_once_with(self.request)
Comment thread
robrap marked this conversation as resolved.
Outdated
else:
mock_auth.logout.assert_not_called()
assert LAST_TOUCH_KEYNAME in self.request.session

should_save = seconds_elapsed > save_delay_seconds

if should_save:
assert self.request.session[LAST_TOUCH_KEYNAME] == current_time.isoformat()
mock_monitoring.set_custom_attribute.assert_has_calls([
call("session_inactivity.activity_seen", current_time.isoformat()),
call("session_inactivity.proceed_with_period_save", True),
], any_order=True)
else:
# Session should not be saved, timestamp remains the same
assert self.request.session[LAST_TOUCH_KEYNAME] == last_touch.isoformat()
mock_monitoring.set_custom_attribute.assert_any_call(
"session_inactivity.proceed_with_period_save", False
Comment thread
robrap marked this conversation as resolved.
Outdated
)

@ddt.data(
# (seconds_elapsed, should_save) - testing around default 900 second save delay
(800, False), # 13.3 min < 15 min default, no save
(900, False), # exactly 15 min default, no save (not exceeded)
(1000, True), # 16.7 min > 15 min default, save occurs
Comment thread
robrap marked this conversation as resolved.
Outdated
)
@ddt.unpack
@override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=3600)
@patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.datetime")
@patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.monitoring_utils")
def test_process_request_uses_default_save_frequency(
self, seconds_elapsed, should_save, mock_monitoring, mock_datetime
):
# Don't set SESSION_ACTIVITY_SAVE_DELAY_SECONDS to test default behavior
last_touch = datetime(2025, 6, 16, 12, 0, 0)
current_time = last_touch + timedelta(seconds=seconds_elapsed)
self.request.session[LAST_TOUCH_KEYNAME] = last_touch.isoformat()
mock_datetime.utcnow.return_value = current_time
mock_datetime.fromisoformat = datetime.fromisoformat

response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none

assert response is None

if should_save:
# Session should be saved with new timestamp
assert self.request.session[LAST_TOUCH_KEYNAME] == current_time.isoformat()
mock_monitoring.set_custom_attribute.assert_any_call(
"session_inactivity.proceed_with_period_save", True
)
else:
# Session should not be saved, timestamp remains the same
assert self.request.session[LAST_TOUCH_KEYNAME] == last_touch.isoformat()
mock_monitoring.set_custom_attribute.assert_any_call(
"session_inactivity.proceed_with_period_save", False
)

@ddt.data(
# (timeout_seconds, seconds_elapsed, should_logout)
# Test boundary conditions more precisely
Comment thread
robrap marked this conversation as resolved.
Outdated
(300, 299, False), # 299 sec < 300 sec timeout, no logout
(300, 300, False), # exactly 300 sec timeout, no logout (not exceeded)
(300, 301, True), # 301 sec > 300 sec timeout, logout occurs
)
@ddt.unpack
@override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=300)
@patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.datetime")
@patch("openedx.core.djangoapps.session_inactivity_timeout.middleware.auth")
def test_process_request_timeout_boundary_conditions(
self, timeout_seconds, seconds_elapsed, should_logout, mock_auth, mock_datetime
):
last_touch = datetime(2025, 6, 16, 12, 0, 0)
current_time = last_touch + timedelta(seconds=seconds_elapsed)
self.request.session[LAST_TOUCH_KEYNAME] = last_touch.isoformat()
mock_datetime.utcnow.return_value = current_time
mock_datetime.fromisoformat = datetime.fromisoformat
response = self.middleware.process_request(self.request) # lint-amnesty, pylint: disable=assignment-from-none

assert response is None

if should_logout:
assert LAST_TOUCH_KEYNAME not in self.request.session
mock_auth.logout.assert_called_once_with(self.request)
else:
mock_auth.logout.assert_not_called()
assert LAST_TOUCH_KEYNAME in self.request.session
Loading