This commit is contained in:
2026-09-11 22:20:29 +02:00
parent 38701bf06a
commit fc1a211c73
9 changed files with 478 additions and 12 deletions
+259 -6
View File
@@ -295,15 +295,23 @@ function fociz_send_new_ip_verification(int $userId, string $email, string $name
function start_secure_session(): void {
if (session_status() === PHP_SESSION_NONE) {
// Dagsessie: 24 uur (86400s) zodat server-garbage collectors de sessie niet tussentijds wissen
ini_set('session.gc_maxlifetime', '86400');
ini_set('session.cookie_lifetime', '86400');
$isSecure = is_https_request();
// OWASP ASVS v4.0 §3.4 & SOC 2 CC6.6 Cryptografische Hardening
ini_set('session.sid_length', '48');
ini_set('session.sid_bits_per_character', '6');
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
ini_set('session.gc_maxlifetime', '86400');
ini_set('session.cookie_httponly', '1');
// Cookie prefix: __Host- garandeert dat cookies niet door subdomeinen kunnen worden overschreven
$sessionName = $isSecure ? '__Host-fociz_session' : 'fociz_session';
session_name($sessionName);
$isSecure = is_https_request();
session_set_cookie_params([
'lifetime' => 86400,
'lifetime' => 0, // Browser sessiecookie: vervalt direct bij afsluiten browser conform ISO 27001 / NIST SP 800-63B
'path' => '/',
'domain' => '',
'secure' => $isSecure,
@@ -353,6 +361,230 @@ function ensure_remember_token_columns(PDO $db): void {
}
}
/**
* SOC 2 & ISO 27001 Actieve Sessietabel
* Garandeert dat de tabel user_sessions aanwezig is voor multi-session tracking en remote revocation
*/
function ensure_sessions_table(PDO $db): void {
static $sessionsTableChecked = false;
if ($sessionsTableChecked) return;
$sessionsTableChecked = true;
try {
$db->exec("
CREATE TABLE IF NOT EXISTS `user_sessions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`session_id_hash` VARCHAR(64) NOT NULL UNIQUE,
`ip_address` VARCHAR(45) NOT NULL,
`user_agent` VARCHAR(255) NULL,
`device_name` VARCHAR(100) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`last_activity_at` DATETIME NOT NULL,
`expires_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (`user_id`),
INDEX (`session_id_hash`),
INDEX (`expires_at`),
INDEX (`is_active`),
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
} catch (Throwable $e) {
error_log("ensure_sessions_table notice: " . $e->getMessage());
}
}
/**
* Zet User-Agent om naar een herkenbare apparaatnaam (SOC 2 / ISO 27001 UI)
*/
function fociz_get_device_name(?string $userAgent): string {
if (empty($userAgent)) return 'Onbekend apparaat';
$ua = $userAgent;
$platform = 'Onbekend OS';
if (stripos($ua, 'Windows NT 10.0') !== false) $platform = 'Windows 10/11';
elseif (stripos($ua, 'Windows') !== false) $platform = 'Windows';
elseif (stripos($ua, 'Macintosh') !== false || stripos($ua, 'Mac OS X') !== false) $platform = 'macOS';
elseif (stripos($ua, 'iPhone') !== false) $platform = 'iPhone';
elseif (stripos($ua, 'iPad') !== false) $platform = 'iPad';
elseif (stripos($ua, 'Android') !== false) $platform = 'Android';
elseif (stripos($ua, 'Linux') !== false) $platform = 'Linux';
$browser = 'Browser';
if (stripos($ua, 'Edg/') !== false) $browser = 'Edge';
elseif (stripos($ua, 'Chrome/') !== false && stripos($ua, 'Chromium') === false) $browser = 'Chrome';
elseif (stripos($ua, 'Safari/') !== false && stripos($ua, 'Chrome') === false) $browser = 'Safari';
elseif (stripos($ua, 'Firefox/') !== false) $browser = 'Firefox';
elseif (stripos($ua, 'Opera') !== false || stripos($ua, 'OPR/') !== false) $browser = 'Opera';
return "{$browser} op {$platform}";
}
/**
* Registreert of vernieuwt een actieve sessie in user_sessions (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
*/
function fociz_register_active_session(int $userId, string $sessionId, int $maxIdleSeconds): void {
try {
if (empty($sessionId) || $userId <= 0) return;
$db = get_db_connection();
ensure_sessions_table($db);
$hash = hash('sha256', $sessionId);
$ip = fociz_get_client_ip();
$ua = substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255);
$deviceName = fociz_get_device_name($ua);
$expiresAt = date('Y-m-d H:i:s', time() + $maxIdleSeconds);
$stmt = $db->prepare("
INSERT INTO user_sessions (user_id, session_id_hash, ip_address, user_agent, device_name, is_active, last_activity_at, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, 1, NOW(), ?, NOW())
ON DUPLICATE KEY UPDATE
ip_address = VALUES(ip_address),
user_agent = VALUES(user_agent),
device_name = VALUES(device_name),
is_active = 1,
last_activity_at = NOW(),
expires_at = VALUES(expires_at)
");
$stmt->execute([$userId, $hash, $ip, $ua, $deviceName, $expiresAt]);
// Verwijder oude verlopen sessies (> 7 dagen geleden verlopen)
$db->exec("DELETE FROM user_sessions WHERE expires_at < NOW() - INTERVAL 7 DAY");
} catch (Throwable $e) {
error_log("fociz_register_active_session error: " . $e->getMessage());
}
}
/**
* Werkt de activiteitstijd en expiratiedatum bij van een actieve sessie
*/
function fociz_touch_session(string $sessionId, int $maxIdleSeconds): void {
try {
if (empty($sessionId)) return;
$db = get_db_connection();
$hash = hash('sha256', $sessionId);
$expiresAt = date('Y-m-d H:i:s', time() + $maxIdleSeconds);
$stmt = $db->prepare("UPDATE user_sessions SET last_activity_at = NOW(), expires_at = ? WHERE session_id_hash = ? AND is_active = 1");
$stmt->execute([$expiresAt, $hash]);
} catch (Throwable $e) {
error_log("fociz_touch_session error: " . $e->getMessage());
}
}
/**
* Roteert een sessie-ID in de database (OWASP ASVS v4.0 §3.3)
*/
function fociz_rotate_session(string $oldSessionId, string $newSessionId): void {
try {
if (empty($oldSessionId) || empty($newSessionId) || $oldSessionId === $newSessionId) return;
$db = get_db_connection();
$oldHash = hash('sha256', $oldSessionId);
$newHash = hash('sha256', $newSessionId);
$stmt = $db->prepare("UPDATE user_sessions SET session_id_hash = ? WHERE session_id_hash = ?");
$stmt->execute([$newHash, $oldHash]);
} catch (Throwable $e) {
error_log("fociz_rotate_session error: " . $e->getMessage());
}
}
/**
* Controleert of de huidige sessie nog actief is (of ingetrokken op afstand)
*/
function fociz_is_session_active(string $sessionId, int $userId): bool {
try {
if (empty($sessionId) || $userId <= 0) return false;
$db = get_db_connection();
ensure_sessions_table($db);
$hash = hash('sha256', $sessionId);
$stmt = $db->prepare("SELECT is_active, expires_at FROM user_sessions WHERE session_id_hash = ? AND user_id = ?");
$stmt->execute([$hash, $userId]);
$row = $stmt->fetch();
if (!$row) return true; // Als sessie nog niet geregistreerd staat, laat het door (wordt geregistreerd)
return (!empty($row['is_active']));
} catch (Throwable $e) {
return true; // Fallback op toegestaan bij databaseverstoring
}
}
/**
* Beëindigt een sessie in de database (bij uitloggen of time-out)
*/
function fociz_terminate_session(string $sessionId): void {
try {
if (empty($sessionId)) return;
$db = get_db_connection();
$hash = hash('sha256', $sessionId);
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE session_id_hash = ?");
$stmt->execute([$hash]);
} catch (Throwable $e) {
error_log("fociz_terminate_session error: " . $e->getMessage());
}
}
/**
* Trekt alle overige sessies van een gebruiker in (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
*/
function fociz_revoke_other_sessions(int $userId, string $currentSessionId): int {
try {
$db = get_db_connection();
ensure_sessions_table($db);
$currentHash = hash('sha256', $currentSessionId);
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE user_id = ? AND session_id_hash != ? AND is_active = 1");
$stmt->execute([$userId, $currentHash]);
$revokedCount = $stmt->rowCount();
// Invalideer ook het dagsessietoken in de database zodat herstel op andere browsers direct stopt
$stmtClear = $db->prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?");
$stmtClear->execute([$userId]);
fociz_log_audit($userId, 'REVOKE_OTHER_SESSIONS', 'user_sessions', $userId, ['revoked_count' => $revokedCount], 'SUCCESS');
return $revokedCount;
} catch (Throwable $e) {
error_log("fociz_revoke_other_sessions error: " . $e->getMessage());
return 0;
}
}
/**
* Trekt een specifieke sessie in op basis van ID
*/
function fociz_revoke_session_by_id(int $userId, int $sessionDbId): bool {
try {
$db = get_db_connection();
$stmt = $db->prepare("UPDATE user_sessions SET is_active = 0 WHERE id = ? AND user_id = ?");
$stmt->execute([$sessionDbId, $userId]);
fociz_log_audit($userId, 'SESSION_TERMINATED_REVOKED', 'user_sessions', $sessionDbId, ['session_db_id' => $sessionDbId], 'SUCCESS');
return true;
} catch (Throwable $e) {
return false;
}
}
/**
* Haalt alle actieve sessies van een gebruiker op voor de profielpagina
*/
function fociz_get_user_active_sessions(int $userId, string $currentSessionId): array {
try {
$db = get_db_connection();
ensure_sessions_table($db);
$currentHash = hash('sha256', $currentSessionId);
$stmt = $db->prepare("
SELECT id, ip_address, user_agent, device_name, last_activity_at, created_at, (session_id_hash = ?) AS is_current
FROM user_sessions
WHERE user_id = ? AND is_active = 1
ORDER BY is_current DESC, last_activity_at DESC
");
$stmt->execute([$currentHash, $userId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
} catch (Throwable $e) {
return [];
}
}
/**
* Garandeert dat de kolom voor notificatie-instellingen aanwezig is in MariaDB
*/
@@ -621,10 +853,22 @@ 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'])) {
// Controleer of sessie op afstand is ingetrokken (SOC 2 CC6.1 / ISO 27001 A.9.4.2)
if (!fociz_is_session_active(session_id(), (int)$_SESSION['user_id'])) {
fociz_log_audit((int)$_SESSION['user_id'], 'SESSION_TERMINATED_REVOKED', 'user_sessions', (int)$_SESSION['user_id'], ['reason' => 'remote_revocation'], 'WARNING');
$_SESSION = ['session_was_expired' => true, 'session_revoked' => true];
clear_day_session_cookie();
if (session_status() === PHP_SESSION_ACTIVE) {
@session_regenerate_id(true);
}
return null;
}
// 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'], 'max_idle' => $maxIdleSeconds], 'WARNING');
fociz_terminate_session(session_id());
// 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']]);
@@ -640,6 +884,7 @@ function get_logged_in_user(): ?array {
$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');
fociz_terminate_session(session_id());
$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];
@@ -668,8 +913,10 @@ function get_logged_in_user(): ?array {
if (empty($_SESSION['last_rotated'])) {
$_SESSION['last_rotated'] = $now;
} elseif (($now - (int)$_SESSION['last_rotated']) > 1800) {
$oldSid = session_id();
if (session_status() === PHP_SESSION_ACTIVE) {
@session_regenerate_id(true);
fociz_rotate_session($oldSid, session_id());
}
$_SESSION['last_rotated'] = $now;
}
@@ -690,6 +937,7 @@ function get_logged_in_user(): ?array {
try {
$stmtAct = $db->prepare("UPDATE users SET last_activity_at = NOW() WHERE id = ?");
$stmtAct->execute([$_SESSION['user_id']]);
fociz_touch_session(session_id(), $maxIdleSeconds);
} catch (Throwable $e) {}
}
}
@@ -748,6 +996,8 @@ function get_logged_in_user(): ?array {
$stmtAct->execute([$user['id']]);
} catch (Throwable $e) {}
fociz_register_active_session((int)$user['id'], session_id(), $maxIdleSeconds);
$user['avatar_url'] = fociz_get_valid_avatar_url($user['avatar_url'] ?? null, (int)$user['id']);
$_SESSION['user_avatar_url'] = $user['avatar_url'];
return $user;
@@ -796,9 +1046,12 @@ function require_auth(bool $json = false): array {
}
$wasExpired = !empty($_SESSION['session_was_expired']);
$ipChanged = !empty($_SESSION['ip_changed']);
unset($_SESSION['session_was_expired'], $_SESSION['ip_changed']);
$wasRevoked = !empty($_SESSION['session_revoked']);
unset($_SESSION['session_was_expired'], $_SESSION['ip_changed'], $_SESSION['session_revoked']);
if ($ipChanged) {
header('Location: /auth/login.php?ip_changed=1');
} elseif ($wasRevoked) {
header('Location: /auth/login.php?revoked=1');
} elseif ($wasExpired) {
header('Location: /auth/login.php?expired=1');
} else {