Merge pull request 'Dev' (#50) from dev into main

Reviewed-on: #50
This commit was merged in pull request #50.
This commit is contained in:
2026-09-11 13:52:21 +00:00
9 changed files with 666 additions and 20 deletions
+121
View File
@@ -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';
</form>
</div>
</div>
<!-- 6. Sessie Inactiviteit & Beveiligingsbeleid (Release v1.6) -->
<div class="fociz-card p-6 sm:p-7 space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-xl bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/60 flex items-center justify-center text-amber-600 dark:text-amber-400 font-bold shadow-xs">
<i data-lucide="shield-alert" class="w-6 h-6"></i>
</div>
<div>
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">Sessie Inactiviteit & Beveiligingsbeleid</h2>
<p class="text-xs text-slate-500 dark:text-slate-400">Automatische afmelding bij inactiviteit conform NIST SP 800-63B, OWASP & ISO 27001</p>
</div>
</div>
<span class="px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-800/60 text-xs font-bold self-start sm:self-auto flex items-center space-x-1.5">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
<span>SOC 2 / ISO 27001 Compliant</span>
</span>
</div>
<form method="POST" action="/admin.php" class="space-y-5">
<input type="hidden" name="action" value="save_security_settings">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<div>
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-2">
Inactiviteitstimeout (Session Idle Expiration)
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<!-- 15 minuten -->
<label class="relative flex items-start p-3.5 rounded-2xl border cursor-pointer transition <?= $sessionIdleTimeout === 900 ? 'bg-amber-50/60 dark:bg-amber-950/20 border-amber-400 dark:border-amber-600 ring-1 ring-amber-400/40' : 'bg-slate-50 dark:bg-[#0c0f17] border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700' ?>">
<input type="radio" name="session_idle_timeout" value="900" <?= $sessionIdleTimeout === 900 ? 'checked' : '' ?> class="mt-0.5 text-amber-600 focus:ring-amber-500 border-slate-300 dark:border-slate-700">
<div class="ml-3">
<span class="block text-xs font-bold text-slate-900 dark:text-white">15 minuten</span>
<span class="block text-[11px] text-slate-500 dark:text-slate-400 mt-0.5">Strikt — ISO 27001 & Overheid BIO richtlijn</span>
</div>
</label>
<!-- 30 minuten (aanbevolen) -->
<label class="relative flex items-start p-3.5 rounded-2xl border cursor-pointer transition <?= $sessionIdleTimeout === 1800 ? 'bg-indigo-50/60 dark:bg-indigo-950/20 border-indigo-400 dark:border-indigo-600 ring-1 ring-indigo-400/40' : 'bg-slate-50 dark:bg-[#0c0f17] border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700' ?>">
<input type="radio" name="session_idle_timeout" value="1800" <?= $sessionIdleTimeout === 1800 ? 'checked' : '' ?> class="mt-0.5 text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700">
<div class="ml-3">
<div class="flex items-center space-x-1.5">
<span class="text-xs font-bold text-slate-900 dark:text-white">30 minuten</span>
<span class="px-1.5 py-0.2 rounded text-[9px] font-extrabold bg-indigo-100 dark:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300">Aanbevolen</span>
</div>
<span class="block text-[11px] text-slate-500 dark:text-slate-400 mt-0.5">NIST SP 800-63B & OWASP ASVS v4.0 standaard</span>
</div>
</label>
<!-- 60 minuten -->
<label class="relative flex items-start p-3.5 rounded-2xl border cursor-pointer transition <?= $sessionIdleTimeout === 3600 ? 'bg-indigo-50/60 dark:bg-indigo-950/20 border-indigo-400 dark:border-indigo-600 ring-1 ring-indigo-400/40' : 'bg-slate-50 dark:bg-[#0c0f17] border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700' ?>">
<input type="radio" name="session_idle_timeout" value="3600" <?= $sessionIdleTimeout === 3600 ? 'checked' : '' ?> class="mt-0.5 text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700">
<div class="ml-3">
<span class="block text-xs font-bold text-slate-900 dark:text-white">60 minuten</span>
<span class="block text-[11px] text-slate-500 dark:text-slate-400 mt-0.5">Kantoorstandaard — Balans tussen gebruiksgemak & veiligheid</span>
</div>
</label>
<!-- 120 minuten -->
<label class="relative flex items-start p-3.5 rounded-2xl border cursor-pointer transition <?= $sessionIdleTimeout === 7200 ? 'bg-indigo-50/60 dark:bg-indigo-950/20 border-indigo-400 dark:border-indigo-600 ring-1 ring-indigo-400/40' : 'bg-slate-50 dark:bg-[#0c0f17] border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700' ?>">
<input type="radio" name="session_idle_timeout" value="7200" <?= $sessionIdleTimeout === 7200 ? 'checked' : '' ?> class="mt-0.5 text-indigo-600 focus:ring-indigo-500 border-slate-300 dark:border-slate-700">
<div class="ml-3">
<span class="block text-xs font-bold text-slate-900 dark:text-white">120 minuten (2 uur)</span>
<span class="block text-[11px] text-slate-500 dark:text-slate-400 mt-0.5">Flexibel — Minimale onderbreking bij geconcentreerd werk</span>
</div>
</label>
</div>
</div>
<!-- Beveiligingsmaatregelen infopanel -->
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80 space-y-2 text-xs">
<div class="font-bold text-slate-800 dark:text-slate-200 flex items-center space-x-1.5">
<i data-lucide="info" class="w-3.5 h-3.5 text-indigo-500"></i>
<span>Actieve beveiligingsmaatregelen:</span>
</div>
<ul class="space-y-1.5 text-[11px] text-slate-600 dark:text-slate-400 list-disc list-inside">
<li><strong>Interactieve waarschuwing:</strong> 2 minuten (120 sec) voor time-out verschijnt een aftelmodal. Actieve gebruikers kunnen met 1 klik ingelogd blijven.</li>
<li><strong>Periodieke Sessierotatie:</strong> Sessie-ID's worden conform OWASP ASVS v4.0 automatisch elke 30 minuten geroteerd tegen session hijacking.</li>
<li><strong>Absolute daglimiet:</strong> Maximaal 10 uur aaneengesloten sessie per kalenderdag; om middernacht vervalt elke sessie automatisch.</li>
<li><strong>Tabblad-synchronisatie:</strong> Activiteit in één browsertabblad verlengt automatisch alle open Fociz-tabbladen.</li>
</ul>
</div>
<div class="flex justify-end pt-2">
<button type="submit" class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
Sessie-instellingen Opslaan
</button>
</div>
</form>
</div>
</div>
<!-- Rechterkolom: Systeem & Team Monitor (4 cols) -->
@@ -1193,6 +1299,21 @@ require_once __DIR__ . '/includes/header.php';
</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
<span>Sessie Idle Timeout</span>
<span class="text-indigo-600 dark:text-indigo-400 font-bold"><?= (int)($sessionIdleTimeout / 60) ?> min</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
<span>Sessie ID Rotatie</span>
<span class="text-teal-600 dark:text-teal-400 font-bold">Elke 30 min</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
<span>Wachtwoordbeleid</span>
<span class="text-teal-600 dark:text-teal-400 font-bold">NIST 12+ tekens</span>
</div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
<span>Database Versie</span>
<span class="text-slate-500 dark:text-slate-400 font-mono text-[11px]">MariaDB 10.x</span>
+20 -1
View File
@@ -42,8 +42,27 @@ try {
exit;
}
// Valideer en sanitize layout items inclusief optionele custom widget titel
$cleanedLayout = [];
foreach ($layout as $item) {
if (!is_array($item) || empty($item['id'])) continue;
$cleaned = [
'id' => substr(trim((string)$item['id']), 0, 64),
'enabled' => !empty($item['enabled']),
'col_span' => in_array((int)($item['col_span'] ?? 6), [4, 5, 6, 7, 8, 12]) ? (int)$item['col_span'] : 6,
'order' => (int)($item['order'] ?? 1),
];
if (!empty($item['custom_title'])) {
$customTitle = trim(strip_tags((string)$item['custom_title']));
if ($customTitle !== '') {
$cleaned['custom_title'] = substr($customTitle, 0, 50);
}
}
$cleanedLayout[] = $cleaned;
}
$stmt = $db->prepare("UPDATE users SET fociz_widget_layout = ? WHERE id = ?");
$stmt->execute([json_encode($layout), $user['id']]);
$stmt->execute([json_encode($cleanedLayout, JSON_UNESCAPED_UNICODE), $user['id']]);
echo json_encode(['success' => true, 'message' => 'Widget indeling succesvol opgeslagen']);
exit;
+72
View File
@@ -0,0 +1,72 @@
<?php
/**
* Fociz Session Guard API (Release v1.6)
* Ondersteunt realtime session keepalive, inactiviteitstimeout status en veilige logout
* Conform NIST SP 800-63B, OWASP ASVS v4.0 en ISO 27001
*/
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/settings.php';
header('Content-Type: application/json; charset=utf-8');
$currentUser = require_auth(true);
$userId = (int)($currentUser['id'] ?? 0);
$action = $_GET['action'] ?? ($_POST['action'] ?? 'keepalive');
$idleTimeout = get_session_idle_timeout();
$now = time();
if ($action === 'keepalive' || $action === 'ping') {
// Verleng de activiteitstimer van de sessie
$_SESSION['last_activity'] = $now;
echo json_encode([
'success' => 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;
+228
View File
@@ -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);
}
})();
+3 -1
View File
@@ -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']);
+38 -4
View File
@@ -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
+43
View File
@@ -16,6 +16,49 @@
<!-- Fociz Agenda Notificaties Engine -->
<script src="/assets/js/notifications.js"></script>
<?php
$sessionIdleTimeout = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
?>
<!-- Fociz Inactiviteitswaarschuwing Modal (ISO 27001 / NIST SP 800-63B) -->
<div id="focizSessionModal" class="fixed inset-0 z-[9999] bg-slate-950/80 backdrop-blur-md hidden items-center justify-center p-4">
<div class="bg-white dark:bg-[#111622] border border-amber-500/30 dark:border-amber-500/30 rounded-3xl w-full max-w-md shadow-2xl p-6 sm:p-7 text-center relative overflow-hidden">
<div class="w-14 h-14 rounded-2xl bg-amber-500/10 dark:bg-amber-500/20 border border-amber-500/30 text-amber-600 dark:text-amber-400 flex items-center justify-center mx-auto mb-4">
<svg class="w-7 h-7 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight mb-2">
Sessie verloopt bijna
</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mb-5 leading-relaxed">
Wegens inactiviteit wordt u binnenkort automatisch afgemeld conform het beveiligingsbeleid. Klik op <strong class="text-slate-800 dark:text-slate-200">Blijf Ingelogd</strong> om uw sessie te verlengen.
</p>
<div class="inline-flex items-center space-x-2 px-4 py-2 rounded-2xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-700 dark:text-amber-300 font-mono text-xs font-bold mb-6">
<svg class="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" stroke-width="2"></circle>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6l4 2"></path>
</svg>
<span>Verloopt over:</span>
<span id="focizSessionCountdown" class="text-amber-600 dark:text-amber-400 font-black tracking-wider">02:00</span>
</div>
<div class="grid grid-cols-2 gap-3">
<button type="button" id="focizSessionLogoutBtn" class="w-full py-2.5 px-4 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold transition cursor-pointer">
Nu Uitloggen
</button>
<button type="button" id="focizSessionKeepaliveBtn" class="w-full py-2.5 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition shadow-xs cursor-pointer">
Blijf Ingelogd
</button>
</div>
</div>
</div>
<!-- Fociz Session Guard Script (NIST SP 800-63B / ISO 27001) -->
<script src="/assets/js/session-guard.js" data-timeout="<?= $sessionIdleTimeout ?>"></script>
<script>
// Lucide Icons initialiseren
if (typeof lucide !== 'undefined') {
+10
View File
@@ -129,3 +129,13 @@ function get_ai_settings(): array {
];
}
/**
* Haalt geconfigureerde sessie idle timeout in seconden op conform NIST SP 800-63B / ISO 27001
* Toegestane waarden: 900 (15m), 1800 (30m - default), 3600 (60m), 7200 (120m)
*/
function get_session_idle_timeout(): int {
$timeout = (int)get_system_setting('session_idle_timeout', 1800);
$validTimeouts = [900, 1800, 3600, 7200];
return in_array($timeout, $validTimeouts, true) ? $timeout : 1800;
}
+131 -14
View File
@@ -289,18 +289,34 @@ $savedLayoutJson = $currentUser['fociz_widget_layout'] ?? '';
if (!empty($savedLayoutJson)) {
$decoded = json_decode($savedLayoutJson, true);
if (is_array($decoded)) {
$savedOrder = 1;
$seen = [];
foreach ($decoded as $item) {
$wId = $item['id'] ?? '';
if (isset($userWidgetLayout[$wId])) {
$userWidgetLayout[$wId]['enabled'] = !empty($item['enabled']);
if (!empty($item['col_span'])) $userWidgetLayout[$wId]['col_span'] = (int)$item['col_span'];
if (isset($item['order'])) $userWidgetLayout[$wId]['order'] = (int)$item['order'];
$userWidgetLayout[$wId]['order'] = isset($item['order']) ? (int)$item['order'] : $savedOrder;
if (!empty($item['custom_title'])) {
$userWidgetLayout[$wId]['custom_title'] = trim($item['custom_title']);
$userWidgetLayout[$wId]['title'] = trim($item['custom_title']);
}
$seen[$wId] = true;
$savedOrder++;
}
}
foreach ($userWidgetLayout as $wId => &$wData) {
if (!isset($seen[$wId])) {
$wData['order'] = $savedOrder++;
}
}
unset($wData);
}
}
uasort($userWidgetLayout, function($a, $b) {
return ($a['order'] ?? 0) <=> ($b['order'] ?? 0);
$diff = ($a['order'] ?? 0) <=> ($b['order'] ?? 0);
if ($diff !== 0) return $diff;
return strcmp($a['id'] ?? '', $b['id'] ?? '');
});
// Bereken dagstatistieken
@@ -545,19 +561,45 @@ for ($h = $startHour; $h <= $endHour; $h++) {
<!-- Links: Drag Handle Grip + Widget Titel -->
<div class="widget-drag-handle flex items-center space-x-1.5 cursor-grab active:cursor-grabbing text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 py-0.5 px-1 -ml-1 rounded-lg hover:bg-slate-200/50 dark:hover:bg-slate-800/50 transition group/drag touch-none" data-widget-id="<?= htmlspecialchars($wId) ?>" title="Sleep om te verplaatsen">
<i data-lucide="grip-vertical" class="w-3.5 h-3.5 text-slate-400 group-hover/drag:text-indigo-500 transition-colors flex-shrink-0"></i>
<span class="text-xs font-bold text-slate-700 dark:text-slate-300 tracking-wide truncate max-w-[160px] sm:max-w-[240px]"><?= htmlspecialchars($widget['title']) ?></span>
<span id="widgetTitleText_<?= htmlspecialchars($wId) ?>" class="text-xs font-bold text-slate-700 dark:text-slate-300 tracking-wide truncate max-w-[160px] sm:max-w-[240px]"><?= htmlspecialchars($widget['title']) ?></span>
</div>
<!-- Rechts: Tandwieltje (Formaat & Verbergen) & Inklappen -->
<!-- Rechts: Tandwieltje (Formaat, Naam & Verbergen) & Inklappen -->
<div class="flex items-center space-x-1 relative">
<!-- Tandwieltje Knop met Dropdown -->
<div class="relative">
<button type="button" onclick="toggleWidgetSettingsMenu('<?= $wId ?>', event)" class="widget-settings-btn p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800/60 transition cursor-pointer" title="Widget instellingen (formaat en verbergen)">
<button type="button" onclick="toggleWidgetSettingsMenu('<?= $wId ?>', event)" class="widget-settings-btn p-1 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-200/60 dark:hover:bg-slate-800/60 transition cursor-pointer" title="Widget instellingen (naam, formaat en verbergen)">
<i data-lucide="settings" class="w-3.5 h-3.5"></i>
</button>
<!-- Dropdown Popover Menu -->
<div id="widgetSettingsMenu_<?= htmlspecialchars($wId) ?>" class="widget-settings-dropdown hidden absolute right-0 top-full mt-1.5 z-50 w-52 p-2.5 rounded-2xl bg-white/95 dark:bg-[#111624]/95 backdrop-blur-md border border-slate-200 dark:border-slate-800 shadow-2xl space-y-2.5">
<div id="widgetSettingsMenu_<?= htmlspecialchars($wId) ?>" class="widget-settings-dropdown hidden absolute right-0 top-full mt-1.5 z-50 w-64 p-3 rounded-2xl bg-white/95 dark:bg-[#111624]/95 backdrop-blur-md border border-slate-200 dark:border-slate-800 shadow-2xl space-y-3">
<!-- 1. Naam / Titel Aanpassen -->
<div class="space-y-1.5 pb-2.5 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center justify-between">
<label for="widgetTitleInput_<?= htmlspecialchars($wId) ?>" class="text-[10px] font-bold uppercase tracking-wider text-slate-400">Naam aanpassen</label>
<button type="button" onclick="resetWidgetTitle('<?= $wId ?>', '<?= htmlspecialchars(addslashes($availableWidgets[$wId]['title'] ?? $widget['title']), ENT_QUOTES) ?>')" class="text-[10px] text-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400 font-medium cursor-pointer" title="Standaardnaam herstellen">Herstellen</button>
</div>
<div class="flex items-center space-x-1.5">
<input type="text"
id="widgetTitleInput_<?= htmlspecialchars($wId) ?>"
value="<?= htmlspecialchars($widget['title']) ?>"
data-default-title="<?= htmlspecialchars($availableWidgets[$wId]['title'] ?? $widget['title']) ?>"
placeholder="<?= htmlspecialchars($availableWidgets[$wId]['title'] ?? 'Widget titel') ?>"
maxlength="50"
onclick="event.stopPropagation();"
onkeydown="if(event.key==='Enter'){event.preventDefault();saveWidgetTitle('<?= $wId ?>');}"
class="flex-1 min-w-0 px-2.5 py-1.5 text-xs rounded-xl bg-slate-50 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 transition">
<button type="button"
onclick="saveWidgetTitle('<?= $wId ?>')"
class="px-2.5 py-1.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-xs transition flex-shrink-0 cursor-pointer shadow-2xs flex items-center justify-center"
title="Naam opslaan">
<i data-lucide="check" class="w-3.5 h-3.5"></i>
</button>
</div>
</div>
<!-- 2. Breedte instellen -->
<div>
<span class="text-[10px] font-bold uppercase tracking-wider text-slate-400 block mb-1.5">Breedte instellen</span>
<div class="grid grid-cols-4 gap-1">
@@ -568,6 +610,7 @@ for ($h = $startHour; $h <= $endHour; $h++) {
</div>
</div>
<!-- 3. Widget verbergen -->
<div class="pt-1.5 border-t border-slate-100 dark:border-slate-800/80">
<button type="button" onclick="hideWidgetOnScreen('<?= $wId ?>')" class="w-full flex items-center space-x-2 px-2 py-1.5 rounded-xl text-slate-600 dark:text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-500/10 transition cursor-pointer text-left text-xs font-semibold">
<i data-lucide="eye-off" class="w-3.5 h-3.5 flex-shrink-0"></i>
@@ -2250,12 +2293,18 @@ async function saveWidgetLayoutConfig() {
focizWidgets.forEach((item, idx) => {
item.order = idx + 1;
});
const payload = focizWidgets.map((w) => ({
id: w.id,
enabled: !!w.enabled,
col_span: parseInt(w.col_span, 10) || 6,
order: w.order
}));
const payload = focizWidgets.map((w) => {
const obj = {
id: w.id,
enabled: !!w.enabled,
col_span: parseInt(w.col_span, 10) || 6,
order: w.order
};
if (w.custom_title) {
obj.custom_title = w.custom_title;
}
return obj;
});
try {
localStorage.setItem('fociz_widget_layout_cache', JSON.stringify(payload));
@@ -2352,6 +2401,9 @@ function getActiveWidgetLayoutPayload() {
col_span: colSpan,
order: currentOrder++
};
if (item && item.custom_title) {
widgetObj.custom_title = item.custom_title;
}
payload.push(widgetObj);
if (item) {
item.order = widgetObj.order;
@@ -2366,12 +2418,16 @@ function getActiveWidgetLayoutPayload() {
focizWidgets.forEach(w => {
if (!seenIds.has(w.id)) {
seenIds.add(w.id);
payload.push({
const hiddenObj = {
id: w.id,
enabled: !!w.enabled,
col_span: parseInt(w.col_span, 10) || 6,
order: currentOrder++
});
};
if (w.custom_title) {
hiddenObj.custom_title = w.custom_title;
}
payload.push(hiddenObj);
}
});
@@ -2453,6 +2509,67 @@ function setWidgetSpan(wId, span) {
showWidgetToast(`Formaat aangepast naar ${spanDescriptions[span] || span + ' kolommen'}`, 'success');
}
// --- Widget Naam / Titel Aanpassen ---
function saveWidgetTitle(wId) {
const input = document.getElementById(`widgetTitleInput_${wId}`);
if (!input) return;
const defaultTitle = input.getAttribute('data-default-title') || '';
let newTitle = input.value.trim();
if (!newTitle) {
newTitle = defaultTitle;
input.value = defaultTitle;
}
// Directe DOM-update op de titelbalk van de widget
const titleEl = document.getElementById(`widgetTitleText_${wId}`);
if (titleEl) {
titleEl.textContent = newTitle;
}
// Werk de in-memory widget catalogus bij
const item = focizWidgets.find(x => x.id === wId);
if (item) {
if (newTitle !== defaultTitle) {
item.custom_title = newTitle;
item.title = newTitle;
} else {
delete item.custom_title;
item.title = defaultTitle;
}
}
// Sluit het instellingen menu
document.getElementById(`widgetSettingsMenu_${wId}`)?.classList.add('hidden');
// Automatisch opslaan naar database
persistWidgetLayoutAuto();
showWidgetToast(`Widgetnaam aangepast naar "${escapeHtml(newTitle)}"`, 'success');
}
function resetWidgetTitle(wId, defaultTitle) {
const input = document.getElementById(`widgetTitleInput_${wId}`);
if (input) {
input.value = defaultTitle;
}
const titleEl = document.getElementById(`widgetTitleText_${wId}`);
if (titleEl) {
titleEl.textContent = defaultTitle;
}
const item = focizWidgets.find(x => x.id === wId);
if (item) {
delete item.custom_title;
item.title = defaultTitle;
}
document.getElementById(`widgetSettingsMenu_${wId}`)?.classList.add('hidden');
persistWidgetLayoutAuto();
showWidgetToast('Standaard widgetnaam hersteld', 'info');
}
// --- Widget Instellingen Popover Menu (Tandwieltje) ---
function toggleWidgetSettingsMenu(wId, event) {
if (event) {