Security-, Bugfix- und UX-Überarbeitung auf Basis des Code-Reviews
Sicherheit: - Bulk-Erstellung serverseitig auf eingeloggte Nutzer beschränkt; expire_minutes wird validiert (anonym: nur Default/Template-Werte, eingeloggt: max. 1 Jahr) - IP-basiertes Rate-Limit über neue Tabelle request_throttle (Voucher-Erstellung + Passwort-Reset-Anfragen), Session-Fallback für Alt-Installationen; Migration 0002 - session_regenerate_id() nach Login, Secure-Cookie-Flag bei HTTPS - Admin-/Aktiv-Status wird pro Request live aus der DB geprüft (Rechteentzug & Deaktivierung wirken sofort); Schutz vor Selbst-Degradierung im Benutzer-Edit - Alle state-ändernden Admin-Aktionen von GET auf POST umgestellt (kein CSRF-Token mehr in URLs) - login_simple.php (Legacy, Debug-Leak) entfernt; cron_test.php nur noch für Admins; .htaccess auf Apache-2.4-Syntax inkl. cron_test.php - M365 Client Secret wird nicht mehr ins Formular zurückgegeben - Updater: Zip-Slip-/Pfad-Traversal-Schutz, Backup vor dem Anwenden mit automatischem Rollback bei Fehlern, AuditLogger-Bug behoben - cron_sync: Token-Vergleich mit hash_equals; login_attempts-Pruning - CSV-Export gegen Excel-Formula-Injection abgesichert Bugfixes: - M365-Login: Fallback auf userPrincipalName, wenn Graph kein 'mail' liefert (Nutzer ohne Exchange-Postfach konnten sich nie anmelden) - PRG-Pattern überall: F5 erzeugt keine Duplikat-Voucher und wiederholt keine Admin-Aktionen (Session-Flash-Messages) - QR-Code nicht mehr invertiert (schwarz auf weiß, scanbar) - Bulk-Erstellung nutzt den UniFi 'n'-Parameter: 1 API-Call statt n× Login + Voucherlisten-Abruf; exaktes Code-Matching per create_time statt "global neuester Voucher" - Mailer: doppelte Zeilenumbrüche behoben, AUTH nur mit Credentials, SMTP-Dot-Stuffing, CLI-sicherer EHLO-Host - forgot_password: System-URL-Auto-Detect (Reset-Link war sonst relativ/kaputt) + Rate-Limit - Audit-Log-Labels an tatsächliche Action-Keys angepasst; Voucher-Erstellung (einzeln & bulk) wird jetzt auditiert - Site-Edit testet die Verbindung auch ohne Passwortänderung UX/UI: - Alert-/Badge-Styles zentral in global.css mit Dark-Mode-Variablen (vorher 7× dupliziert mit hart codierten Hellfarben) - Sticky-Formulare + Tab-Erhalt nach Validierungsfehlern (Bulk), Settings kehren nach dem Speichern zum aktiven Tab zurück - Gültigkeit menschenlesbar (z.B. "8 Stunden" statt "480 Minuten") - Voucher-Name-Default "Gast/Guest" im öffentlichen Modus - Favicon auch auf Login-/öffentlichen Seiten - Verbindungstest-Button pro Site-Karte (Health-Check) - i18n-Pass: Confirm-Dialoge, Toasts, Fehl-/Erfolgsmeldungen in de/en - Sprachumschalter ohne fetch+reload (kein Re-Submit-Dialog) - A11y: Esc schließt Modals, aria-live für Toasts, aria-labels auf Icon-Buttons; APP_KEY-Warnbanner im Dashboard - Dashboard-Sync: set_time_limit passend zur Site-Anzahl; Voucher-Sync mit Map statt SELECT pro Voucher Tooling: - GitHub-Actions-Workflow: PHP-Lint aller Dateien + de/en-Key-Parität https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
This commit is contained in:
parent
f747a3d429
commit
6e19958a37
31 changed files with 1040 additions and 628 deletions
|
|
@ -1,7 +1,9 @@
|
|||
<?php
|
||||
class Auth {
|
||||
private $db;
|
||||
|
||||
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
|
||||
private $sessionUser = false;
|
||||
|
||||
public function __construct() {
|
||||
try {
|
||||
$this->db = Database::getInstance();
|
||||
|
|
@ -14,7 +16,10 @@ class Auth {
|
|||
ini_set('session.cookie_httponly', 1);
|
||||
ini_set('session.use_strict_mode', 1);
|
||||
ini_set('session.cookie_samesite', 'Lax');
|
||||
|
||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
||||
ini_set('session.cookie_secure', 1);
|
||||
}
|
||||
|
||||
if (!session_start()) {
|
||||
die("Session konnte nicht gestartet werden");
|
||||
}
|
||||
|
|
@ -72,6 +77,8 @@ class Auth {
|
|||
|
||||
private function recordLoginAttempt($ip, $email) {
|
||||
try {
|
||||
// Alte Eintraege aufraeumen, damit die Tabelle nicht unbegrenzt waechst
|
||||
$this->db->query("DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
|
||||
$this->db->query(
|
||||
"INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)",
|
||||
[$ip, $email]
|
||||
|
|
@ -138,6 +145,12 @@ class Auth {
|
|||
|
||||
// Session setzen
|
||||
private function setUserSession($user) {
|
||||
// Session-ID nach erfolgreichem Login rotieren (verhindert Session-Fixation)
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_regenerate_id(true);
|
||||
}
|
||||
|
||||
$this->sessionUser = false; // User-Cache invalidieren
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['user_email'] = $user['email'];
|
||||
$_SESSION['user_name'] = $user['name'];
|
||||
|
|
@ -160,6 +173,7 @@ class Auth {
|
|||
|
||||
// Ausloggen
|
||||
public function logout() {
|
||||
$this->sessionUser = null;
|
||||
$_SESSION = [];
|
||||
|
||||
if (isset($_COOKIE[session_name()])) {
|
||||
|
|
@ -169,6 +183,25 @@ class Auth {
|
|||
session_destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Laedt den Session-User einmal pro Request aus der DB. Dadurch wirken
|
||||
* Rechteaenderungen (Admin entzogen, Konto deaktiviert/geloescht) sofort
|
||||
* und nicht erst nach Ablauf der Session.
|
||||
*/
|
||||
private function loadSessionUser() {
|
||||
if ($this->sessionUser === false) {
|
||||
$this->sessionUser = null;
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
$user = $this->db->fetchOne(
|
||||
"SELECT * FROM users WHERE id = ? AND is_active = 1",
|
||||
[$_SESSION['user_id']]
|
||||
);
|
||||
$this->sessionUser = $user ?: null;
|
||||
}
|
||||
}
|
||||
return $this->sessionUser;
|
||||
}
|
||||
|
||||
// Prüfen ob eingeloggt
|
||||
public function isLoggedIn() {
|
||||
if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) {
|
||||
|
|
@ -183,24 +216,32 @@ class Auth {
|
|||
return false;
|
||||
}
|
||||
|
||||
// Deaktivierte/geloeschte Konten sofort aussperren
|
||||
if ($this->loadSessionUser() === null) {
|
||||
$this->logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prüfen ob Admin
|
||||
|
||||
// Prüfen ob Admin (live aus der DB, nicht aus dem Session-Cache)
|
||||
public function isAdmin() {
|
||||
return $this->isLoggedIn() && isset($_SESSION['is_admin']) && $_SESSION['is_admin'] === true;
|
||||
if (!$this->isLoggedIn()) {
|
||||
return false;
|
||||
}
|
||||
$user = $this->loadSessionUser();
|
||||
$isAdmin = $user !== null && (bool)$user['is_admin'];
|
||||
$_SESSION['is_admin'] = $isAdmin;
|
||||
return $isAdmin;
|
||||
}
|
||||
|
||||
|
||||
// Aktuellen Benutzer abrufen
|
||||
public function getCurrentUser() {
|
||||
if (!$this->isLoggedIn()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->db->fetchOne(
|
||||
"SELECT * FROM users WHERE id = ?",
|
||||
[$_SESSION['user_id']]
|
||||
);
|
||||
return $this->loadSessionUser();
|
||||
}
|
||||
|
||||
// Prüfen ob Benutzer Zugriff auf Site hat
|
||||
|
|
|
|||
|
|
@ -114,4 +114,9 @@ class Crypto {
|
|||
public static function isEncrypted($value) {
|
||||
return is_string($value) && strpos($value, self::PREFIX) === 0;
|
||||
}
|
||||
|
||||
/** Prueft, ob ein gueltiger APP_KEY konfiguriert ist (fuer Admin-Warnhinweis). */
|
||||
public static function hasKey() {
|
||||
return self::key() !== null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
53
includes/Helpers.php
Normal file
53
includes/Helpers.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
/**
|
||||
* Kleine Shared-Helper:
|
||||
* - Session-Flash-Messages fuer das PRG-Pattern (Redirect nach POST,
|
||||
* Erfolgsmeldung ueberlebt den Redirect, F5 wiederholt keine Aktion).
|
||||
* - IP-basiertes Request-Throttling ueber die Tabelle request_throttle.
|
||||
*/
|
||||
|
||||
function flashSet($message, $type = 'success') {
|
||||
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
|
||||
}
|
||||
|
||||
/** @return array|null ['type' => ..., 'message' => ...] oder null */
|
||||
function flashGet() {
|
||||
$flash = $_SESSION['flash'] ?? null;
|
||||
unset($_SESSION['flash']);
|
||||
return $flash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zaehlt eine Aktion fuer die aktuelle IP und prueft das Limit.
|
||||
*
|
||||
* @param Database $db
|
||||
* @param string $action Logischer Name, z.B. 'voucher_create'
|
||||
* @param int $maxWeight Erlaubte Summe im Zeitfenster
|
||||
* @param int $windowMinutes Zeitfenster in Minuten
|
||||
* @param int $weight Gewicht dieser Anfrage (z.B. Bulk-Anzahl)
|
||||
* @return bool|null true = limitiert, false = erlaubt (und gezaehlt),
|
||||
* null = Tabelle fehlt (Aufrufer entscheidet ueber Fallback)
|
||||
*/
|
||||
function throttleHit($db, $action, $maxWeight, $windowMinutes, $weight = 1) {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
try {
|
||||
$db->query("DELETE FROM request_throttle WHERE requested_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
|
||||
$row = $db->fetchOne(
|
||||
"SELECT COALESCE(SUM(weight), 0) AS cnt FROM request_throttle
|
||||
WHERE action = ? AND ip_address = ?
|
||||
AND requested_at > DATE_SUB(NOW(), INTERVAL " . (int)$windowMinutes . " MINUTE)",
|
||||
[$action, $ip]
|
||||
);
|
||||
if ((int)$row['cnt'] + $weight > $maxWeight) {
|
||||
return true;
|
||||
}
|
||||
$db->query(
|
||||
"INSERT INTO request_throttle (ip_address, action, weight) VALUES (?, ?, ?)",
|
||||
[$ip, $action, $weight]
|
||||
);
|
||||
return false;
|
||||
} catch (Exception $e) {
|
||||
// Tabelle existiert noch nicht (Migration 0002 nicht gelaufen)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ class Mailer {
|
|||
$this->smtpUsername = $this->db->getSetting('smtp_username', '');
|
||||
$this->smtpPassword = $this->db->getSetting('smtp_password', '');
|
||||
$this->smtpEncryption = $this->db->getSetting('smtp_encryption', 'tls');
|
||||
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . $_SERVER['HTTP_HOST']);
|
||||
$this->fromEmail = $this->db->getSetting('smtp_from_email', 'noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost'));
|
||||
$this->fromName = $this->db->getSetting('smtp_from_name', $this->db->getSetting('app_title', 'UniFi Voucher System'));
|
||||
}
|
||||
|
||||
|
|
@ -49,24 +49,30 @@ class Mailer {
|
|||
|
||||
private function sendWithSmtp($to, $subject, $body, $isHtml = false) {
|
||||
try {
|
||||
// Hostname auch im CLI-Kontext (Cron) verfuegbar
|
||||
$heloHost = $_SERVER['HTTP_HOST'] ?? (gethostname() ?: 'localhost');
|
||||
|
||||
// Verbindung aufbauen
|
||||
$socket = $this->connectToSmtp();
|
||||
|
||||
|
||||
// EHLO
|
||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
||||
|
||||
$this->smtpCommand($socket, "EHLO " . $heloHost);
|
||||
|
||||
// STARTTLS wenn nötig
|
||||
if ($this->smtpEncryption === 'tls') {
|
||||
$this->smtpCommand($socket, "STARTTLS");
|
||||
stream_socket_enable_crypto($socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT);
|
||||
$this->smtpCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
|
||||
$this->smtpCommand($socket, "EHLO " . $heloHost);
|
||||
}
|
||||
|
||||
// AUTH LOGIN
|
||||
$this->smtpCommand($socket, "AUTH LOGIN");
|
||||
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
|
||||
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
|
||||
|
||||
|
||||
// AUTH LOGIN – nur wenn Zugangsdaten konfiguriert sind
|
||||
// (Server ohne Auth lehnen ein leeres AUTH LOGIN sonst ab)
|
||||
if ($this->smtpUsername !== '') {
|
||||
$this->smtpCommand($socket, "AUTH LOGIN");
|
||||
$this->smtpCommand($socket, base64_encode($this->smtpUsername));
|
||||
$this->smtpCommand($socket, base64_encode($this->smtpPassword));
|
||||
}
|
||||
|
||||
// MAIL FROM
|
||||
$this->smtpCommand($socket, "MAIL FROM:<{$this->fromEmail}>");
|
||||
|
||||
|
|
@ -89,13 +95,14 @@ class Mailer {
|
|||
}
|
||||
|
||||
$message .= "\r\n";
|
||||
|
||||
// Body - bei Plain Text Zeilenumbrüche konvertieren
|
||||
if (!$isHtml) {
|
||||
$body = nl2br($body, false); // Für Plain Text
|
||||
$body = str_replace('<br>', "\r\n", $body);
|
||||
}
|
||||
|
||||
|
||||
// Zeilenumbrueche auf CRLF normalisieren (der fruehere
|
||||
// nl2br/str_replace-Umweg hat Umbrueche verdoppelt)
|
||||
$body = preg_replace("/\r\n|\r|\n/", "\r\n", $body);
|
||||
// SMTP-Dot-Stuffing: Zeilen, die mit '.' beginnen, wuerden sonst
|
||||
// die DATA-Phase vorzeitig beenden (RFC 5321, 4.5.2)
|
||||
$body = preg_replace('/^\./m', '..', $body);
|
||||
|
||||
$message .= $body;
|
||||
$message .= "\r\n.\r\n";
|
||||
|
||||
|
|
|
|||
|
|
@ -155,66 +155,83 @@ class UniFiController {
|
|||
return json_decode($response, true);
|
||||
}
|
||||
|
||||
// Voucher erstellen
|
||||
// Einzelnen Voucher erstellen
|
||||
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480) {
|
||||
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1);
|
||||
return $vouchers[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt $count Voucher in EINEM API-Call (UniFi 'n'-Parameter) statt
|
||||
* pro Voucher Login + Full-Fetch auszufuehren.
|
||||
*
|
||||
* Matching: Die create-voucher-Antwort liefert die create_time der neuen
|
||||
* Voucher; darueber (plus note) werden exakt die soeben erstellten Codes
|
||||
* identifiziert. Der fruehere Fallback "global neuester Voucher" konnte
|
||||
* bei parallelen Erstellungen fremde Codes liefern und wurde entfernt.
|
||||
*
|
||||
* @return array Liste von ['code','formatted_code','unifi_id','create_time']
|
||||
*/
|
||||
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1) {
|
||||
$count = max(1, (int)$count);
|
||||
$data = [
|
||||
'cmd' => 'create-voucher',
|
||||
'expire' => (int)$expireMinutes,
|
||||
'n' => 1,
|
||||
'n' => $count,
|
||||
'note' => $voucherName,
|
||||
'quota' => (int)$maxUses
|
||||
];
|
||||
|
||||
|
||||
$response = $this->apiRequest("/proxy/network/api/s/{$this->siteId}/cmd/hotspot", $data);
|
||||
|
||||
if (!isset($response['data'][0]['create_time'])) {
|
||||
throw new Exception("Voucher konnte nicht erstellt werden");
|
||||
}
|
||||
|
||||
// Voucher-Code abrufen. WICHTIG: getVouchers() liefert die Voucher
|
||||
// unsortiert zurueck – ein blindes reset() kann bei parallelen
|
||||
// Erstellungen den falschen (fremden) Code liefern. Daher gezielt
|
||||
// nach dem soeben erstellten Voucher suchen: gleiche note + neueste
|
||||
// create_time.
|
||||
$vouchers = $this->getVouchers();
|
||||
$createTime = $response['data'][0]['create_time'];
|
||||
|
||||
if (empty($vouchers)) {
|
||||
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
|
||||
}
|
||||
$all = $this->getVouchers();
|
||||
|
||||
$latestVoucher = null;
|
||||
foreach ($vouchers as $voucher) {
|
||||
// Nur Voucher mit passender Notiz beruecksichtigen
|
||||
if (($voucher['note'] ?? null) !== $voucherName) {
|
||||
continue;
|
||||
}
|
||||
if ($latestVoucher === null
|
||||
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
|
||||
$latestVoucher = $voucher;
|
||||
// Exakte Treffer: gleiche note UND die vom Controller gemeldete create_time
|
||||
$matches = [];
|
||||
foreach ($all as $voucher) {
|
||||
if (($voucher['note'] ?? null) === $voucherName
|
||||
&& ($voucher['create_time'] ?? null) == $createTime) {
|
||||
$matches[] = $voucher;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: falls keine note-Uebereinstimmung (z.B. Sonderzeichen),
|
||||
// den global neuesten Voucher nehmen.
|
||||
if ($latestVoucher === null) {
|
||||
foreach ($vouchers as $voucher) {
|
||||
if ($latestVoucher === null
|
||||
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
|
||||
$latestVoucher = $voucher;
|
||||
// Fallback: nur note matchen (falls der Controller create_time leicht
|
||||
// abweichend meldet), neueste zuerst, auf $count begrenzen.
|
||||
if (empty($matches)) {
|
||||
foreach ($all as $voucher) {
|
||||
if (($voucher['note'] ?? null) === $voucherName) {
|
||||
$matches[] = $voucher;
|
||||
}
|
||||
}
|
||||
usort($matches, function ($a, $b) {
|
||||
return ($b['create_time'] ?? 0) <=> ($a['create_time'] ?? 0);
|
||||
});
|
||||
$matches = array_slice($matches, 0, $count);
|
||||
}
|
||||
|
||||
if ($latestVoucher === null || empty($latestVoucher['code'])) {
|
||||
$result = [];
|
||||
foreach ($matches as $voucher) {
|
||||
if (empty($voucher['code'])) {
|
||||
continue;
|
||||
}
|
||||
$result[] = [
|
||||
'code' => $voucher['code'],
|
||||
'formatted_code' => $this->formatVoucherCode($voucher['code']),
|
||||
'unifi_id' => $voucher['_id'] ?? null,
|
||||
'create_time' => $voucher['create_time'] ?? null
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($result)) {
|
||||
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => $latestVoucher['code'],
|
||||
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),
|
||||
'unifi_id' => $latestVoucher['_id'] ?? null,
|
||||
'create_time' => $latestVoucher['create_time'] ?? null
|
||||
];
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Alle Voucher abrufen
|
||||
|
|
@ -339,17 +356,26 @@ class UniFiController {
|
|||
// Alle aktuellen UniFi-IDs sammeln
|
||||
$unifiIds = [];
|
||||
|
||||
// Bestehende Voucher der Site einmal als Map laden statt pro Voucher
|
||||
// ein SELECT auszufuehren (halbiert die Query-Anzahl bei grossen Syncs)
|
||||
$existingRows = $db->fetchAll(
|
||||
"SELECT id, unifi_voucher_id FROM vouchers WHERE site_id = ? AND unifi_voucher_id IS NOT NULL",
|
||||
[$dbSiteId]
|
||||
);
|
||||
$existingMap = [];
|
||||
foreach ($existingRows as $row) {
|
||||
$existingMap[$row['unifi_voucher_id']] = $row['id'];
|
||||
}
|
||||
|
||||
foreach ($vouchers as $voucher) {
|
||||
$unifiIds[] = $voucher['_id'];
|
||||
|
||||
// Status zählen
|
||||
$stats[$voucher['status']]++;
|
||||
|
||||
// Prüfen ob Voucher bereits existiert
|
||||
$existing = $db->fetchOne(
|
||||
"SELECT id, status, used_count FROM vouchers WHERE unifi_voucher_id = ? AND site_id = ?",
|
||||
[$voucher['_id'], $dbSiteId]
|
||||
);
|
||||
$existing = isset($existingMap[$voucher['_id']])
|
||||
? ['id' => $existingMap[$voucher['_id']]]
|
||||
: null;
|
||||
|
||||
$expiresAt = date('Y-m-d H:i:s', $voucher['expire_time']);
|
||||
$createdAt = date('Y-m-d H:i:s', $voucher['create_time']);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue