DB-gestützte Sessions (opt-in) + 'überall abmelden'

- includes/DbSessionHandler.php (SessionHandlerInterface, fehlertolerant)
- Auth: opt-in-Registrierung (Setting session_driver=db), activeSessionCount(),
  logoutOtherSessions()
- Migration 0004 (sessions.user_id NULL) + database.sql
- Settings-Toggle in integrations.php; 'überall abmelden' in security.php
This commit is contained in:
Claude 2026-06-06 05:49:53 +00:00
parent ebfa27d5c3
commit 1a51721477
No known key found for this signature in database
6 changed files with 144 additions and 2 deletions

View file

@ -24,6 +24,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) {
$error = __('error_csrf'); $error = __('error_csrf');
} else { } else {
$db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0'); $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'; $cm = in_array($_POST['captcha_mode'] ?? 'off', ['off','math','hcaptcha'], true) ? $_POST['captcha_mode'] : 'off';
$db->setSetting('captcha_mode', $cm); $db->setSetting('captcha_mode', $cm);
$db->setSetting('captcha_site_key', trim($_POST['captcha_site_key'] ?? '')); $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'; $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1';
$sessionDriver = $db->getSetting('session_driver', 'php');
$captchaMode = $db->getSetting('captcha_mode', 'off'); $captchaMode = $db->getSetting('captcha_mode', 'off');
$captchaSiteKey = $db->getSetting('captcha_site_key', ''); $captchaSiteKey = $db->getSetting('captcha_site_key', '');
$captchaSecretSet = $db->getSetting('captcha_secret', '') !== ''; $captchaSecretSet = $db->getSetting('captcha_secret', '') !== '';
@ -121,6 +123,11 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px
<label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label> <label class="chk"><input type="checkbox" name="enforce_2fa_admins" <?= $enforce2fa ? 'checked' : '' ?>> 2FA für Administratoren verpflichtend</label>
<label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label> <label>Tageslimit Voucher pro Nicht-Admin-Benutzer (0 = unbegrenzt)</label>
<input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;"> <input class="input" type="number" min="0" name="user_daily_voucher_limit" value="<?= $dailyLimit ?>" style="max-width:200px;">
<label>Session-Speicher</label>
<select class="input" name="session_driver" style="max-width:240px;">
<option value="php" <?= $sessionDriver==='php'?'selected':'' ?>>PHP-Standard (Dateien)</option>
<option value="db" <?= $sessionDriver==='db'?'selected':'' ?>>Datenbank (ermöglicht „überall abmelden")</option>
</select>
<label>Captcha im öffentlichen Modus</label> <label>Captcha im öffentlichen Modus</label>
<select class="input" name="captcha_mode" style="max-width:240px;"> <select class="input" name="captcha_mode" style="max-width:240px;">
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option> <option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option>

View file

@ -42,6 +42,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) {
} }
} }
// Überall abmelden (andere Sessions beenden)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout_others'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = 'Ungültiges Sicherheits-Token';
} else {
$auth->logoutOtherSessions();
$success = 'Alle anderen Sitzungen wurden beendet.';
}
}
// Recovery-Codes neu erzeugen // Recovery-Codes neu erzeugen
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
@ -73,6 +83,8 @@ if (!$totpEnabled && $hasPassword) {
$otpUri = Totp::provisioningUri($setupSecret, $user['email'], $appTitle); $otpUri = Totp::provisioningUri($setupSecret, $user['email'], $appTitle);
} }
$csrf = $auth->getCsrfToken(); $csrf = $auth->getCsrfToken();
$dbSessions = $db->getSetting('session_driver', 'php') === 'db';
$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0;
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
@ -167,6 +179,15 @@ input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-ra
</script> </script>
<?php endif; ?> <?php endif; ?>
<?php if ($dbSessions): ?>
<hr style="margin:20px 0;border:none;border-top:1px solid #eee;">
<p class="sub">Aktive Sitzungen: <strong><?= (int)$activeSessions ?></strong></p>
<form method="post" onsubmit="return confirm('Alle anderen Sitzungen abmelden?');">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<button type="submit" name="logout_others" class="btn" style="background:#eef0ff;color:#5a63d6;width:100%;">Auf allen anderen Geräten abmelden</button>
</form>
<?php endif; ?>
<a class="back" href="../index.php"> Zurück</a> <a class="back" href="../index.php"> Zurück</a>
</div> </div>
</body> </body>

View file

@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS `vouchers` (
CREATE TABLE IF NOT EXISTS `sessions` ( CREATE TABLE IF NOT EXISTS `sessions` (
`id` VARCHAR(128) PRIMARY KEY, `id` VARCHAR(128) PRIMARY KEY,
`user_id` INT NOT NULL, `user_id` INT NULL,
`data` TEXT, `data` TEXT,
`expires_at` TIMESTAMP NOT NULL, `expires_at` TIMESTAMP NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

View file

@ -18,7 +18,16 @@ class Auth {
ini_set('session.cookie_httponly', 1); ini_set('session.cookie_httponly', 1);
ini_set('session.use_strict_mode', 1); ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_samesite', 'Lax'); 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()) { if (!session_start()) {
die("Session konnte nicht gestartet werden"); die("Session konnte nicht gestartet werden");
} }
@ -434,6 +443,31 @@ class Auth {
} catch (\Exception $e) { /* Richtlinie nie blockierend */ } } 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 // Login erforderlich
public function requireLogin() { public function requireLogin() {
if (!$this->isLoggedIn()) { if (!$this->isLoggedIn()) {

View file

@ -0,0 +1,75 @@
<?php
/**
* DbSessionHandler speichert PHP-Sessions in der `sessions`-Tabelle.
*
* Opt-in über Setting `session_driver = db` (Standard 'php' = unverändert).
* Ermöglicht "überall abmelden" und eine Übersicht aktiver Sessions.
*
* Alle DB-Operationen sind fehlertolerant gekapselt schlägt etwas fehl,
* degradiert die Session still, statt die Anwendung lahmzulegen.
*/
class DbSessionHandler implements SessionHandlerInterface
{
private $db;
private $ttl;
public function __construct($db, $ttl = 3600)
{
$this->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;
}
}

View file

@ -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;