feat: add session expiry warning
Some checks failed
CI / python-validation (push) Has been cancelled
CI / docker-release-gate (push) Has been cancelled
i18n / compile-translations (push) Has been cancelled

This commit is contained in:
Md Bayazid Bostame
2026-04-01 22:04:31 +02:00
parent 5fab01d57a
commit e47b1b3110
7 changed files with 188 additions and 1 deletions

View File

@@ -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

View File

@@ -127,6 +127,7 @@ class RateLimitMiddleware:
class AuthSessionHardeningMiddleware:
EXEMPT_PREFIXES = (
'/healthz/',
'/session/keepalive/',
'/i18n/',
'/accounts/login/',
'/accounts/logout/',

View File

@@ -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);
})();

View File

@@ -14,6 +14,16 @@
{% block extra_head %}{% endblock %}
</head>
<body{% block body_attrs %}{% endblock %}>
{% if request.user.is_authenticated %}
<script>
window.WorkdockSessionConfig = {
idleTimeoutSeconds: {{ session_idle_timeout_seconds|default:0 }},
reauthTimeoutSeconds: {{ session_reauth_timeout_seconds|default:0 }},
keepaliveUrl: "/session/keepalive/",
loginUrl: "/accounts/login/"
};
</script>
{% endif %}
{% block pre_shell %}{% endblock %}
{% if portal_trial_enabled %}
<div class="app-trial-banner{% if portal_trial_expired %} is-expired{% endif %}">
@@ -93,8 +103,29 @@
</div>
</div>
</div>
<div class="confirm-modal" id="app-session-warning-modal" hidden aria-hidden="true">
<div class="confirm-backdrop"></div>
<div class="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="app-session-warning-title" aria-describedby="app-session-warning-copy">
<div class="confirm-dialog-head">
<p class="action-progress-kicker">{% trans "Sitzung" %}</p>
<h2 id="app-session-warning-title">{% trans "Ihre Sitzung läuft bald ab" %}</h2>
</div>
<p class="confirm-message" id="app-session-warning-copy">
{% trans "Sie sind weiterhin angemeldet, aber diese Sitzung wird bald ablaufen. Bleiben Sie aktiv, wenn Sie weiterarbeiten möchten." %}
</p>
<p class="confirm-message" id="app-session-warning-countdown"></p>
<div class="confirm-actions">
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button class="btn btn-secondary" type="submit">{% trans "Abmelden" %}</button>
</form>
<button class="btn btn-primary" type="button" id="app-session-warning-extend">{% trans "Angemeldet bleiben" %}</button>
</div>
</div>
</div>
<script src="{% static 'workflows/js/confirm_dialog.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'workflows/js/action_progress.js' %}?v={{ static_asset_version }}"></script>
<script src="{% static 'workflows/js/session_warning.js' %}?v={{ static_asset_version }}"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -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)

View File

@@ -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/<int:notification_id>/read/', views.mark_notification_read, name='mark_notification_read'),

View File

@@ -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: