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';
+
+
+
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 @@
+
+
+
+
+
+
+
+ Sessie verloopt bijna
+
+
+
+ Wegens inactiviteit wordt u binnenkort automatisch afgemeld conform het beveiligingsbeleid. Klik op Blijf Ingelogd om uw sessie te verlengen.
+
+
+
+
+
Verloopt over:
+
02:00
+
+
+
+
+
+
+
+
+
+
+
+