feat: add session expiry warning
This commit is contained in:
@@ -19,5 +19,12 @@ def role_context(request):
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
context.update({'header_notifications': [], 'header_unread_notification_count': 0})
|
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
|
return context
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class RateLimitMiddleware:
|
|||||||
class AuthSessionHardeningMiddleware:
|
class AuthSessionHardeningMiddleware:
|
||||||
EXEMPT_PREFIXES = (
|
EXEMPT_PREFIXES = (
|
||||||
'/healthz/',
|
'/healthz/',
|
||||||
|
'/session/keepalive/',
|
||||||
'/i18n/',
|
'/i18n/',
|
||||||
'/accounts/login/',
|
'/accounts/login/',
|
||||||
'/accounts/logout/',
|
'/accounts/logout/',
|
||||||
|
|||||||
82
backend/workflows/static/workflows/js/session_warning.js
Normal file
82
backend/workflows/static/workflows/js/session_warning.js
Normal 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);
|
||||||
|
})();
|
||||||
@@ -14,6 +14,16 @@
|
|||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body{% block body_attrs %}{% endblock %}>
|
<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 %}
|
{% block pre_shell %}{% endblock %}
|
||||||
{% if portal_trial_enabled %}
|
{% if portal_trial_enabled %}
|
||||||
<div class="app-trial-banner{% if portal_trial_expired %} is-expired{% endif %}">
|
<div class="app-trial-banner{% if portal_trial_expired %} is-expired{% endif %}">
|
||||||
@@ -93,8 +103,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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/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/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 %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
49
backend/workflows/tests/test_session_warning.py
Normal file
49
backend/workflows/tests/test_session_warning.py
Normal 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)
|
||||||
@@ -4,6 +4,7 @@ from . import error_views, views
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('healthz/', views.healthz, name='healthz'),
|
path('healthz/', views.healthz, name='healthz'),
|
||||||
|
path('session/keepalive/', views.session_keepalive, name='session_keepalive'),
|
||||||
path('', views.home, name='home'),
|
path('', views.home, name='home'),
|
||||||
path('account/', views.account_profile_page, name='account_profile_page'),
|
path('account/', views.account_profile_page, name='account_profile_page'),
|
||||||
path('notifications/<int:notification_id>/read/', views.mark_notification_read, name='mark_notification_read'),
|
path('notifications/<int:notification_id>/read/', views.mark_notification_read, name='mark_notification_read'),
|
||||||
|
|||||||
@@ -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())
|
UserNotification.objects.filter(user=request.user, read_at__isnull=True).update(read_at=timezone.now())
|
||||||
return _redirect_back(request, 'home')
|
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):
|
def healthz(request):
|
||||||
db_ok = True
|
db_ok = True
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user