diff --git a/admin.php b/admin.php index 8f6fdfc..f615c1b 100644 --- a/admin.php +++ b/admin.php @@ -238,6 +238,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } } + + // 6. Sessie- & Beveiligingsinstellingen opslaan (ISO 27001 / NIST SP 800-63B) + elseif ($action === 'save_security_settings') { + $timeout = (int)($_POST['session_idle_timeout'] ?? 1800); + if (!in_array($timeout, [900, 1800, 3600, 7200], true)) { + $timeout = 1800; + } + set_system_setting('session_idle_timeout', $timeout); + + fociz_log_audit($userId, 'SETTINGS_UPDATED', 'security_settings', null, ['session_idle_timeout' => $timeout], 'SUCCESS', $adminUser['email']); + + $message = "Sessie- en beveiligingsinstellingen zijn succesvol opgeslagen."; + $messageType = 'success'; + } } } @@ -246,6 +260,7 @@ $appConfig = microsoft_get_app_config(); $companyJiraDomain = jira_get_company_domain(); $workdaySettings = get_workday_settings(); $aiSettings = get_ai_settings(); +$sessionIdleTimeout = get_session_idle_timeout(); // Redirect URI $redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php'; @@ -1128,6 +1143,97 @@ require_once __DIR__ . '/includes/header.php'; + + +
+
+
+
+ +
+
+

Sessie Inactiviteit & Beveiligingsbeleid

+

Automatische afmelding bij inactiviteit conform NIST SP 800-63B, OWASP & ISO 27001

+
+
+ + + SOC 2 / ISO 27001 Compliant + +
+ +
+ + + +
+ + +
+ + + + + + + + + + + +
+
+ + +
+
+ + Actieve beveiligingsmaatregelen: +
+
    +
  • Interactieve waarschuwing: 2 minuten (120 sec) voor time-out verschijnt een aftelmodal. Actieve gebruikers kunnen met 1 klik ingelogd blijven.
  • +
  • Periodieke Sessierotatie: Sessie-ID's worden conform OWASP ASVS v4.0 automatisch elke 30 minuten geroteerd tegen session hijacking.
  • +
  • Absolute daglimiet: Maximaal 10 uur aaneengesloten sessie per kalenderdag; om middernacht vervalt elke sessie automatisch.
  • +
  • Tabblad-synchronisatie: Activiteit in één browsertabblad verlengt automatisch alle open Fociz-tabbladen.
  • +
