Merge origin/main: Review-Branch mit v2.4.0-Features zusammenführen

Konfliktauflösung kombiniert beide Seiten:
- Auth: Secure-Cookie-Flag + DB-Session-Handler (main)
- UniFiController: createVouchers (n-Parameter, 1 API-Call) + QoS-Optionen (main)
- index.php: IP-Rate-Limit/PRG/Sticky-Forms + CAPTCHA/SMS/QoS/Tageslimit (main)
- users.php: 2FA-Reset (main) auf POST+PRG umgestellt wie übrige Aktionen
- forgot_password: Session-Throttle (main) + IP-Throttle kombiniert
- Eigene Migrationen wegen Nummernkollision auf 0005/0006 umbenannt

https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
This commit is contained in:
Claude 2026-06-09 20:03:32 +00:00
commit 1a2f86ed3d
No known key found for this signature in database
65 changed files with 3136 additions and 32 deletions

View file

@ -1,4 +1,8 @@
<?php
require_once __DIR__ . '/Totp.php';
require_once __DIR__ . '/Notifier.php';
require_once __DIR__ . '/Crypto.php';
class Auth {
private $db;
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
@ -20,15 +24,49 @@ class Auth {
ini_set('session.cookie_secure', 1);
}
// 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");
}
}
}
/**
* Ermittelt die echte Client-IP. Hinter einem konfigurierten Trusted-Proxy
* (Setting `trusted_proxy`, kommaseparierte IP-Liste) wird die erste IP aus
* X-Forwarded-For verwendet, sonst REMOTE_ADDR. Verhindert, dass ein
* Reverse-Proxy alle Clients als dieselbe IP erscheinen laesst (Rate-Limit).
*/
public function clientIp() {
$remote = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
try {
$trusted = (string)$this->db->getSetting('trusted_proxy', '');
} catch (\Exception $e) {
$trusted = '';
}
if ($trusted === '' || empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
return $remote;
}
$trustedList = array_filter(array_map('trim', explode(',', $trusted)));
if (!in_array($remote, $trustedList, true)) {
return $remote; // Anfrage kam nicht vom Trusted-Proxy -> XFF ignorieren
}
$parts = array_filter(array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])));
$client = $parts[0] ?? $remote;
return filter_var($client, FILTER_VALIDATE_IP) ? $client : $remote;
}
// Benutzer einloggen
public function login($email, $password) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$ip = $this->clientIp();
if ($this->isRateLimited($ip, $email)) {
return 'rate_limited';
@ -41,6 +79,15 @@ class Auth {
if ($user && password_verify($password, $user['password_hash'])) {
$this->clearLoginAttempts($ip, $email);
// 2FA aktiv? Dann Login zunaechst nur "vormerken" und Code anfordern.
if (!empty($user['totp_enabled']) && !empty($user['totp_secret'])) {
$_SESSION['totp_pending_user_id'] = $user['id'];
$_SESSION['totp_pending_time'] = time();
return 'totp_required';
}
$this->notifyIfNewIp($user, $ip);
$this->setUserSession($user);
$this->updateLastLogin($user['id']);
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
@ -51,11 +98,135 @@ class Auth {
return false;
}
/** Webhook bei Login von einer für diesen Nutzer bisher unbekannten IP. */
private function notifyIfNewIp($user, $ip) {
try {
$seen = $this->db->fetchOne(
"SELECT 1 FROM audit_log WHERE user_id = ? AND action = 'user_login' AND ip_address = ? LIMIT 1",
[$user['id'], $ip]
);
if (!$seen) {
Notifier::loginNewIp($user['email'], $ip);
}
} catch (\Exception $e) { /* nie blockierend */ }
}
/** Liegt ein Login vor, der noch auf den 2FA-Code wartet? */
public function isTotpPending() {
return isset($_SESSION['totp_pending_user_id'])
&& isset($_SESSION['totp_pending_time'])
&& (time() - (int)$_SESSION['totp_pending_time']) < 300; // 5 Min Fenster
}
/** Schliesst einen 2FA-Login mit dem eingegebenen Code ab. */
public function verifyTotpLogin($code) {
if (!$this->isTotpPending()) {
return false;
}
$user = $this->db->fetchOne(
"SELECT * FROM users WHERE id = ? AND is_active = 1",
[$_SESSION['totp_pending_user_id']]
);
if (!$user || empty($user['totp_secret'])) {
unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']);
return false;
}
// Entweder gueltiger TOTP-Code ODER ein Recovery-/Backup-Code
// (Secret wird verschluesselt gespeichert; Klartext-Fallback via decrypt)
$ok = Totp::verify(Crypto::decrypt($user['totp_secret']), $code);
if (!$ok && $this->consumeBackupCode($user, $code)) {
$ok = true;
$this->writeAuditLog($user['id'], 'user_login_backup_code', 'user', $user['id'], 'Login per Recovery-Code');
}
if (!$ok) {
$this->writeAuditLog($user['id'], 'user_login_2fa_failed', 'user', $user['id'], '2FA-Code falsch');
return false;
}
unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']);
$this->notifyIfNewIp($user, $this->clientIp());
$this->setUserSession($user);
$this->updateLastLogin($user['id']);
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich (2FA)');
return true;
}
/**
* 2FA fuer einen Benutzer aktivieren und Recovery-Codes erzeugen.
* @return array Klartext-Recovery-Codes (nur hier einmalig verfuegbar)
*/
public function enableTotp($userId, $secret) {
$codes = $this->generateBackupCodes();
$hashes = array_map(function ($c) { return hash('sha256', $c); }, $codes);
$this->db->query(
"UPDATE users SET totp_secret = ?, totp_enabled = 1, totp_backup_codes = ? WHERE id = ?",
[Crypto::encrypt($secret), json_encode($hashes), $userId]
);
$this->writeAuditLog($userId, 'totp_enabled', 'user', $userId, '2FA aktiviert');
return $codes;
}
/** 2FA fuer einen Benutzer deaktivieren. */
public function disableTotp($userId) {
$this->db->query(
"UPDATE users SET totp_secret = NULL, totp_enabled = 0, totp_backup_codes = NULL WHERE id = ?",
[$userId]
);
$this->writeAuditLog($userId, 'totp_disabled', 'user', $userId, '2FA deaktiviert');
}
/** Neue Recovery-Codes erzeugen (8 Stueck, Format XXXX-XXXX). */
public function generateBackupCodes($count = 8) {
$codes = [];
for ($i = 0; $i < $count; $i++) {
$raw = strtoupper(bin2hex(random_bytes(4))); // 8 Hex-Zeichen
$codes[] = substr($raw, 0, 4) . '-' . substr($raw, 4, 4);
}
return $codes;
}
/** Recovery-Codes neu erzeugen und speichern; gibt Klartext zurueck. */
public function regenerateBackupCodes($userId) {
$codes = $this->generateBackupCodes();
$hashes = array_map(function ($c) { return hash('sha256', $c); }, $codes);
$this->db->query("UPDATE users SET totp_backup_codes = ? WHERE id = ?", [json_encode($hashes), $userId]);
$this->writeAuditLog($userId, 'totp_backup_regenerated', 'user', $userId, 'Recovery-Codes neu erzeugt');
return $codes;
}
/** Anzahl noch nicht verbrauchter Recovery-Codes. */
public function backupCodesRemaining($user) {
$list = json_decode($user['totp_backup_codes'] ?? '[]', true);
return is_array($list) ? count($list) : 0;
}
/** Prueft & verbraucht einen Recovery-Code (konstante Zeit). */
private function consumeBackupCode($user, $code) {
$code = strtoupper(trim($code));
$list = json_decode($user['totp_backup_codes'] ?? '[]', true);
if (!is_array($list) || empty($list)) {
return false;
}
$hash = hash('sha256', $code);
$matched = false;
$remaining = [];
foreach ($list as $h) {
if (!$matched && hash_equals($h, $hash)) {
$matched = true; // diesen verbrauchen (nicht behalten)
} else {
$remaining[] = $h;
}
}
if ($matched) {
$this->db->query("UPDATE users SET totp_backup_codes = ? WHERE id = ?", [json_encode($remaining), $user['id']]);
}
return $matched;
}
public function writeAuditLog($userId, $action, $entityType = null, $entityId = null, $details = null) {
try {
$this->db->execute(
"INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)",
[$userId, $action, $entityType, $entityId !== null ? (string)$entityId : null, $details, $_SERVER['REMOTE_ADDR'] ?? '']
[$userId, $action, $entityType, $entityId !== null ? (string)$entityId : null, $details, $this->clientIp()]
);
} catch (\Exception $e) {
// audit_log table may not exist on old installs
@ -297,8 +468,47 @@ class Auth {
header('Location: /index.php?error=access_denied');
exit;
}
// Optionale Richtlinie: 2FA fuer Admins erzwingen. Admins ohne aktives
// 2FA werden zur Einrichtung umgeleitet (security.php nutzt requireLogin,
// daher keine Endlosschleife).
try {
if ((string)$this->db->getSetting('enforce_2fa_admins', '0') === '1') {
$user = $this->getCurrentUser();
$script = basename($_SERVER['SCRIPT_NAME'] ?? '');
if ($user && !empty($user['password_hash']) && empty($user['totp_enabled'])
&& $script !== 'security.php') {
header('Location: security.php?setup_required=1');
exit;
}
}
} 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()) {