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