diff --git a/admin/integrations.php b/admin/integrations.php index 2da737f..6f6d1bd 100644 --- a/admin/integrations.php +++ b/admin/integrations.php @@ -24,6 +24,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) { $error = __('error_csrf'); } else { $db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0'); + $db->setSetting('session_driver', ($_POST['session_driver'] ?? 'php') === 'db' ? 'db' : 'php'); $cm = in_array($_POST['captcha_mode'] ?? 'off', ['off','math','hcaptcha'], true) ? $_POST['captcha_mode'] : 'off'; $db->setSetting('captcha_mode', $cm); $db->setSetting('captcha_site_key', trim($_POST['captcha_site_key'] ?? '')); @@ -58,6 +59,7 @@ if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrf } $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; +$sessionDriver = $db->getSetting('session_driver', 'php'); $captchaMode = $db->getSetting('captcha_mode', 'off'); $captchaSiteKey = $db->getSetting('captcha_site_key', ''); $captchaSecretSet = $db->getSetting('captcha_secret', '') !== ''; @@ -121,6 +123,11 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px + + + + + + ← Zurück diff --git a/database.sql b/database.sql index a9c1168..6dfa5d4 100644 --- a/database.sql +++ b/database.sql @@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` ( CREATE TABLE IF NOT EXISTS `sessions` ( `id` VARCHAR(128) PRIMARY KEY, - `user_id` INT NOT NULL, + `user_id` INT NULL, `data` TEXT, `expires_at` TIMESTAMP NOT NULL, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/includes/Auth.php b/includes/Auth.php index 2c62e79..4caab35 100644 --- a/includes/Auth.php +++ b/includes/Auth.php @@ -18,7 +18,16 @@ class Auth { ini_set('session.cookie_httponly', 1); ini_set('session.use_strict_mode', 1); ini_set('session.cookie_samesite', 'Lax'); - + + // Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung) + try { + if ($this->db->getSetting('session_driver', 'php') === 'db') { + require_once __DIR__ . '/DbSessionHandler.php'; + $ttl = defined('SESSION_LIFETIME') ? (int)SESSION_LIFETIME : 3600; + session_set_save_handler(new DbSessionHandler($this->db, $ttl), true); + } + } catch (\Throwable $e) { /* Fallback: Standard-PHP-Sessions */ } + if (!session_start()) { die("Session konnte nicht gestartet werden"); } @@ -434,6 +443,31 @@ class Auth { } catch (\Exception $e) { /* Richtlinie nie blockierend */ } } + /** Anzahl aktiver (nicht abgelaufener) DB-Sessions des aktuellen Nutzers. */ + public function activeSessionCount() { + if (!$this->isLoggedIn()) return 0; + try { + $r = $this->db->fetchOne( + "SELECT COUNT(*) c FROM sessions WHERE user_id = ? AND expires_at > NOW()", + [$_SESSION['user_id']] + ); + return (int)($r['c'] ?? 0); + } catch (\Exception $e) { return 0; } + } + + /** Alle anderen Sessions des Nutzers beenden ("überall abmelden"). */ + public function logoutOtherSessions() { + if (!$this->isLoggedIn()) return; + try { + $current = session_id(); + $this->db->query( + "DELETE FROM sessions WHERE user_id = ? AND id != ?", + [$_SESSION['user_id'], $current] + ); + $this->writeAuditLog($_SESSION['user_id'], 'logout_other_sessions', 'user', $_SESSION['user_id'], 'Andere Sessions beendet'); + } catch (\Exception $e) { /* nur bei DB-Sessions wirksam */ } + } + // Login erforderlich public function requireLogin() { if (!$this->isLoggedIn()) { diff --git a/includes/DbSessionHandler.php b/includes/DbSessionHandler.php new file mode 100644 index 0000000..682c10a --- /dev/null +++ b/includes/DbSessionHandler.php @@ -0,0 +1,75 @@ +db = $db; + $this->ttl = max(300, (int)$ttl); + } + + #[\ReturnTypeWillChange] + public function open($path, $name) { return true; } + + #[\ReturnTypeWillChange] + public function close() { return true; } + + #[\ReturnTypeWillChange] + public function read($id) + { + try { + $row = $this->db->fetchOne( + "SELECT data FROM sessions WHERE id = ? AND expires_at > NOW()", [$id] + ); + return $row && $row['data'] !== null ? (string)$row['data'] : ''; + } catch (\Throwable $e) { + return ''; + } + } + + #[\ReturnTypeWillChange] + public function write($id, $data) + { + try { + $uid = isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null; + $expires = date('Y-m-d H:i:s', time() + $this->ttl); + $this->db->query( + "INSERT INTO sessions (id, user_id, data, expires_at) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), data = VALUES(data), expires_at = VALUES(expires_at)", + [$id, $uid, $data, $expires] + ); + } catch (\Throwable $e) { + // still ignorieren + } + return true; + } + + #[\ReturnTypeWillChange] + public function destroy($id) + { + try { + $this->db->query("DELETE FROM sessions WHERE id = ?", [$id]); + } catch (\Throwable $e) {} + return true; + } + + #[\ReturnTypeWillChange] + public function gc($max_lifetime) + { + try { + $this->db->query("DELETE FROM sessions WHERE expires_at < NOW()"); + } catch (\Throwable $e) {} + return true; + } +} diff --git a/updater/migrations/0004_db_sessions.sql b/updater/migrations/0004_db_sessions.sql new file mode 100644 index 0000000..fff7a49 --- /dev/null +++ b/updater/migrations/0004_db_sessions.sql @@ -0,0 +1,5 @@ +-- Updater-Migration: DB-gestützte Sessions zulassen. +-- user_id muss NULL erlauben (anonyme Sessions vor dem Login). +-- Idempotent genug: bei bereits NULL-barer Spalte ist das ein No-Op. + +ALTER TABLE `sessions` MODIFY `user_id` INT NULL;