+
+ +
+ +
+
+
@@ -1193,6 +1299,21 @@ require_once __DIR__ . '/includes/header.php'; +
+ Sessie Idle Timeout + min +
+ +
+ Sessie ID Rotatie + Elke 30 min +
+ +
+ Wachtwoordbeleid + NIST 12+ tekens +
+
Database Versie MariaDB 10.x diff --git a/api/session.php b/api/session.php new file mode 100644 index 0000000..23158b9 --- /dev/null +++ b/api/session.php @@ -0,0 +1,72 @@ + true, + 'remaining' => $idleTimeout, + 'timeout' => $idleTimeout, + 'timestamp' => $now + ], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($action === 'status') { + $lastActivity = (int)($_SESSION['last_activity'] ?? $now); + $elapsed = $now - $lastActivity; + $remaining = max(0, $idleTimeout - $elapsed); + + echo json_encode([ + 'success' => true, + 'remaining' => $remaining, + 'timeout' => $idleTimeout, + 'timestamp' => $now + ], JSON_UNESCAPED_UNICODE); + exit; +} + +if ($action === 'logout') { + try { + $db = get_db_connection(); + $stmt = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?"); + $stmt->execute([$userId]); + + fociz_log_audit($userId, 'LOGOUT_SESSION_GUARD', 'users', $userId, ['ip' => fociz_get_client_ip()], 'SUCCESS', $currentUser['email'] ?? null); + } catch (Throwable $e) { + error_log("Session guard logout error: " . $e->getMessage()); + } + + clear_day_session_cookie(); + $_SESSION = []; + if (session_status() === PHP_SESSION_ACTIVE) { + @session_regenerate_id(true); + @session_destroy(); + } + + echo json_encode(['success' => true, 'message' => 'Succesvol uitgelogd.'], JSON_UNESCAPED_UNICODE); + exit; +} + +http_response_code(400); +echo json_encode(['success' => false, 'error' => 'Onbekende sessieactie.'], JSON_UNESCAPED_UNICODE); +exit; + diff --git a/assets/js/session-guard.js b/assets/js/session-guard.js new file mode 100644 index 0000000..d44309c --- /dev/null +++ b/assets/js/session-guard.js @@ -0,0 +1,228 @@ +/** + * Fociz Session Guard (Release v1.6) + * Realtime beveiligingsbeheer voor sessie-inactiviteit conform NIST SP 800-63B / ISO 27001 + * - Waarschuwt de gebruiker 2 minuten voor het verlopen met aftelklok + * - Throttled keepalive pings tijdens actieve interactie + * - Synchronisatie over meerdere open browsertabbladen via localStorage + */ + +(function () { + 'use strict'; + + // Script configuratie uitlezen + var scriptTag = document.currentScript || document.querySelector('script[src*="session-guard.js"]'); + var rawTimeout = scriptTag ? parseInt(scriptTag.getAttribute('data-timeout'), 10) : 1800; + var idleTimeout = (!isNaN(rawTimeout) && rawTimeout > 0) ? rawTimeout : 1800; + + // Waarschuwing 120 seconden voor afloop (of de helft indien timeout kort is) + var warningSeconds = idleTimeout > 240 ? 120 : Math.max(10, Math.floor(idleTimeout / 2)); + + var lastActivity = Date.now(); + var lastPingTime = Date.now(); + var isWarningShowing = false; + var keepaliveInFlight = false; + + // Synchronisatie met andere tabbladen + var storedActivity = localStorage.getItem('fociz_session_last_activity'); + if (storedActivity) { + var parsed = parseInt(storedActivity, 10); + if (!isNaN(parsed) && parsed > 0 && parsed <= Date.now()) { + lastActivity = parsed; + } + } + + // Modal DOM elementen + var modalEl = null; + var countdownEl = null; + var keepaliveBtn = null; + var logoutBtn = null; + + function initDom() { + modalEl = document.getElementById('focizSessionModal'); + countdownEl = document.getElementById('focizSessionCountdown'); + keepaliveBtn = document.getElementById('focizSessionKeepaliveBtn'); + logoutBtn = document.getElementById('focizSessionLogoutBtn'); + + if (keepaliveBtn) { + keepaliveBtn.addEventListener('click', function (e) { + e.preventDefault(); + sendKeepalive(true); + }); + } + + if (logoutBtn) { + logoutBtn.addEventListener('click', function (e) { + e.preventDefault(); + performLogout(); + }); + } + } + + // Luister naar tabblad synchronisatie + window.addEventListener('storage', function (e) { + if (e.key === 'fociz_session_last_activity') { + var updated = parseInt(e.newValue, 10); + if (!isNaN(updated) && updated > lastActivity) { + lastActivity = updated; + if (isWarningShowing) { + hideModal(); + } + } + } else if (e.key === 'fociz_session_logged_out') { + window.location.href = '/auth/login.php?expired=1'; + } + }); + + // Formatteer seconden naar mm:ss + function formatTime(seconds) { + var m = Math.floor(seconds / 60); + var s = seconds % 60; + return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; + } + + // Toon modal + function showModal(remainingSeconds) { + if (!modalEl) initDom(); + if (!modalEl) return; + + isWarningShowing = true; + modalEl.classList.remove('hidden'); + modalEl.classList.add('flex'); + + if (countdownEl) { + countdownEl.textContent = formatTime(remainingSeconds); + } + } + + // Verberg modal + function hideModal() { + if (!modalEl) return; + isWarningShowing = false; + modalEl.classList.add('hidden'); + modalEl.classList.remove('flex'); + } + + // Verzend keepalive naar de backend + function sendKeepalive(manualClick) { + if (keepaliveInFlight) return; + keepaliveInFlight = true; + + if (manualClick && keepaliveBtn) { + keepaliveBtn.disabled = true; + keepaliveBtn.textContent = 'Verlengen...'; + } + + fetch('/api/session.php?action=keepalive', { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json' + } + }) + .then(function (res) { + if (res.status === 401) { + // Reeds verlopen + localStorage.setItem('fociz_session_logged_out', Date.now().toString()); + window.location.href = '/auth/login.php?expired=1'; + return null; + } + return res.json(); + }) + .then(function (data) { + keepaliveInFlight = false; + if (manualClick && keepaliveBtn) { + keepaliveBtn.disabled = false; + keepaliveBtn.textContent = 'Blijf Ingelogd'; + } + + if (data && data.success) { + lastActivity = Date.now(); + lastPingTime = Date.now(); + localStorage.setItem('fociz_session_last_activity', lastActivity.toString()); + hideModal(); + } + }) + .catch(function (err) { + keepaliveInFlight = false; + if (manualClick && keepaliveBtn) { + keepaliveBtn.disabled = false; + keepaliveBtn.textContent = 'Blijf Ingelogd'; + } + console.error('Session keepalive error:', err); + }); + } + + // Direct uitloggen + function performLogout() { + localStorage.setItem('fociz_session_logged_out', Date.now().toString()); + fetch('/api/session.php?action=logout', { + method: 'POST', + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }).finally(function () { + window.location.href = '/auth/logout.php'; + }); + } + + // Registreer gebruikersactiviteit (throttled) + function registerUserActivity() { + var now = Date.now(); + + // Als de waarschuwingsmodal actief is, negeren we vage muisbewegingen; + // de gebruiker moet expliciet op 'Blijf Ingelogd' klikken conform beveiligingsnormen. + if (isWarningShowing) return; + + // Lokale activiteit bijwerken (maximaal eens per 3 seconden wegschrijven) + if (now - lastActivity > 3000) { + lastActivity = now; + localStorage.setItem('fociz_session_last_activity', now.toString()); + } + + // Server keepalive: stuur op de achtergrond een ping als er activiteit is + // én er minimaal 60 seconden (of idleTimeout / 4) sinds de vorige serverping is verstreken. + var minPingInterval = Math.min(60000, Math.max(15000, (idleTimeout * 1000) / 4)); + if (now - lastPingTime > minPingInterval) { + lastPingTime = now; + sendKeepalive(false); + } + } + + // Event listeners voor menselijke interactie + var events = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart']; + events.forEach(function (evt) { + window.addEventListener(evt, registerUserActivity, { passive: true }); + }); + + // Hoofdtimer interval (elke seconde) + function tick() { + var now = Date.now(); + var elapsedSec = Math.floor((now - lastActivity) / 1000); + var remainingSec = idleTimeout - elapsedSec; + + if (remainingSec <= 0) { + // Sessie officieel verlopen + localStorage.setItem('fociz_session_logged_out', now.toString()); + window.location.href = '/auth/login.php?expired=1'; + return; + } + + if (remainingSec <= warningSeconds) { + // Binnen waarschuwingsvenster + showModal(remainingSec); + } else if (isWarningShowing) { + // Waarschuwing niet meer van toepassing (bijv. verlengd vanuit ander tabblad) + hideModal(); + } + } + + // Starten zodra DOM gereed is + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + initDom(); + setInterval(tick, 1000); + }); + } else { + initDom(); + setInterval(tick, 1000); + } +})(); + diff --git a/auth/login.php b/auth/login.php index 60ed6cd..9c158ab 100644 --- a/auth/login.php +++ b/auth/login.php @@ -22,7 +22,7 @@ if ($currentUser) { $error = null; $infoMessage = null; if (isset($_GET['expired'])) { - $infoMessage = "Uw dagsessie is beëindigd. Log opnieuw in om verder te gaan."; + $infoMessage = "Uw sessie is beëindigd wegens inactiviteit of een dagwissel. Log opnieuw in om verder te gaan."; } if (isset($_GET['ip_changed'])) { $infoMessage = "U bent ingelogd vanaf een andere netwerklocatie. Uit veiligheidsoverwegingen dient u opnieuw in te loggen met 2FA."; @@ -157,6 +157,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $_SESSION['auth_fingerprint'] = fociz_get_client_fingerprint(); $_SESSION['session_expires_at'] = strtotime('today 23:59:59'); $_SESSION['last_activity'] = time(); + $_SESSION['login_time'] = time(); + $_SESSION['last_rotated'] = time(); // Dagsessie-token uitgeven (strikt geldig tot 23:59:59 van vandaag) issue_day_session_token((int)$user['id']); diff --git a/includes/auth.php b/includes/auth.php index a0f0afb..2fa3a8a 100644 --- a/includes/auth.php +++ b/includes/auth.php @@ -10,6 +10,7 @@ if (basename($_SERVER['SCRIPT_FILENAME'] ?? '') === 'auth.php') { require_once __DIR__ . '/../config/database.php'; require_once __DIR__ . '/totp.php'; +require_once __DIR__ . '/settings.php'; /** * Bepaalt of de huidige verbinding HTTPS gebruikt (inclusief reverse proxies / OpenResty / Docker) @@ -472,10 +473,13 @@ function get_logged_in_user(): ?array { // 2. Controleer actieve PHP sessie (mits behorend bij vandaag én hetzelfde IP/fingerprint) if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) { - // Inactiviteitstimeout (ISO 27001 / SOC 2): 120 minuten (7200s) - $maxIdleSeconds = 7200; + // Dynamische inactiviteitstimeout conform NIST SP 800-63B / ISO 27001 / OWASP ASVS v4.0 + $maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800; if (!empty($_SESSION['last_activity']) && ($now - (int)$_SESSION['last_activity']) > $maxIdleSeconds) { - fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_IDLE_TIMEOUT', 'users', (int)$_SESSION['user_id'], ['idle_seconds' => $now - (int)$_SESSION['last_activity']], 'WARNING'); + fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_IDLE_TIMEOUT', 'users', (int)$_SESSION['user_id'], ['idle_seconds' => $now - (int)$_SESSION['last_activity'], 'max_idle' => $maxIdleSeconds], 'WARNING'); + // Invalideer dagsessie token in database zodat herstel via cookie uitgesloten is + $stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?"); + $stmtClear->execute([$_SESSION['user_id']]); $_SESSION = ['session_was_expired' => true, 'idle_timeout' => true]; clear_day_session_cookie(); if (session_status() === PHP_SESSION_ACTIVE) { @@ -484,6 +488,20 @@ function get_logged_in_user(): ?array { return null; } + // Absolute sessielimiet (NIST SP 800-63B): max 10 uur aaneengesloten sessie per dag + $maxSessionDuration = 36000; + if (!empty($_SESSION['login_time']) && ($now - (int)$_SESSION['login_time']) > $maxSessionDuration) { + fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_ABSOLUTE_TIMEOUT', 'users', (int)$_SESSION['user_id'], ['duration' => $now - (int)$_SESSION['login_time']], 'WARNING'); + $stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?"); + $stmtClear->execute([$_SESSION['user_id']]); + $_SESSION = ['session_was_expired' => true, 'absolute_timeout' => true]; + clear_day_session_cookie(); + if (session_status() === PHP_SESSION_ACTIVE) { + @session_regenerate_id(true); + } + return null; + } + // IP-verificatie: als het IP/locatie tussentijds verandert, moet er opnieuw worden ingelogd if (!empty($_SESSION['auth_fingerprint']) && $_SESSION['auth_fingerprint'] !== $clientFingerprint) { $_SESSION = ['session_was_expired' => true, 'ip_changed' => true]; @@ -498,6 +516,20 @@ function get_logged_in_user(): ?array { $stmt->execute([$_SESSION['user_id']]); $user = $stmt->fetch(); if ($user) { + // Periodieke sessie-ID rotatie (OWASP ASVS v4.0 §3.3): elke 30 minuten ter voorkoming van session hijacking + if (empty($_SESSION['last_rotated'])) { + $_SESSION['last_rotated'] = $now; + } elseif (($now - (int)$_SESSION['last_rotated']) > 1800) { + if (session_status() === PHP_SESSION_ACTIVE) { + @session_regenerate_id(true); + } + $_SESSION['last_rotated'] = $now; + } + + if (empty($_SESSION['login_time'])) { + $_SESSION['login_time'] = $now; + } + $_SESSION['auth_date'] = $today; $_SESSION['auth_fingerprint'] = $clientFingerprint; $_SESSION['session_expires_at'] = strtotime('today 23:59:59'); @@ -510,7 +542,7 @@ function get_logged_in_user(): ?array { return null; } - // 3. Fallback dagsessie-token: herstelt sessie na browserherstart of server idle timeout (uitsluitend vandaag!) + // 3. Fallback dagsessie-token: herstelt sessie na browserherstart (uitsluitend vandaag!) $dayToken = $_COOKIE['fociz_day_session'] ?? ($_COOKIE['fociz_remember'] ?? null); if (!empty($dayToken) && is_string($dayToken)) { $stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, avatar_url, theme, notification_settings, default_jira_filter_code, fociz_widget_layout, created_at FROM users WHERE remember_token = ? AND remember_token_expires_at >= NOW() AND DATE(remember_token_expires_at) = ?"); @@ -534,6 +566,8 @@ function get_logged_in_user(): ?array { $_SESSION['auth_fingerprint'] = $clientFingerprint; $_SESSION['session_expires_at'] = strtotime('today 23:59:59'); $_SESSION['last_activity'] = $now; + $_SESSION['login_time'] = $now; + $_SESSION['last_rotated'] = $now; return $user; } // Token is ongeldig of van gisteren diff --git a/includes/footer.php b/includes/footer.php index 8e1b645..3747b11 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -16,6 +16,49 @@ + + + + + + +