diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a04fb03 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +docs +*.md +config.php +updater/storage/.version +updater/storage/.maintenance +updater/storage/.update-staging +updater/storage/updater-settings.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5aaaad9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [ "**" ] + pull_request: + +jobs: + lint: + name: PHP Lint + runs-on: ubuntu-latest + strategy: + matrix: + php: [ "7.4", "8.2" ] + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo, pdo_mysql, curl, mbstring, json + coverage: none + + - name: Syntax check all PHP files + run: | + set -e + find . -name '*.git' -prune -o -name '*.php' -print | while read -r f; do + php -l "$f" + done + + - name: Validate JSON language/migration assets + run: | + php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";' + + test: + name: Unit Tests & Static Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.2 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + extensions: pdo, pdo_mysql, curl, mbstring, json + tools: composer + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: PHPUnit + run: vendor/bin/phpunit + + - name: PHPStan + run: vendor/bin/phpstan analyse --no-progress diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..b8be3e7 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,39 @@ +name: Docker Publish + +on: + push: + tags: [ "v*" ] + workflow_dispatch: + +jobs: + build-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62c62f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Dev-/Test-Abhängigkeiten (Laufzeit braucht KEIN composer) +/vendor/ +composer.lock +.phpunit.result.cache +.phpunit.cache/ + +# Updater-Laufzeitdaten +/updater/storage/.version +/updater/storage/.maintenance +/updater/storage/.update-progress +/updater/storage/.update.zip +/updater/storage/.update-staging/ +/updater/storage/.migrations-lock +/updater/storage/updater-settings.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3d0f28c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# UniFi Voucher Management System – Container-Image +FROM php:8.2-apache + +# System-Tools (curl für Healthcheck) + PHP-Extensions +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && docker-php-ext-install pdo pdo_mysql \ + && a2enmod rewrite headers + +# Empfohlene PHP-Einstellungen +RUN { \ + echo 'display_errors=0'; \ + echo 'log_errors=1'; \ + echo 'expose_php=0'; \ + echo 'upload_max_filesize=8M'; \ + echo 'post_max_size=8M'; \ + } > /usr/local/etc/php/conf.d/zz-voucher.ini + +WORKDIR /var/www/html +COPY . /var/www/html + +# Laufzeit-Verzeichnis des Updaters beschreibbar machen +RUN mkdir -p /var/www/html/updater/storage \ + && chown -R www-data:www-data /var/www/html + +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +EXPOSE 80 + +# Apache-Worker laufen als www-data (Privilege-Drop durch den Master). +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost/health.php || exit 1 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["apache2-foreground"] diff --git a/Readme.md b/Readme.md index dad67de..7019def 100644 --- a/Readme.md +++ b/Readme.md @@ -8,7 +8,8 @@ ![MySQL](https://img.shields.io/badge/MySQL-5.7%2B%20%2F%20MariaDB-4479A1?logo=mysql&logoColor=white) ![UniFi OS](https://img.shields.io/badge/UniFi%20OS-7.0%2B-0559C9?logo=ubiquiti&logoColor=white) ![License](https://img.shields.io/badge/Lizenz-MIT-green) -![Version](https://img.shields.io/badge/Version-2.1.0-blueviolet) +![Version](https://img.shields.io/badge/Version-2.4.0-blueviolet) +![CI](https://github.com/friloo/unifi-voucher-tool/actions/workflows/ci.yml/badge.svg) @@ -29,7 +30,20 @@ - 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten - 👥 **Benutzerverwaltung** mit granularer Site-Zugriffskontrolle - 🔐 **Authentifizierung** via lokale Accounts **oder** Microsoft 365 OAuth +- 🔒 **2FA (TOTP)** – optional, mit Recovery-Codes & erzwingbarer Admin-Pflicht +- 📈 **Reporting** – Auswertungen mit Charts und CSV-/PDF-Export +- 🩺 **Health-Endpoint** (`/health.php`) für Monitoring/Uptime - 🔑 **Passwort-Reset** per E-Mail (token-basiert, zeitlich begrenzt) +- 🚦 **Bandbreiten- & Datenlimits** pro Voucher/Profil (UniFi QoS) +- 🧰 **REST-API mit API-Schlüsseln** – Scopes (read/write), Rate-Limit, OpenAPI-Spec +- 🪪 **Single Sign-On** via generisches OpenID Connect (zusätzlich zu M365) +- 📲 **SMS-Versand** der Codes (Twilio) · **CSV-Batch-Import** von Vouchern +- 🤖 **CAPTCHA** (Rechenaufgabe oder hCaptcha) im öffentlichen Modus +- 🗄️ **DB-gestützte Sessions** (opt-in) mit „überall abmelden" +- 🔔 **Webhooks** (Slack / Teams / generisch) für Erstellung, Ausfälle, neue Login-IP +- 🧹 **Auto-Cleanup & DSGVO** – Aufbewahrungsfristen per Cron +- 💾 **Config-Backup & -Restore** (JSON Export/Import) +- 🐳 **Docker** – Dockerfile + docker-compose (MariaDB) - 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle) - 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert - 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`) @@ -72,6 +86,17 @@ Wartungsmodus +### REST-API, 2FA & Integrationen + +
+ API-Schlüssel-Verwaltung + Integration & Wartung +
+ +
+ Zwei-Faktor-Authentifizierung +
+ --- ## 📋 Anforderungen @@ -286,6 +311,59 @@ Body: {"cmd": "delete-voucher", "_id": ""} --- +## 🧰 REST-API + +Voucher lassen sich programmatisch erstellen (z. B. aus Buchungssystemen oder +Self-Service-Terminals). Schlüssel werden unter **Administration → API-Schlüssel** +verwaltet. Authentifizierung per `Authorization: Bearer ` oder `X-API-Key`. + +```bash +# Voucher erstellen +curl -X POST https://ihre-domain.de/api/vouchers.php \ + -H "Authorization: Bearer uvt_…" \ + -H "Content-Type: application/json" \ + -d '{"site_id":1,"name":"API Gast","max_uses":1,"expire_minutes":480, + "qos":{"down":10000,"up":2000,"quota_mb":500}}' + +# Sites auflisten +curl https://ihre-domain.de/api/sites.php -H "X-API-Key: uvt_…" + +# Voucher einer Site abrufen +curl "https://ihre-domain.de/api/vouchers.php?site_id=1" -H "X-API-Key: uvt_…" +``` + +| Methode | Endpunkt | Zweck | +|---|---|---| +| `POST` | `/api/vouchers.php` | Voucher erstellen (optional mit QoS-Limits) | +| `GET` | `/api/vouchers.php?site_id=` | Voucher einer Site auflisten | +| `GET` | `/api/sites.php` | Aktive Sites auflisten | + +## 🔒 2FA, Webhooks & Wartung + +- **2FA:** Unter **Administration → Sicherheit (2FA)** aktivierbar – QR-Code + scannen, Code bestätigen. Danach wird bei jeder Anmeldung ein Authenticator- + Code abgefragt. +- **Webhooks & Trusted-Proxy & Datenhaltung:** unter **Administration → + Integration & Wartung** konfigurierbar. +- **Auto-Cleanup (DSGVO):** täglicher Cron, löscht abgelaufene Voucher, + Audit-Log, Login-Versuche nach einstellbaren Fristen: + ```bash + 0 3 * * * curl -s "https://ihre-domain.de/cron_cleanup.php?token=IHR_CRON_TOKEN" + ``` + +## 🐳 Docker + +```bash +# APP_KEY erzeugen und in docker-compose.yml eintragen: +php -r 'echo base64_encode(random_bytes(32))."\n";' + +docker compose up -d # App auf http://localhost:8080 +``` + +Das Schema wird beim ersten Start automatisch in MariaDB geladen; danach den +Installer (`/install.php`) für den Admin-Account aufrufen oder Config per ENV +setzen (`DB_*`, `APP_KEY`). + ## 🗺️ Roadmap - [x] Voucher-Templates (vordefinierte Laufzeiten) @@ -295,13 +373,16 @@ Body: {"cmd": "delete-voucher", "_id": ""} - [x] Passwort-Reset - [x] Audit-Log - [x] Auto-Updater mit DB-Migrationen -- [ ] Erweiterte Reporting-Funktionen -- [ ] Docker-Container +- [x] REST-API mit API-Schlüsseln +- [x] 2FA (TOTP), Webhooks, Bandbreitenlimits +- [x] Docker-Container +- [x] Erweiterte Reporting-Funktionen (CSV/PDF) + Health-Endpoint +- [x] 2FA-Recovery-Codes, API-Scopes/Rate-Limit/OpenAPI, Test-Suite (PHPUnit/PHPStan) ---
-**Version 2.1.0** · Autor: **Friederich Loheide** · Lizenz: **MIT** +**Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
diff --git a/admin/api_keys.php b/admin/api_keys.php new file mode 100644 index 0000000..a2bbbfb --- /dev/null +++ b/admin/api_keys.php @@ -0,0 +1,171 @@ +requireAdmin(); +I18n::init(); + +$db = Database::getInstance(); +$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); + +$error = ''; +$success = ''; +$newKey = ''; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_key'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = __('error_csrf'); + } else { + $name = trim($_POST['name'] ?? ''); + if ($name === '') { + $error = __('error_name_req'); + } else { + $scope = ($_POST['scope'] ?? 'write') === 'read' ? 'read' : 'write'; + $rate = max(0, (int)($_POST['rate_limit'] ?? 0)); + $k = ApiKey::generate(); + $db->execute( + "INSERT INTO api_keys (name, key_prefix, key_hash, scope, rate_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)", + [$name, $k['prefix'], $k['hash'], $scope, $rate, $_SESSION['user_id']] + ); + $auth->writeAuditLog($_SESSION['user_id'], 'api_key_create', 'api_key', null, "API-Key '$name' erstellt"); + $newKey = $k['plain']; + $success = 'API-Schlüssel erstellt. Bitte JETZT kopieren – er wird nur einmal angezeigt!'; + } + } +} + +if (isset($_GET['toggle']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { + $row = $db->fetchOne("SELECT is_active FROM api_keys WHERE id = ?", [(int)$_GET['toggle']]); + if ($row) { + $db->query("UPDATE api_keys SET is_active = ? WHERE id = ?", [$row['is_active'] ? 0 : 1, (int)$_GET['toggle']]); + $success = 'Status aktualisiert.'; + } +} + +if (isset($_GET['delete']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { + $db->query("DELETE FROM api_keys WHERE id = ?", [(int)$_GET['delete']]); + $auth->writeAuditLog($_SESSION['user_id'], 'api_key_delete', 'api_key', (int)$_GET['delete'], 'API-Key gelöscht'); + $success = 'API-Schlüssel gelöscht.'; +} + +$keys = $db->fetchAll("SELECT k.*, u.name AS creator FROM api_keys k LEFT JOIN users u ON k.created_by = u.id ORDER BY k.created_at DESC"); +$csrf = $auth->getCsrfToken(); +$currentPage = 'api_keys'; +$adminBase = ''; +?> + + + + + +API-Schlüssel – <?= htmlspecialchars($appTitle) ?> + + + + +

🔑 API-Schlüssel

+ +
+
+ + +
+

Neuer Schlüssel

+

Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.

+
+
+ + +
+

Neuen API-Schlüssel erstellen

+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Vorhandene Schlüssel

+ +

Noch keine API-Schlüssel angelegt.

+ + + + + + + + + + + + + + + +
NamePräfixScopeLimitStatusZuletzt genutztErstellt von
uvt_ + + Löschen +
+ +
+ +
+

Verwendung

+

Authentifizierung per Header Authorization: Bearer <key> oder X-API-Key: <key>.

+
# Voucher erstellen
+curl -X POST https://IHRE-DOMAIN/api/vouchers.php \
+  -H "Authorization: Bearer uvt_…" \
+  -H "Content-Type: application/json" \
+  -d '{"site_id":1,"name":"API Gast","max_uses":1,"expire_minutes":480}'
+
+# Sites auflisten
+curl https://IHRE-DOMAIN/api/sites.php -H "X-API-Key: uvt_…"
+

OpenAPI-Spezifikation (Import in Postman/Swagger): /api/openapi.php

+
+ + + + + diff --git a/admin/backup.php b/admin/backup.php new file mode 100644 index 0000000..0faf942 --- /dev/null +++ b/admin/backup.php @@ -0,0 +1,159 @@ +requireAdmin(); +I18n::init(); + +$db = Database::getInstance(); +$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); + +$error = ''; +$success = ''; + +// Export: JSON-Download +if (isset($_GET['export']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { + $export = [ + 'meta' => [ + 'app' => 'unifi-voucher-tool', + 'version' => '2.2.0', + 'exported_at'=> date('c'), + 'note' => 'Site-Passwörter sind mit dem APP_KEY dieser Installation verschlüsselt.', + ], + 'settings' => $db->fetchAll("SELECT setting_key, setting_value FROM settings"), + 'sites' => $db->fetchAll("SELECT name, site_id, unifi_controller_url, unifi_username, unifi_password, is_active, public_access FROM sites"), + 'voucher_templates' => $db->fetchAll("SELECT name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, is_active FROM voucher_templates"), + ]; + $auth->writeAuditLog($_SESSION['user_id'], 'config_export', 'config', null, 'Konfiguration exportiert'); + header('Content-Type: application/json'); + header('Content-Disposition: attachment; filename="voucher-config-' . date('Y-m-d') . '.json"'); + echo json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +// Import +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['import'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = __('error_csrf'); + } elseif (empty($_FILES['backup']['tmp_name'])) { + $error = 'Bitte eine Backup-Datei auswählen.'; + } else { + $raw = file_get_contents($_FILES['backup']['tmp_name']); + $data = json_decode($raw, true); + if (!is_array($data) || ($data['meta']['app'] ?? '') !== 'unifi-voucher-tool') { + $error = 'Ungültige oder fremde Backup-Datei.'; + } else { + $importSites = isset($_POST['import_sites']); + $importTemplates = isset($_POST['import_templates']); + $importSettings = isset($_POST['import_settings']); + $counts = ['settings' => 0, 'sites' => 0, 'templates' => 0]; + + try { + if ($importSettings && !empty($data['settings'])) { + foreach ($data['settings'] as $s) { + // Cron-Token NICHT überschreiben (Sicherheit der Ziel-Installation) + if (($s['setting_key'] ?? '') === 'cron_token') continue; + $db->setSetting($s['setting_key'], $s['setting_value']); + $counts['settings']++; + } + } + if ($importSites && !empty($data['sites'])) { + foreach ($data['sites'] as $s) { + $exists = $db->fetchOne("SELECT id FROM sites WHERE name = ? AND site_id = ?", [$s['name'], $s['site_id']]); + if ($exists) { + $db->query( + "UPDATE sites SET unifi_controller_url=?, unifi_username=?, unifi_password=?, is_active=?, public_access=? WHERE id=?", + [$s['unifi_controller_url'], $s['unifi_username'], $s['unifi_password'], (int)$s['is_active'], (int)$s['public_access'], $exists['id']] + ); + } else { + $db->query( + "INSERT INTO sites (name, site_id, unifi_controller_url, unifi_username, unifi_password, is_active, public_access) VALUES (?,?,?,?,?,?,?)", + [$s['name'], $s['site_id'], $s['unifi_controller_url'], $s['unifi_username'], $s['unifi_password'], (int)$s['is_active'], (int)$s['public_access']] + ); + } + $counts['sites']++; + } + } + if ($importTemplates && !empty($data['voucher_templates'])) { + foreach ($data['voucher_templates'] as $t) { + $exists = $db->fetchOne("SELECT id FROM voucher_templates WHERE name = ?", [$t['name']]); + if (!$exists) { + $db->query( + "INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, is_active) VALUES (?,?,?,?,?,?,?,?)", + [$t['name'], (int)$t['max_uses'], (int)$t['expire_minutes'], $t['description'] ?? null, + $t['qos_rate_max_down'] ?? null, $t['qos_rate_max_up'] ?? null, $t['qos_usage_quota'] ?? null, (int)($t['is_active'] ?? 1)] + ); + $counts['templates']++; + } + } + } + $auth->writeAuditLog($_SESSION['user_id'], 'config_import', 'config', null, 'Konfiguration importiert'); + $success = "Import abgeschlossen: {$counts['settings']} Einstellungen, {$counts['sites']} Sites, {$counts['templates']} Profile."; + } catch (Exception $e) { + $error = 'Import-Fehler: ' . $e->getMessage(); + } + } + } +} + +$csrf = $auth->getCsrfToken(); +$currentPage = 'backup'; +$adminBase = ''; +?> + + + + + +Backup & Restore – <?= htmlspecialchars($appTitle) ?> + + + + +

💾 Backup & Restore

+ +
+
+ +
+

Export

+

Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem APP_KEY dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.

+ Konfiguration exportieren +
+ +
+

Import / Restore

+

Vorhandene Sites werden anhand von Name + Site-ID aktualisiert, neue hinzugefügt. Profile werden nur angelegt, wenn der Name noch nicht existiert. Der Cron-Token wird nie überschrieben.

+
+ +
+ + + + +
+
+ + + + + diff --git a/admin/import.php b/admin/import.php new file mode 100644 index 0000000..dfb9725 --- /dev/null +++ b/admin/import.php @@ -0,0 +1,148 @@ +requireAdmin(); +I18n::init(); + +$db = Database::getInstance(); +$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); +$defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480)); +$defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1)); + +$error = ''; +$success = ''; +$results = []; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['do_import'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = __('error_csrf'); + } else { + try { + $siteId = (int)($_POST['site_id'] ?? 0); + $site = $db->fetchOne("SELECT * FROM sites WHERE id=? AND is_active=1", [$siteId]); + if (!$site) throw new Exception('Site nicht gefunden'); + + // CSV-Quelle: Datei bevorzugt, sonst Textarea + $raw = ''; + if (!empty($_FILES['csv']['tmp_name'])) { + $raw = file_get_contents($_FILES['csv']['tmp_name']); + } else { + $raw = (string)($_POST['csv_text'] ?? ''); + } + $lines = preg_split('/\r\n|\r|\n/', trim($raw)); + if (count($lines) > 200) throw new Exception('Maximal 200 Zeilen pro Import.'); + + $controller = new UniFiController( + $site['unifi_controller_url'], $site['unifi_username'], + Crypto::decrypt($site['unifi_password']), $site['site_id'] + ); + + $created = 0; + foreach ($lines as $i => $line) { + $line = trim($line); + if ($line === '') continue; + $cols = str_getcsv($line); + $name = trim((string)($cols[0] ?? '')); + if ($name === '' || strtolower($name) === 'name') continue; // Header/leer überspringen + $maxUses = isset($cols[1]) && $cols[1] !== '' ? max(1, (int)$cols[1]) : $defaultMaxUses; + $expire = isset($cols[2]) && $cols[2] !== '' ? max(1, (int)$cols[2]) : $defaultExpire; + try { + $v = $controller->createVoucher(date('Y-m-d') . '_' . $name, $maxUses, $expire); + if (!is_array($v) || empty($v['formatted_code'])) throw new Exception('ungültige Antwort'); + $db->execute( + "INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id) + VALUES (?, ?, ?, ?, ?, ?, ?)", + [$siteId, $_SESSION['user_id'], $v['code'], date('Y-m-d') . '_' . $name, $maxUses, $expire, $v['unifi_id'] ?? null] + ); + $results[] = ['name' => $name, 'code' => $v['formatted_code'], 'ok' => true]; + $created++; + } catch (Exception $e) { + $results[] = ['name' => $name, 'code' => $e->getMessage(), 'ok' => false]; + } + } + if ($created > 0) { + Notifier::voucherCreated($created, $site['name'], $_SESSION['user_name'] ?? null); + $auth->writeAuditLog($_SESSION['user_id'], 'voucher_import', 'site', $siteId, "$created Voucher importiert"); + } + $success = "$created Voucher erstellt."; + } catch (Exception $e) { + $error = $e->getMessage(); + } + } +} + +$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name"); +$csrf = $auth->getCsrfToken(); +$currentPage = 'import'; +$adminBase = ''; +?> + + + + + +CSV-Import – <?= htmlspecialchars($appTitle) ?> + + + + +

📥 Voucher-Import (CSV)

+ +
+
+ +
+

Mehrere Voucher erstellen

+

Eine Zeile pro Voucher: Name,MaxGeräte,Minuten – MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:
+ Gast Müller,1,480 · Konferenzraum A,5,240 · Tagespass

+
+ + + + + + + + +
+
+ + +
+

Ergebnis

+ + + + +
NameCode / FehlerStatus
+
+ + + + + + diff --git a/admin/integrations.php b/admin/integrations.php new file mode 100644 index 0000000..6f6d1bd --- /dev/null +++ b/admin/integrations.php @@ -0,0 +1,204 @@ +requireAdmin(); +I18n::init(); + +$db = Database::getInstance(); +$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); + +$error = ''; +$success = ''; + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $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'] ?? '')); + if (!empty($_POST['captcha_secret'])) { $db->setSetting('captcha_secret', trim($_POST['captcha_secret'])); } + $db->setSetting('sms_enabled', isset($_POST['sms_enabled']) ? '1' : '0'); + $db->setSetting('twilio_sid', trim($_POST['twilio_sid'] ?? '')); + $db->setSetting('twilio_from', trim($_POST['twilio_from'] ?? '')); + if (!empty($_POST['twilio_token'])) { $db->setSetting('twilio_token', trim($_POST['twilio_token'])); } + $db->setSetting('oidc_enabled', isset($_POST['oidc_enabled']) ? '1' : '0'); + $db->setSetting('oidc_name', trim($_POST['oidc_name'] ?? 'SSO')); + $db->setSetting('oidc_client_id', trim($_POST['oidc_client_id'] ?? '')); + $db->setSetting('oidc_auth_url', trim($_POST['oidc_auth_url'] ?? '')); + $db->setSetting('oidc_token_url', trim($_POST['oidc_token_url'] ?? '')); + $db->setSetting('oidc_userinfo_url', trim($_POST['oidc_userinfo_url'] ?? '')); + $db->setSetting('oidc_scopes', trim($_POST['oidc_scopes'] ?? 'openid profile email')); + if (!empty($_POST['oidc_client_secret'])) { $db->setSetting('oidc_client_secret', trim($_POST['oidc_client_secret'])); } + $db->setSetting('user_daily_voucher_limit', max(0, (int)($_POST['user_daily_voucher_limit'] ?? 0))); + $db->setSetting('trusted_proxy', trim($_POST['trusted_proxy'] ?? '')); + $db->setSetting('webhook_enabled', isset($_POST['webhook_enabled']) ? '1' : '0'); + $db->setSetting('webhook_url', trim($_POST['webhook_url'] ?? '')); + $db->setSetting('cleanup_expired_days', max(0, (int)($_POST['cleanup_expired_days'] ?? 0))); + $db->setSetting('cleanup_audit_days', max(0, (int)($_POST['cleanup_audit_days'] ?? 0))); + $db->setSetting('cleanup_login_days', max(0, (int)($_POST['cleanup_login_days'] ?? 30))); + $auth->writeAuditLog($_SESSION['user_id'], 'settings_update', 'config', null, 'Integration/Wartung gespeichert'); + $success = 'Einstellungen gespeichert.'; + } +} + +if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrfToken($_GET['token'])) { + Notifier::send('✅ Test-Benachrichtigung vom UniFi Voucher System.', ['type' => 'test']); + $success = 'Test-Benachrichtigung gesendet (sofern Webhook aktiv & URL gültig).'; +} + +$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', '') !== ''; +$smsEnabled = $db->getSetting('sms_enabled', '0') === '1'; +$twilioSid = $db->getSetting('twilio_sid', ''); +$twilioFrom = $db->getSetting('twilio_from', ''); +$twilioTokenSet = $db->getSetting('twilio_token', '') !== ''; +$oidcEnabled = $db->getSetting('oidc_enabled', '0') === '1'; +$oidcName = $db->getSetting('oidc_name', 'SSO'); +$oidcClientId = $db->getSetting('oidc_client_id', ''); +$oidcAuthUrl = $db->getSetting('oidc_auth_url', ''); +$oidcTokenUrl = $db->getSetting('oidc_token_url', ''); +$oidcUserinfoUrl = $db->getSetting('oidc_userinfo_url', ''); +$oidcScopes = $db->getSetting('oidc_scopes', 'openid profile email'); +$oidcSecretSet = $db->getSetting('oidc_client_secret', '') !== ''; +$dailyLimit = (int)$db->getSetting('user_daily_voucher_limit', 0); +$trustedProxy = $db->getSetting('trusted_proxy', ''); +$webhookEnabled = $db->getSetting('webhook_enabled', '0') === '1'; +$webhookUrl = $db->getSetting('webhook_url', ''); +$cleanupExpired = (int)$db->getSetting('cleanup_expired_days', 0); +$cleanupAudit = (int)$db->getSetting('cleanup_audit_days', 0); +$cleanupLogin = (int)$db->getSetting('cleanup_login_days', 30); +$lastCleanup = $db->getSetting('last_cleanup', ''); +$csrf = $auth->getCsrfToken(); +$currentPage = 'integrations'; +$adminBase = ''; +?> + + + + + +Integration & Wartung – <?= htmlspecialchars($appTitle) ?> + + + + +

🔧 Integration & Wartung

+ +
+
+ +
+ + +
+

Sicherheitsrichtlinie

+

Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.

+ + + + + + + +
+
+
+
+
+ +
+

Reverse-Proxy

+

IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus X-Forwarded-For für Rate-Limit & Audit verwendet.

+ +
+ +
+

Webhook-Benachrichtigungen

+

Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.

+ + + + +
+ +
+

SMS-Versand (Twilio)

+

Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.

+ +
+
+
+
+
+
+ +
+

Single Sign-On (OpenID Connect)

+

Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI:

+ +
+
+
+
+
+ + + + +
+ +
+

Datenhaltung & Cleanup (DSGVO)

+

Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per cron_cleanup.php (täglich empfohlen). +
Letzter Lauf: +

+
+
+
+
+
+
+ + +
+ + + + + diff --git a/admin/reports.php b/admin/reports.php new file mode 100644 index 0000000..e43b056 --- /dev/null +++ b/admin/reports.php @@ -0,0 +1,174 @@ +requireAdmin(); +I18n::init(); + +$db = Database::getInstance(); +$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); + +// Zeitraum (Tage) +$days = max(1, min(365, (int)($_GET['days'] ?? 30))); + +// CSV-Export +if (isset($_GET['export'])) { + $auth->requireAdmin(); + header('Content-Type: text/csv; charset=utf-8'); + $fn = 'report-' . $_GET['export'] . '-' . date('Y-m-d') . '.csv'; + header('Content-Disposition: attachment; filename="' . $fn . '"'); + $out = fopen('php://output', 'w'); + fprintf($out, "\xEF\xBB\xBF"); // UTF-8 BOM für Excel + + if ($_GET['export'] === 'per_site') { + fputcsv($out, ['Site', 'Gesamt', 'Gültig', 'Verwendet', 'Abgelaufen']); + $rows = $db->fetchAll( + "SELECT s.name, + COUNT(v.id) total, + SUM(v.status='valid') valid, + SUM(v.status='used') used, + SUM(v.status='expired') expired + FROM sites s LEFT JOIN vouchers v ON v.site_id=s.id + GROUP BY s.id ORDER BY total DESC" + ); + foreach ($rows as $r) fputcsv($out, [$r['name'], (int)$r['total'], (int)$r['valid'], (int)$r['used'], (int)$r['expired']]); + } elseif ($_GET['export'] === 'per_user') { + fputcsv($out, ['Benutzer', 'E-Mail', 'Voucher erstellt']); + $rows = $db->fetchAll( + "SELECT u.name, u.email, COUNT(v.id) c FROM users u + LEFT JOIN vouchers v ON v.user_id=u.id GROUP BY u.id ORDER BY c DESC" + ); + foreach ($rows as $r) fputcsv($out, [$r['name'], $r['email'], (int)$r['c']]); + } else { // daily + fputcsv($out, ['Datum', 'Erstellte Voucher']); + $rows = $db->fetchAll( + "SELECT DATE(created_at) d, COUNT(*) c FROM vouchers + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + GROUP BY DATE(created_at) ORDER BY d", [$days] + ); + foreach ($rows as $r) fputcsv($out, [$r['d'], (int)$r['c']]); + } + fclose($out); + exit; +} + +// Kennzahlen +$totals = $db->fetchOne( + "SELECT COUNT(*) total, + SUM(status='valid') valid, SUM(status='used') used, SUM(status='expired') expired + FROM vouchers" +); +$inPeriod = (int)($db->fetchOne( + "SELECT COUNT(*) c FROM vouchers WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY)", [$days] +)['c'] ?? 0); + +$perSite = $db->fetchAll( + "SELECT s.name, + COUNT(v.id) total, SUM(v.status='valid') valid, SUM(v.status='used') used, SUM(v.status='expired') expired + FROM sites s LEFT JOIN vouchers v ON v.site_id=s.id GROUP BY s.id ORDER BY total DESC" +); +$perUser = $db->fetchAll( + "SELECT u.name, COUNT(v.id) c FROM users u LEFT JOIN vouchers v ON v.user_id=u.id + GROUP BY u.id HAVING c > 0 ORDER BY c DESC LIMIT 10" +); +$daily = $db->fetchAll( + "SELECT DATE(created_at) d, COUNT(*) c FROM vouchers + WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + GROUP BY DATE(created_at) ORDER BY d", [$days] +); +$chartLabels = array_map(fn($r) => date('d.m', strtotime($r['d'])), $daily); +$chartData = array_map(fn($r) => (int)$r['c'], $daily); + +$csrf = $auth->getCsrfToken(); +$currentPage = 'reports'; +$adminBase = ''; +?> + + + + + +Reporting – <?= htmlspecialchars($appTitle) ?> + + + + + +

📊 Reporting

+ +
+
+ + +
+ ⬇️ CSV (täglich) + ⬇️ CSV (pro Site) + ⬇️ CSV (pro Nutzer) + +
+ +
+
Vouchers gesamt
+
Gültig
+
Verwendet
+
In Tagen erstellt
+
+ +
+

Erstellte Voucher ( Tage)

+ +
+ +
+

Pro Site

+ + + + +
SiteGesamtGültigVerwendetAbgelaufen
+
+ +
+

Top-Nutzer

+ + + + + +
BenutzerVoucher erstellt
Keine Daten
+
+ + + + + + diff --git a/admin/security.php b/admin/security.php new file mode 100644 index 0000000..b1934d0 --- /dev/null +++ b/admin/security.php @@ -0,0 +1,194 @@ +requireLogin(); + +$db = Database::getInstance(); +$user = $auth->getCurrentUser(); +$appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); + +$error = ''; +$success = ''; +$backupCodes = []; // nur direkt nach Erzeugung gefüllt +$hasPassword = !empty($user['password_hash']); +$totpEnabled = !empty($user['totp_enabled']); +$setupRequired = isset($_GET['setup_required']); + +// 2FA aktivieren (Code bestaetigen) +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['enable_totp'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = 'Ungültiges Sicherheits-Token'; + } else { + $secret = $_SESSION['totp_setup_secret'] ?? ''; + $code = trim($_POST['code'] ?? ''); + if ($secret === '') { + $error = 'Setup abgelaufen, bitte erneut starten.'; + } elseif (!Totp::verify($secret, $code)) { + $error = 'Code ungültig. Bitte erneut versuchen.'; + } else { + $backupCodes = $auth->enableTotp($user['id'], $secret); + unset($_SESSION['totp_setup_secret']); + $totpEnabled = true; + $user = $auth->getCurrentUser(); + $success = 'Zwei-Faktor-Authentifizierung wurde aktiviert. Bitte Recovery-Codes sicher speichern!'; + } + } +} + +// Ü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 +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['regen_codes'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = 'Ungültiges Sicherheits-Token'; + } elseif (!empty($user['totp_enabled'])) { + $backupCodes = $auth->regenerateBackupCodes($user['id']); + $user = $auth->getCurrentUser(); + $success = 'Neue Recovery-Codes erzeugt. Die alten sind jetzt ungültig.'; + } +} + +// 2FA deaktivieren +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['disable_totp'])) { + if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) { + $error = 'Ungültiges Sicherheits-Token'; + } else { + $auth->disableTotp($user['id']); + $totpEnabled = false; + $success = 'Zwei-Faktor-Authentifizierung wurde deaktiviert.'; + } +} + +// Für die Setup-Ansicht ein Secret erzeugen (in Session halten bis bestätigt) +$setupSecret = ''; +$otpUri = ''; +if (!$totpEnabled && $hasPassword) { + $setupSecret = $_SESSION['totp_setup_secret'] ?? Totp::generateSecret(); + $_SESSION['totp_setup_secret'] = $setupSecret; + $otpUri = Totp::provisioningUri($setupSecret, $user['email'], $appTitle); +} +$csrf = $auth->getCsrfToken(); +$dbSessions = $db->getSetting('session_driver', 'php') === 'db'; +$activeSessions = $dbSessions ? $auth->activeSessionCount() : 0; +?> + + + + + +Zwei-Faktor-Authentifizierung – <?= htmlspecialchars($appTitle) ?> + + + + + + +
+

🔐 Zwei-Faktor-Authentifizierung

+

Konto:

+ + +
Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.
+ +
+
+ + +
+ 🔑 Recovery-Codes +

Bewahren Sie diese sicher auf. Jeder Code funktioniert einmal, falls Sie keinen Zugriff auf Ihre App haben.

+
+ +
+
+ + + +
● Nicht verfügbar
+

Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.

+ +
● Aktiv
+

Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.
+ Verbleibende Recovery-Codes: backupCodesRemaining($user) ?>

+
+ + +
+
+ + +
+ +
● Inaktiv
+
    +
  1. Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)
  2. +
  3. QR-Code scannen oder Secret manuell eingeben
  4. +
  5. Den angezeigten 6-stelligen Code unten eingeben
  6. +
+
+
+
+ + + + +
+ + + + +
+

Aktive Sitzungen:

+
+ + +
+ + + ← Zurück +
+ + diff --git a/admin/templates.php b/admin/templates.php index fe9512e..2fe218a 100644 --- a/admin/templates.php +++ b/admin/templates.php @@ -29,13 +29,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) { $expireMin = (int)($_POST['expire_minutes'] ?? 480); $description = trim($_POST['description'] ?? ''); + $qosDown = max(0, (int)($_POST['qos_rate_max_down'] ?? 0)) ?: null; + $qosUp = max(0, (int)($_POST['qos_rate_max_up'] ?? 0)) ?: null; + $qosQuota = max(0, (int)($_POST['qos_usage_quota'] ?? 0)) ?: null; + if (empty($name)) throw new Exception(__('error_name_req')); if ($maxUses < 1) $maxUses = 1; if ($expireMin < 1) $expireMin = 60; $db->execute( - "INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, created_by) VALUES (?, ?, ?, ?, ?)", - [$name, $maxUses, $expireMin, $description, $_SESSION['user_id']] + "INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']] ); $success = __('templates_added'); } catch (Exception $e) { @@ -57,11 +61,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) { $description = trim($_POST['description'] ?? ''); $isActive = isset($_POST['is_active']) ? 1 : 0; + $qosDown = max(0, (int)($_POST['qos_rate_max_down'] ?? 0)) ?: null; + $qosUp = max(0, (int)($_POST['qos_rate_max_up'] ?? 0)) ?: null; + $qosQuota = max(0, (int)($_POST['qos_usage_quota'] ?? 0)) ?: null; + if (empty($name)) throw new Exception(__('error_name_req')); $db->execute( - "UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, is_active=? WHERE id=?", - [$name, $maxUses, $expireMin, $description, $isActive, $id] + "UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?", + [$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id] ); $success = __('templates_updated'); } catch (Exception $e) { @@ -204,7 +212,7 @@ $adminBase = ''; -
+
+
+
+
+
@@ -276,6 +289,11 @@ $adminBase = '';
+
+
+
+
+
@@ -293,13 +311,16 @@ $adminBase = ''; + @@ -452,6 +514,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
@@ -631,6 +717,13 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText, const bmuEl = document.getElementById('bulk_max_uses'); if (bmuEl) bmuEl.value = maxUses; + const qosDown = opt.value ? (parseInt(opt.dataset.qosDown) || 0) : 0; + const qosUp = opt.value ? (parseInt(opt.dataset.qosUp) || 0) : 0; + const qosQuota = opt.value ? (parseInt(opt.dataset.qosQuota) || 0) : 0; + document.querySelectorAll('.qos-down-field').forEach(el => el.value = qosDown); + document.querySelectorAll('.qos-up-field').forEach(el => el.value = qosUp); + document.querySelectorAll('.qos-quota-field').forEach(el => el.value = qosQuota); + const descEl = document.getElementById('template_desc'); if (descEl) descEl.textContent = opt.dataset.desc || ''; } diff --git a/lang/de.php b/lang/de.php index c96ba91..6e71868 100644 --- a/lang/de.php +++ b/lang/de.php @@ -7,7 +7,13 @@ return [ 'nav_vouchers' => 'Live Vouchers', 'nav_templates' => 'Voucher-Profile', 'nav_audit_log' => 'Audit-Log', + 'nav_reports' => 'Reporting', + 'nav_import' => 'Voucher-Import', 'nav_settings' => 'Einstellungen', + 'nav_api_keys' => 'API-Schlüssel', + 'nav_security' => 'Sicherheit (2FA)', + 'nav_integrations' => 'Integration & Wartung', + 'nav_backup' => 'Backup & Restore', 'nav_update' => 'System-Update', 'nav_back' => 'Zurück zur Startseite', 'nav_administration'=> 'Administration', diff --git a/lang/en.php b/lang/en.php index 42b4b56..4651fb9 100644 --- a/lang/en.php +++ b/lang/en.php @@ -7,7 +7,13 @@ return [ 'nav_vouchers' => 'Live Vouchers', 'nav_templates' => 'Voucher Profiles', 'nav_audit_log' => 'Audit Log', + 'nav_reports' => 'Reporting', + 'nav_import' => 'Voucher Import', 'nav_settings' => 'Settings', + 'nav_api_keys' => 'API Keys', + 'nav_security' => 'Security (2FA)', + 'nav_integrations' => 'Integration & Maintenance', + 'nav_backup' => 'Backup & Restore', 'nav_update' => 'System Update', 'nav_back' => 'Back to Home', 'nav_administration'=> 'Administration', diff --git a/login.php b/login.php index 59b981b..1530eef 100644 --- a/login.php +++ b/login.php @@ -19,8 +19,21 @@ I18n::init(); $error = ''; $success = ''; +$show2fa = false; -if ($_SERVER['REQUEST_METHOD'] === 'POST') { +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['totp_code'])) { + // Zweiter Login-Schritt: 2FA-Code + try { + if ($auth->verifyTotpLogin(trim($_POST['totp_code']))) { + header('Location: index.php'); + exit; + } + $error = 'Code ungültig oder abgelaufen. Bitte erneut versuchen.'; + $show2fa = $auth->isTotpPending(); + } catch (Exception $e) { + $error = 'Login-Fehler: ' . $e->getMessage(); + } +} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') { try { $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? ''; @@ -32,6 +45,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($result === true) { header('Location: index.php'); exit; + } elseif ($result === 'totp_required') { + $show2fa = true; } elseif ($result === 'rate_limited') { $error = __('login_error_rate'); } else { @@ -43,6 +58,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } } +// Direkter Aufruf mit ?2fa=1 (z.B. nach Redirect) und noch ausstehendem Login +if (!$show2fa && isset($_GET['2fa']) && $auth->isTotpPending()) { + $show2fa = true; +} + try { $db = Database::getInstance(); $appTitle = $db->getSetting('app_title', 'UniFi Voucher System'); @@ -74,6 +94,27 @@ try { $m365LoginUrl = "https://login.microsoftonline.com/$m365TenantId/oauth2/v2.0/authorize?" . http_build_query($params); } + // Generisches OIDC (optional) + $oidcEnabled = $db->getSetting('oidc_enabled', '0') === '1' + && $db->getSetting('oidc_client_id', '') !== '' + && $db->getSetting('oidc_auth_url', '') !== ''; + $oidcName = $db->getSetting('oidc_name', 'SSO'); + $oidcLoginUrl = ''; + if ($oidcEnabled) { + $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http'; + $scriptPath = dirname($_SERVER['SCRIPT_NAME']); + $scriptPath = $scriptPath === '/' ? '' : $scriptPath; + $oidcState = bin2hex(random_bytes(16)); + $_SESSION['oidc_state'] = $oidcState; + $oidcLoginUrl = rtrim($db->getSetting('oidc_auth_url', ''), '?') . '?' . http_build_query([ + 'client_id' => $db->getSetting('oidc_client_id', ''), + 'response_type' => 'code', + 'redirect_uri' => $protocol . '://' . $_SERVER['HTTP_HOST'] . $scriptPath . '/oidc_callback.php', + 'scope' => $db->getSetting('oidc_scopes', 'openid profile email'), + 'state' => $oidcState, + ]); + } + $showLocalLogin = isset($_GET['local']) && $_GET['local'] === '1'; } catch (Exception $e) { @@ -146,7 +187,23 @@ try {
- + +
+

+ Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein + – oder einen Ihrer Recovery-Codes. +

+
+ + +
+ +
+
+ @@ -187,6 +244,13 @@ try { + +
+
+ 🔑 + + + diff --git a/oidc_callback.php b/oidc_callback.php new file mode 100644 index 0000000..852889c --- /dev/null +++ b/oidc_callback.php @@ -0,0 +1,101 @@ +getSetting('oidc_client_id', ''); +$clientSecret = $db->getSetting('oidc_client_secret', ''); +$tokenUrl = $db->getSetting('oidc_token_url', ''); +$userinfoUrl = $db->getSetting('oidc_userinfo_url', ''); + +if ($db->getSetting('oidc_enabled', '0') !== '1' || $clientId === '' || $tokenUrl === '' || $userinfoUrl === '') { + die('OIDC ist nicht konfiguriert. Zurück zum Login'); +} + +if (isset($_GET['error'])) { + die('OIDC-Fehler: ' . htmlspecialchars($_GET['error']) . '
Zurück zum Login'); +} +if (!isset($_GET['code'])) { + die('Kein Authorization Code erhalten.
Zurück zum Login'); +} + +// State validieren (CSRF) +$sessionState = $_SESSION['oidc_state'] ?? ''; +$returnedState = $_GET['state'] ?? ''; +unset($_SESSION['oidc_state']); +if ($sessionState === '' || !hash_equals($sessionState, $returnedState)) { + die('Ungültiger Sicherheits-Token (state).
Zurück zum Login'); +} + +$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +$scriptPath = dirname($_SERVER['SCRIPT_NAME']); +$scriptPath = $scriptPath === '/' ? '' : $scriptPath; +$redirectUri = $protocol . '://' . $_SERVER['HTTP_HOST'] . $scriptPath . '/oidc_callback.php'; + +// Code -> Token +$ch = curl_init($tokenUrl); +curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'grant_type' => 'authorization_code', + 'code' => $_GET['code'], + 'redirect_uri' => $redirectUri, + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + ]), + CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, +]); +$resp = curl_exec($ch); +$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); +$token = json_decode((string)$resp, true); + +if ($httpCode !== 200 || empty($token['access_token'])) { + die('Token-Abruf fehlgeschlagen (HTTP ' . $httpCode . ').
Zurück zum Login'); +} + +// Userinfo abrufen +$ch = curl_init($userinfoUrl); +curl_setopt_array($ch, [ + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token['access_token'], 'Accept: application/json'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, +]); +$uResp = curl_exec($ch); +$uCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); +curl_close($ch); +$info = json_decode((string)$uResp, true); + +if ($uCode !== 200 || !is_array($info)) { + die('Benutzerinfo-Abruf fehlgeschlagen (HTTP ' . $uCode . ').
Zurück zum Login'); +} + +$sub = $info['sub'] ?? ($info['id'] ?? ''); +$email = $info['email'] ?? ($info['preferred_username'] ?? ''); +$name = $info['name'] ?? trim(($info['given_name'] ?? '') . ' ' . ($info['family_name'] ?? '')); +if ($sub === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + die('OIDC lieferte keine gültige Identität (sub/email).
Zurück zum Login'); +} + +try { + // Wiederverwendung der externen-SSO-Verknüpfung (microsoft_id = externe ID) + $auth->loginWithMicrosoft(['id' => 'oidc:' . $sub, 'email' => $email, 'name' => $name ?: $email]); + header('Location: index.php'); + exit; +} catch (Exception $e) { + die('Login-Fehler: ' . htmlspecialchars($e->getMessage()) . '
Zurück zum Login'); +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..818df76 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 5 + paths: + - includes/Totp.php + - includes/Crypto.php + - includes/ApiKey.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..c31cd71 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/tests/ApiKeyTest.php b/tests/ApiKeyTest.php new file mode 100644 index 0000000..c7dbd73 --- /dev/null +++ b/tests/ApiKeyTest.php @@ -0,0 +1,35 @@ +assertStringStartsWith('uvt_', $k['plain']); + $this->assertSame(44, strlen($k['plain'])); + $this->assertSame(hash('sha256', $k['plain']), $k['hash']); + $this->assertSame(substr($k['plain'], 4, 8), $k['prefix']); + } + + public function testScopes(): void + { + $read = ['scope' => 'read']; + $write = ['scope' => 'write']; + $this->assertTrue(\ApiKey::hasScope($read, 'read')); + $this->assertFalse(\ApiKey::hasScope($read, 'write')); + $this->assertTrue(\ApiKey::hasScope($write, 'read')); + $this->assertTrue(\ApiKey::hasScope($write, 'write')); + } + + public function testFromRequestBearer(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer uvt_testkey123'; + $this->assertSame('uvt_testkey123', \ApiKey::fromRequest()); + unset($_SERVER['HTTP_AUTHORIZATION']); + } +} diff --git a/tests/CryptoTest.php b/tests/CryptoTest.php new file mode 100644 index 0000000..195d79b --- /dev/null +++ b/tests/CryptoTest.php @@ -0,0 +1,39 @@ +assertNotSame($plain, $cipher); + $this->assertTrue(\Crypto::isEncrypted($cipher)); + $this->assertSame($plain, \Crypto::decrypt($cipher)); + } + + public function testPlaintextPassthrough(): void + { + // Legacy-/Klartextwerte werden unverändert zurückgegeben. + $this->assertSame('altesKlartextPW', \Crypto::decrypt('altesKlartextPW')); + } + + public function testEmptyValues(): void + { + $this->assertSame('', \Crypto::encrypt('')); + $this->assertNull(\Crypto::decrypt(null)); + } + + public function testGenerateKeyLength(): void + { + $key = \Crypto::generateKey(); + $this->assertSame(32, strlen(base64_decode($key))); + } +} diff --git a/tests/MigrationSplitterTest.php b/tests/MigrationSplitterTest.php new file mode 100644 index 0000000..c3129ff --- /dev/null +++ b/tests/MigrationSplitterTest.php @@ -0,0 +1,42 @@ +newInstanceWithoutConstructor(); + $m = $rc->getMethod('splitStatements'); + $m->setAccessible(true); + $parts = $m->invoke($inst, $sql); + return array_values(array_filter(array_map('trim', $parts), fn($s) => $s !== '')); + } + + public function testIgnoresSemicolonsInStringsAndComments(): void + { + $sql = "INSERT INTO t (a) VALUES (\";semi;colon\"); -- comment; not split\n" + . "CREATE TABLE x (id INT); /* block ; comment */ INSERT INTO y VALUES (1);"; + $parts = $this->split($sql); + $this->assertCount(3, $parts); + } + + public function testSingleStatement(): void + { + $parts = $this->split("ALTER TABLE users ADD COLUMN foo INT"); + $this->assertCount(1, $parts); + } + + public function testIgnorableErrorDetection(): void + { + $rc = new \ReflectionClass(\Updater\MigrationRunner::class); + $inst = $rc->newInstanceWithoutConstructor(); + $this->assertTrue($inst->isIgnorableSqlError('Duplicate column name "x"', 'mysql')); + $this->assertTrue($inst->isIgnorableSqlError('Table already exists', 'mysql')); + $this->assertFalse($inst->isIgnorableSqlError('Syntax error near FROM', 'mysql')); + } +} diff --git a/tests/TotpTest.php b/tests/TotpTest.php new file mode 100644 index 0000000..5185398 --- /dev/null +++ b/tests/TotpTest.php @@ -0,0 +1,46 @@ +assertSame('287082', \Totp::code($secret, intdiv(59, 30))); + $this->assertSame('081804', \Totp::code($secret, intdiv(1111111109, 30))); + $this->assertSame('005924', \Totp::code($secret, intdiv(1234567890, 30))); + } + + public function testVerifyAcceptsCurrentCode(): void + { + $secret = \Totp::generateSecret(); + $code = \Totp::code($secret); + $this->assertTrue(\Totp::verify($secret, $code)); + } + + public function testVerifyRejectsWrongCode(): void + { + $secret = \Totp::generateSecret(); + $wrong = \Totp::code($secret) === '000000' ? '111111' : '000000'; + $this->assertFalse(\Totp::verify($secret, $wrong)); + } + + public function testVerifyRejectsMalformed(): void + { + $secret = \Totp::generateSecret(); + $this->assertFalse(\Totp::verify($secret, 'abcdef')); + $this->assertFalse(\Totp::verify($secret, '12345')); + } + + public function testProvisioningUri(): void + { + $uri = \Totp::provisioningUri('ABC', 'user@example.com', 'My App'); + $this->assertStringStartsWith('otpauth://totp/', $uri); + $this->assertStringContainsString('secret=ABC', $uri); + } +} diff --git a/updater/migrations/0002_extended_features.sql b/updater/migrations/0002_extended_features.sql new file mode 100644 index 0000000..51a9e93 --- /dev/null +++ b/updater/migrations/0002_extended_features.sql @@ -0,0 +1,27 @@ +-- Updater-Migration: Schema fuer erweiterte Funktionen +-- * 2FA (TOTP) fuer lokale Accounts +-- * Bandbreiten-/Datenlimits in Voucher-Profilen +-- * REST-API-Schluessel +-- Idempotent gehalten; "duplicate column"/"already exists" werden vom +-- MigrationRunner ignoriert. + +ALTER TABLE `users` ADD COLUMN `totp_secret` VARCHAR(64) NULL; +ALTER TABLE `users` ADD COLUMN `totp_enabled` TINYINT(1) NOT NULL DEFAULT 0; + +ALTER TABLE `voucher_templates` ADD COLUMN `qos_rate_max_down` INT NULL; +ALTER TABLE `voucher_templates` ADD COLUMN `qos_rate_max_up` INT NULL; +ALTER TABLE `voucher_templates` ADD COLUMN `qos_usage_quota` INT NULL; + +CREATE TABLE IF NOT EXISTS `api_keys` ( + `id` INT PRIMARY KEY AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `key_prefix` VARCHAR(16) NOT NULL, + `key_hash` VARCHAR(255) NOT NULL, + `created_by` INT, + `last_used_at` TIMESTAMP NULL, + `is_active` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL, + INDEX `idx_prefix` (`key_prefix`), + INDEX `idx_active` (`is_active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/updater/migrations/0003_maturity_features.sql b/updater/migrations/0003_maturity_features.sql new file mode 100644 index 0000000..f11800f --- /dev/null +++ b/updater/migrations/0003_maturity_features.sql @@ -0,0 +1,16 @@ +-- Updater-Migration: Reife-Funktionen +-- * 2FA Recovery-/Backup-Codes +-- * API-Scopes & Rate-Limit pro Schlüssel +-- Idempotent; "duplicate column"/"already exists" werden ignoriert. + +ALTER TABLE `users` ADD COLUMN `totp_backup_codes` TEXT NULL; + +ALTER TABLE `api_keys` ADD COLUMN `scope` VARCHAR(16) NOT NULL DEFAULT 'write'; +ALTER TABLE `api_keys` ADD COLUMN `rate_limit` INT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS `api_key_hits` ( + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `api_key_id` INT NOT NULL, + `hit_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX `idx_key_time` (`api_key_id`, `hit_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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;