From e47b1b31109c103af5e13a9574c55d6c8342770d Mon Sep 17 00:00:00 2001 From: Md Bayazid Bostame Date: Wed, 1 Apr 2026 22:04:31 +0200 Subject: [PATCH] feat: add session expiry warning --- backend/workflows/context_processors.py | 9 +- backend/workflows/middleware.py | 1 + .../static/workflows/js/session_warning.js | 82 +++++++++++++++++++ .../templates/workflows/base_shell.html | 31 +++++++ .../workflows/tests/test_session_warning.py | 49 +++++++++++ backend/workflows/urls.py | 1 + backend/workflows/views.py | 16 ++++ 7 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 backend/workflows/static/workflows/js/session_warning.js create mode 100644 backend/workflows/tests/test_session_warning.py diff --git a/backend/workflows/context_processors.py b/backend/workflows/context_processors.py index 6fac856..450bd35 100644 --- a/backend/workflows/context_processors.py +++ b/backend/workflows/context_processors.py @@ -19,5 +19,12 @@ def role_context(request): ) else: context.update({'header_notifications': [], 'header_unread_notification_count': 0}) - context.update({'static_asset_version': settings.STATIC_ASSET_VERSION}) + context.update( + { + 'static_asset_version': settings.STATIC_ASSET_VERSION, + 'session_idle_timeout_seconds': settings.SESSION_IDLE_TIMEOUT_SECONDS, + 'session_reauth_timeout_seconds': settings.SENSITIVE_ACTION_REAUTH_SECONDS, + 'force_branded_error_pages': settings.FORCE_BRANDED_ERROR_PAGES, + } + ) return context diff --git a/backend/workflows/middleware.py b/backend/workflows/middleware.py index 65e2f64..367a4ca 100644 --- a/backend/workflows/middleware.py +++ b/backend/workflows/middleware.py @@ -127,6 +127,7 @@ class RateLimitMiddleware: class AuthSessionHardeningMiddleware: EXEMPT_PREFIXES = ( '/healthz/', + '/session/keepalive/', '/i18n/', '/accounts/login/', '/accounts/logout/', diff --git a/backend/workflows/static/workflows/js/session_warning.js b/backend/workflows/static/workflows/js/session_warning.js new file mode 100644 index 0000000..736acd4 --- /dev/null +++ b/backend/workflows/static/workflows/js/session_warning.js @@ -0,0 +1,82 @@ +(function () { + const config = window.WorkdockSessionConfig; + if (!config || !config.idleTimeoutSeconds) return; + + const warningLeadSeconds = Math.min(300, Math.max(60, Math.floor(config.idleTimeoutSeconds / 6))); + const modal = document.getElementById("app-session-warning-modal"); + const countdown = document.getElementById("app-session-warning-countdown"); + const extendButton = document.getElementById("app-session-warning-extend"); + if (!modal || !countdown || !extendButton) return; + + let lastConfirmedAt = Date.now(); + let warningVisible = false; + let keepaliveInFlight = false; + + function getCsrfToken() { + const cookie = document.cookie + .split(";") + .map((item) => item.trim()) + .find((item) => item.startsWith("csrftoken=")); + return cookie ? decodeURIComponent(cookie.split("=")[1]) : ""; + } + + function hideWarning() { + if (!warningVisible) return; + modal.hidden = true; + modal.setAttribute("aria-hidden", "true"); + warningVisible = false; + } + + function showWarning(secondsLeft) { + countdown.textContent = `Noch etwa ${secondsLeft} Sekunden bis zur automatischen Abmeldung.`; + if (warningVisible) return; + modal.hidden = false; + modal.setAttribute("aria-hidden", "false"); + warningVisible = true; + } + + async function sendKeepalive() { + if (keepaliveInFlight) return; + keepaliveInFlight = true; + try { + const response = await fetch(config.keepaliveUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCsrfToken(), + "X-Requested-With": "XMLHttpRequest", + }, + credentials: "same-origin", + body: JSON.stringify({ keepalive: true }), + }); + if (!response.ok) { + window.location.href = config.loginUrl; + return; + } + lastConfirmedAt = Date.now(); + hideWarning(); + } catch (_error) { + window.location.href = config.loginUrl; + } finally { + keepaliveInFlight = false; + } + } + + function tick() { + const elapsedSeconds = Math.floor((Date.now() - lastConfirmedAt) / 1000); + const secondsLeft = config.idleTimeoutSeconds - elapsedSeconds; + if (secondsLeft <= 0) { + window.location.href = config.loginUrl; + return; + } + if (secondsLeft <= warningLeadSeconds) { + showWarning(secondsLeft); + } + } + + extendButton.addEventListener("click", function () { + sendKeepalive(); + }); + + setInterval(tick, 1000); +})(); diff --git a/backend/workflows/templates/workflows/base_shell.html b/backend/workflows/templates/workflows/base_shell.html index 54e2e6b..8d4bf40 100644 --- a/backend/workflows/templates/workflows/base_shell.html +++ b/backend/workflows/templates/workflows/base_shell.html @@ -14,6 +14,16 @@ {% block extra_head %}{% endblock %} + {% if request.user.is_authenticated %} + + {% endif %} {% block pre_shell %}{% endblock %} {% if portal_trial_enabled %}
@@ -93,8 +103,29 @@
+ + {% block extra_scripts %}{% endblock %} diff --git a/backend/workflows/tests/test_session_warning.py b/backend/workflows/tests/test_session_warning.py new file mode 100644 index 0000000..706fa3c --- /dev/null +++ b/backend/workflows/tests/test_session_warning.py @@ -0,0 +1,49 @@ +from django.contrib.auth import get_user_model +from django.test import Client, TestCase +from django.test.utils import override_settings + + +TEST_STORAGES = { + 'default': { + 'BACKEND': 'django.core.files.storage.FileSystemStorage', + }, + 'staticfiles': { + 'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage', + }, +} + + +@override_settings(STORAGES=TEST_STORAGES) +class SessionWarningTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + username='session-user', + email='session@example.com', + password='secret-12345', + ) + + def test_base_shell_exposes_session_warning_config(self): + client = Client(HTTP_HOST='localhost') + client.force_login(self.user) + + response = client.get('/') + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'WorkdockSessionConfig') + self.assertContains(response, '/session/keepalive/') + self.assertContains(response, 'Ihre Sitzung läuft bald ab') + + def test_keepalive_refreshes_session_timestamps(self): + client = Client(HTTP_HOST='localhost') + client.force_login(self.user) + session = client.session + session['last_activity_ts'] = 1 + session['auth_fresh_ts'] = 1 + session.save() + + response = client.post('/session/keepalive/', {}, HTTP_HOST='localhost') + + self.assertEqual(response.status_code, 200) + session = client.session + self.assertGreater(session['last_activity_ts'], 1) + self.assertGreater(session['auth_fresh_ts'], 1) diff --git a/backend/workflows/urls.py b/backend/workflows/urls.py index 7971322..5650f2e 100644 --- a/backend/workflows/urls.py +++ b/backend/workflows/urls.py @@ -4,6 +4,7 @@ from . import error_views, views urlpatterns = [ path('healthz/', views.healthz, name='healthz'), + path('session/keepalive/', views.session_keepalive, name='session_keepalive'), path('', views.home, name='home'), path('account/', views.account_profile_page, name='account_profile_page'), path('notifications//read/', views.mark_notification_read, name='mark_notification_read'), diff --git a/backend/workflows/views.py b/backend/workflows/views.py index 3b4efaa..02997a9 100644 --- a/backend/workflows/views.py +++ b/backend/workflows/views.py @@ -138,6 +138,22 @@ def mark_all_notifications_read(request): UserNotification.objects.filter(user=request.user, read_at__isnull=True).update(read_at=timezone.now()) return _redirect_back(request, 'home') + +@login_required +@require_POST +def session_keepalive(request): + now_ts = int(timezone.now().timestamp()) + request.session['last_activity_ts'] = now_ts + request.session['auth_fresh_ts'] = now_ts + return JsonResponse( + { + 'status': 'ok', + 'idle_timeout_seconds': settings.SESSION_IDLE_TIMEOUT_SECONDS, + 'reauth_timeout_seconds': settings.SENSITIVE_ACTION_REAUTH_SECONDS, + 'refreshed_at': now_ts, + } + ) + def healthz(request): db_ok = True try: