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
9
.dockerignore
Normal file
|
|
@ -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
|
||||||
57
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -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
|
||||||
39
.github/workflows/docker-publish.yml
vendored
Normal file
|
|
@ -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 }}
|
||||||
14
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
36
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
89
Readme.md
|
|
@ -8,7 +8,8 @@
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|

|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -29,7 +30,20 @@
|
||||||
- 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten
|
- 🏢 **Multi-Site-Support** – beliebig viele UniFi-Standorte zentral verwalten
|
||||||
- 👥 **Benutzerverwaltung** mit granularer Site-Zugriffskontrolle
|
- 👥 **Benutzerverwaltung** mit granularer Site-Zugriffskontrolle
|
||||||
- 🔐 **Authentifizierung** via lokale Accounts **oder** Microsoft 365 OAuth
|
- 🔐 **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)
|
- 🔑 **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)
|
- 🌍 **Öffentlicher Modus** – optional ohne Login nutzbar (mit CSRF-Schutz & Throttle)
|
||||||
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
|
- 🌗 **Dark Mode** – umschaltbar, Einstellung wird im Browser gespeichert
|
||||||
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
- 🌐 **Mehrsprachig** – Deutsch / Englisch per Umschalter (`lang/`)
|
||||||
|
|
@ -72,6 +86,17 @@
|
||||||
<img src="docs/screenshots/maintenance.png" alt="Wartungsmodus" width="48%">
|
<img src="docs/screenshots/maintenance.png" alt="Wartungsmodus" width="48%">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
### REST-API, 2FA & Integrationen
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/api-keys.png" alt="API-Schlüssel-Verwaltung" width="48%">
|
||||||
|
<img src="docs/screenshots/integrations.png" alt="Integration & Wartung" width="48%">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/two-factor.png" alt="Zwei-Faktor-Authentifizierung" width="60%">
|
||||||
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Anforderungen
|
## 📋 Anforderungen
|
||||||
|
|
@ -286,6 +311,59 @@ Body: {"cmd": "delete-voucher", "_id": "<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 <key>` 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=<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
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
- [x] Voucher-Templates (vordefinierte Laufzeiten)
|
- [x] Voucher-Templates (vordefinierte Laufzeiten)
|
||||||
|
|
@ -295,13 +373,16 @@ Body: {"cmd": "delete-voucher", "_id": "<voucher_id>"}
|
||||||
- [x] Passwort-Reset
|
- [x] Passwort-Reset
|
||||||
- [x] Audit-Log
|
- [x] Audit-Log
|
||||||
- [x] Auto-Updater mit DB-Migrationen
|
- [x] Auto-Updater mit DB-Migrationen
|
||||||
- [ ] Erweiterte Reporting-Funktionen
|
- [x] REST-API mit API-Schlüsseln
|
||||||
- [ ] Docker-Container
|
- [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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
**Version 2.1.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
**Version 2.4.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
171
admin/api_keys.php
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?php
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/ApiKey.php';
|
||||||
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
|
||||||
|
$auth = new Auth();
|
||||||
|
$auth->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 = '';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API-Schlüssel – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.card { background: var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); }
|
||||||
|
.card h2 { font-size:16px; margin-bottom:16px; color:var(--text-primary); }
|
||||||
|
table { width:100%; border-collapse:collapse; }
|
||||||
|
th,td { text-align:left; padding:11px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
|
||||||
|
th { color:var(--text-muted); font-weight:600; }
|
||||||
|
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
|
||||||
|
.btn { padding:10px 16px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
||||||
|
.btn-primary { background:var(--accent,#667eea); color:#fff; }
|
||||||
|
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
||||||
|
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
.keybox { background:#0f1117; color:#7CFFB2; font-family:monospace; padding:14px; border-radius:8px; word-break:break-all; font-size:15px; margin-top:10px; }
|
||||||
|
.badge { padding:3px 9px; border-radius:6px; font-size:12px; font-weight:600; }
|
||||||
|
.b-on { background:#e3f6ea; color:#2a7; } .b-off { background:#fdeaea; color:#c33; }
|
||||||
|
.a-link { color:var(--accent,#667eea); text-decoration:none; margin-right:10px; font-size:13px; }
|
||||||
|
.muted { color:var(--text-muted); font-size:13px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔑 API-Schlüssel</h1>
|
||||||
|
|
||||||
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($newKey): ?>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Neuer Schlüssel</h2>
|
||||||
|
<p class="muted">Kopieren Sie ihn jetzt – aus Sicherheitsgründen wird er nicht erneut angezeigt.</p>
|
||||||
|
<div class="keybox"><?= htmlspecialchars($newKey) ?></div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Neuen API-Schlüssel erstellen</h2>
|
||||||
|
<form method="post" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
<div style="flex:2;min-width:200px;">
|
||||||
|
<label class="muted" style="display:block;margin-bottom:6px;">Bezeichnung</label>
|
||||||
|
<input class="input" type="text" name="name" placeholder="z.B. Buchungssystem, Terminal Foyer" required>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:130px;">
|
||||||
|
<label class="muted" style="display:block;margin-bottom:6px;">Berechtigung</label>
|
||||||
|
<select class="input" name="scope">
|
||||||
|
<option value="write">Lesen + Erstellen</option>
|
||||||
|
<option value="read">Nur Lesen</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:120px;">
|
||||||
|
<label class="muted" style="display:block;margin-bottom:6px;">Limit (Anfr./min)</label>
|
||||||
|
<input class="input" type="number" name="rate_limit" min="0" value="0" title="0 = unbegrenzt">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit" name="create_key">Erstellen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Vorhandene Schlüssel</h2>
|
||||||
|
<?php if (empty($keys)): ?>
|
||||||
|
<p class="muted">Noch keine API-Schlüssel angelegt.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table>
|
||||||
|
<tr><th>Name</th><th>Präfix</th><th>Scope</th><th>Limit</th><th>Status</th><th>Zuletzt genutzt</th><th>Erstellt von</th><th></th></tr>
|
||||||
|
<?php foreach ($keys as $k): ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars($k['name']) ?></td>
|
||||||
|
<td><code>uvt_<?= htmlspecialchars($k['key_prefix']) ?>…</code></td>
|
||||||
|
<td><?= ($k['scope'] ?? 'write') === 'read' ? 'nur Lesen' : 'Lesen+Erstellen' ?></td>
|
||||||
|
<td><?= (int)($k['rate_limit'] ?? 0) === 0 ? '∞' : (int)$k['rate_limit'] . '/min' ?></td>
|
||||||
|
<td><span class="badge <?= $k['is_active'] ? 'b-on' : 'b-off' ?>"><?= $k['is_active'] ? 'aktiv' : 'gesperrt' ?></span></td>
|
||||||
|
<td class="muted"><?= $k['last_used_at'] ? htmlspecialchars($k['last_used_at']) : '–' ?></td>
|
||||||
|
<td class="muted"><?= htmlspecialchars($k['creator'] ?? '–') ?></td>
|
||||||
|
<td style="text-align:right;white-space:nowrap;">
|
||||||
|
<a class="a-link" href="?toggle=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>"><?= $k['is_active'] ? 'Sperren' : 'Aktivieren' ?></a>
|
||||||
|
<a class="a-link" style="color:#e25555;" href="?delete=<?= (int)$k['id'] ?>&token=<?= urlencode($csrf) ?>" onclick="return confirm('Schlüssel löschen?');">Löschen</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Verwendung</h2>
|
||||||
|
<p class="muted" style="margin-bottom:10px;">Authentifizierung per Header <code>Authorization: Bearer <key></code> oder <code>X-API-Key: <key></code>.</p>
|
||||||
|
<pre class="keybox" style="color:#cdd3e0;white-space:pre-wrap;"># 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_…"</pre>
|
||||||
|
<p class="muted" style="margin-top:12px;">OpenAPI-Spezifikation (Import in Postman/Swagger): <a href="../api/openapi.php" target="_blank">/api/openapi.php</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /main-content -->
|
||||||
|
<script src="../assets/global.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
159
admin/backup.php
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<?php
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
|
||||||
|
$auth = new Auth();
|
||||||
|
$auth->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 = '';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Backup & Restore – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
|
||||||
|
.card h2 { font-size:16px; margin-bottom:12px; color:var(--text-primary); }
|
||||||
|
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
|
||||||
|
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
||||||
|
.btn-primary { background:var(--accent,#667eea); color:#fff; }
|
||||||
|
.btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
||||||
|
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
label.chk { display:flex; align-items:center; gap:9px; margin:8px 0; color:var(--text-primary); font-size:14px; }
|
||||||
|
input[type=file] { margin:10px 0; color:var(--text-primary); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">💾 Backup & Restore</h1>
|
||||||
|
|
||||||
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Export</h2>
|
||||||
|
<p class="muted">Lädt Einstellungen, Sites und Voucher-Profile als JSON. Site-Passwörter bleiben mit dem <code>APP_KEY</code> dieser Installation verschlüsselt – ein Restore auf einer Installation mit anderem APP_KEY kann sie nicht entschlüsseln.</p>
|
||||||
|
<a class="btn btn-primary" href="?export=1&token=<?= urlencode($csrf) ?>">Konfiguration exportieren</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Import / Restore</h2>
|
||||||
|
<p class="muted">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.</p>
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
<input type="file" name="backup" accept="application/json,.json" required><br>
|
||||||
|
<label class="chk"><input type="checkbox" name="import_settings" checked> Einstellungen</label>
|
||||||
|
<label class="chk"><input type="checkbox" name="import_sites" checked> Sites</label>
|
||||||
|
<label class="chk"><input type="checkbox" name="import_templates" checked> Voucher-Profile</label>
|
||||||
|
<button class="btn btn-primary" type="submit" name="import" style="margin-top:12px;" onclick="return confirm('Import jetzt durchführen?');">Importieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /main-content -->
|
||||||
|
<script src="../assets/global.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
148
admin/import.php
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<?php
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/UniFiController.php';
|
||||||
|
require_once __DIR__ . '/../includes/Notifier.php';
|
||||||
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
|
||||||
|
$auth = new Auth();
|
||||||
|
$auth->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 = '';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CSV-Import – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); max-width:760px; }
|
||||||
|
.card h2 { font-size:15px; margin-bottom:12px; color:var(--text-primary); }
|
||||||
|
.muted { color:var(--text-muted); font-size:13px; margin-bottom:12px; }
|
||||||
|
label { display:block; font-size:13px; color:var(--text-secondary); margin:12px 0 6px; }
|
||||||
|
.input,textarea,select { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; font-family:inherit; }
|
||||||
|
textarea { min-height:140px; font-family:monospace; }
|
||||||
|
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; font-size:14px; cursor:pointer; background:var(--accent,#667eea); color:#fff; }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:760px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:8px; font-size:13px; border-bottom:1px solid var(--border-color); color:var(--text-primary); }
|
||||||
|
code { font-family:monospace; background:var(--bg-hover); padding:2px 6px; border-radius:5px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📥 Voucher-Import (CSV)</h1>
|
||||||
|
|
||||||
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Mehrere Voucher erstellen</h2>
|
||||||
|
<p class="muted">Eine Zeile pro Voucher: <code>Name,MaxGeräte,Minuten</code> – MaxGeräte und Minuten sind optional (Standardwerte greifen). Max. 200 Zeilen. Beispiel:<br>
|
||||||
|
<code>Gast Müller,1,480</code> · <code>Konferenzraum A,5,240</code> · <code>Tagespass</code></p>
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
<label>Standort</label>
|
||||||
|
<select class="input" name="site_id" required>
|
||||||
|
<?php foreach ($sites as $s): ?><option value="<?= (int)$s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option><?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<label>CSV-Datei (optional)</label>
|
||||||
|
<input class="input" type="file" name="csv" accept=".csv,text/csv">
|
||||||
|
<label>… oder direkt einfügen</label>
|
||||||
|
<textarea name="csv_text" placeholder="Gast Müller,1,480 Konferenzraum A,5,240"></textarea>
|
||||||
|
<button class="btn" type="submit" name="do_import" style="margin-top:14px;" onclick="return confirm('Import jetzt starten?');">Importieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($results)): ?>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Ergebnis</h2>
|
||||||
|
<table><tr><th>Name</th><th>Code / Fehler</th><th>Status</th></tr>
|
||||||
|
<?php foreach ($results as $r): ?>
|
||||||
|
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><code><?= htmlspecialchars($r['code']) ?></code></td><td><?= $r['ok'] ? '✅' : '❌' ?></td></tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div><!-- /main-content -->
|
||||||
|
<script src="../assets/global.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
204
admin/integrations.php
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
<?php
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/Notifier.php';
|
||||||
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
|
||||||
|
$auth = new Auth();
|
||||||
|
$auth->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 = '';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Integration & Wartung – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:24px; margin-bottom:22px; box-shadow:0 4px 14px var(--shadow); max-width:680px; }
|
||||||
|
.card h2 { font-size:16px; margin-bottom:6px; color:var(--text-primary); }
|
||||||
|
.muted { color:var(--text-muted); font-size:13px; margin-bottom:14px; }
|
||||||
|
label { display:block; font-size:14px; color:var(--text-secondary); margin:14px 0 6px; }
|
||||||
|
.input { width:100%; padding:11px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); font-size:14px; }
|
||||||
|
.row { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
||||||
|
.chk { display:flex; align-items:center; gap:9px; margin-top:14px; color:var(--text-primary); font-size:14px; }
|
||||||
|
.btn { padding:11px 18px; border:none; border-radius:8px; font-weight:600; cursor:pointer; font-size:14px; text-decoration:none; display:inline-block; }
|
||||||
|
.btn-primary { background:var(--accent,#667eea); color:#fff; } .btn-secondary { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; max-width:680px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; } .alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:20px;color:var(--text-primary);">🔧 Integration & Wartung</h1>
|
||||||
|
|
||||||
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Sicherheitsrichtlinie</h2>
|
||||||
|
<p class="muted">Erzwingt Zwei-Faktor-Authentifizierung für alle Administrator-Konten (lokale Accounts). Admins ohne 2FA werden bei der nächsten Aktion zur Einrichtung geleitet.</p>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<select class="input" name="captcha_mode" style="max-width:240px;">
|
||||||
|
<option value="off" <?= $captchaMode==='off'?'selected':'' ?>>Aus</option>
|
||||||
|
<option value="math" <?= $captchaMode==='math'?'selected':'' ?>>Rechenaufgabe (ohne externen Dienst)</option>
|
||||||
|
<option value="hcaptcha" <?= $captchaMode==='hcaptcha'?'selected':'' ?>>hCaptcha</option>
|
||||||
|
</select>
|
||||||
|
<div class="row3" style="margin-top:10px;">
|
||||||
|
<div><label>hCaptcha Site-Key</label><input class="input" type="text" name="captcha_site_key" value="<?= htmlspecialchars($captchaSiteKey) ?>"></div>
|
||||||
|
<div><label>hCaptcha Secret<?= $captchaSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="captcha_secret" placeholder="<?= $captchaSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Reverse-Proxy</h2>
|
||||||
|
<p class="muted">IP-Adressen vertrauenswürdiger Proxies (kommasepariert). Nur dann wird die echte Client-IP aus <code>X-Forwarded-For</code> für Rate-Limit & Audit verwendet.</p>
|
||||||
|
<input class="input" type="text" name="trusted_proxy" value="<?= htmlspecialchars($trustedProxy) ?>" placeholder="z.B. 10.0.0.1, 172.18.0.1">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Webhook-Benachrichtigungen</h2>
|
||||||
|
<p class="muted">Slack-, Microsoft-Teams- oder generische JSON-Webhook-URL. Wird bei Voucher-Erstellung ausgelöst.</p>
|
||||||
|
<label class="chk"><input type="checkbox" name="webhook_enabled" <?= $webhookEnabled ? 'checked' : '' ?>> Webhook aktiv</label>
|
||||||
|
<label>Webhook-URL</label>
|
||||||
|
<input class="input" type="url" name="webhook_url" value="<?= htmlspecialchars($webhookUrl) ?>" placeholder="https://hooks.slack.com/services/…">
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
<a class="btn btn-secondary" href="?test_webhook=1&token=<?= urlencode($csrf) ?>">Test senden</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>SMS-Versand (Twilio)</h2>
|
||||||
|
<p class="muted">Voucher-Codes optional per SMS versenden. Erfordert ein Twilio-Konto.</p>
|
||||||
|
<label class="chk"><input type="checkbox" name="sms_enabled" <?= $smsEnabled ? 'checked' : '' ?>> SMS-Versand aktiv</label>
|
||||||
|
<div class="row3" style="margin-top:10px;">
|
||||||
|
<div><label>Account SID</label><input class="input" type="text" name="twilio_sid" value="<?= htmlspecialchars($twilioSid) ?>"></div>
|
||||||
|
<div><label>Auth Token<?= $twilioTokenSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="twilio_token" placeholder="<?= $twilioTokenSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
||||||
|
<div><label>Absender (From)</label><input class="input" type="text" name="twilio_from" value="<?= htmlspecialchars($twilioFrom) ?>" placeholder="+49…"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Single Sign-On (OpenID Connect)</h2>
|
||||||
|
<p class="muted">Generischer OIDC-Provider (z.B. Keycloak, Authentik, Google, Auth0). Redirect-URI: <code><?= htmlspecialchars(((!empty($_SERVER['HTTPS'])&&$_SERVER['HTTPS']!=='off')?'https':'http').'://'.$_SERVER['HTTP_HOST'].rtrim(dirname($_SERVER['SCRIPT_NAME']),'/').'/../oidc_callback.php') ?></code></p>
|
||||||
|
<label class="chk"><input type="checkbox" name="oidc_enabled" <?= $oidcEnabled ? 'checked' : '' ?>> OIDC-Login aktiv</label>
|
||||||
|
<div class="row3" style="margin-top:10px;">
|
||||||
|
<div><label>Button-Text</label><input class="input" type="text" name="oidc_name" value="<?= htmlspecialchars($oidcName) ?>"></div>
|
||||||
|
<div><label>Client ID</label><input class="input" type="text" name="oidc_client_id" value="<?= htmlspecialchars($oidcClientId) ?>"></div>
|
||||||
|
<div><label>Client Secret<?= $oidcSecretSet ? ' (gesetzt)' : '' ?></label><input class="input" type="password" name="oidc_client_secret" placeholder="<?= $oidcSecretSet ? '••••••• (leer = unverändert)' : '' ?>"></div>
|
||||||
|
</div>
|
||||||
|
<label>Authorization Endpoint</label><input class="input" type="url" name="oidc_auth_url" value="<?= htmlspecialchars($oidcAuthUrl) ?>" placeholder="https://idp/authorize">
|
||||||
|
<label>Token Endpoint</label><input class="input" type="url" name="oidc_token_url" value="<?= htmlspecialchars($oidcTokenUrl) ?>" placeholder="https://idp/token">
|
||||||
|
<label>Userinfo Endpoint</label><input class="input" type="url" name="oidc_userinfo_url" value="<?= htmlspecialchars($oidcUserinfoUrl) ?>" placeholder="https://idp/userinfo">
|
||||||
|
<label>Scopes</label><input class="input" type="text" name="oidc_scopes" value="<?= htmlspecialchars($oidcScopes) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Datenhaltung & Cleanup (DSGVO)</h2>
|
||||||
|
<p class="muted">Aufbewahrungsfristen in Tagen (0 = deaktiviert). Ausführung per <code>cron_cleanup.php</code> (täglich empfohlen).
|
||||||
|
<?php if ($lastCleanup): ?><br>Letzter Lauf: <?= htmlspecialchars($lastCleanup) ?><?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<div class="row">
|
||||||
|
<div><label>Abgelaufene Voucher</label><input class="input" type="number" min="0" name="cleanup_expired_days" value="<?= $cleanupExpired ?>"></div>
|
||||||
|
<div><label>Audit-Log</label><input class="input" type="number" min="0" name="cleanup_audit_days" value="<?= $cleanupAudit ?>"></div>
|
||||||
|
<div><label>Login-Versuche</label><input class="input" type="number" min="0" name="cleanup_login_days" value="<?= $cleanupLogin ?>"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" type="submit" name="save">Speichern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div><!-- /main-content -->
|
||||||
|
<script src="../assets/global.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
174
admin/reports.php
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
require_once __DIR__ . '/../includes/I18n.php';
|
||||||
|
|
||||||
|
$auth = new Auth();
|
||||||
|
$auth->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 = '';
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<?= I18n::getLanguage() ?>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Reporting – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<?php require __DIR__ . '/../includes/admin_nav.php'; ?>
|
||||||
|
<style>
|
||||||
|
.card { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:22px; margin-bottom:20px; box-shadow:0 4px 14px var(--shadow); }
|
||||||
|
.card h2 { font-size:15px; margin-bottom:14px; color:var(--text-primary); }
|
||||||
|
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:20px; }
|
||||||
|
.stat { background:var(--bg-card); border:1px solid var(--border-color); border-radius:14px; padding:20px; }
|
||||||
|
.stat .n { font-size:28px; font-weight:700; color:var(--text-primary); } .stat .l { color:var(--text-muted); font-size:12.5px; margin-top:3px; }
|
||||||
|
table { width:100%; border-collapse:collapse; } th,td { text-align:left; padding:10px 8px; font-size:14px; border-bottom:1px solid var(--border-color); color:var(--text-primary); } th { color:var(--text-muted); }
|
||||||
|
.btn { padding:9px 15px; border:none; border-radius:8px; font-weight:600; font-size:13px; text-decoration:none; display:inline-block; cursor:pointer; }
|
||||||
|
.btn-s { background:var(--bg-hover); color:var(--text-primary); border:1px solid var(--border-color); }
|
||||||
|
.toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:18px; }
|
||||||
|
select.input { padding:9px; border:2px solid var(--border-color); border-radius:8px; background:var(--bg-input,#fff); color:var(--text-primary); }
|
||||||
|
@media print { .sidebar,.header,.toolbar,.no-print { display:none !important; } .main-content { margin:0 !important; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 style="font-size:24px;margin-bottom:18px;color:var(--text-primary);">📊 Reporting</h1>
|
||||||
|
|
||||||
|
<div class="toolbar no-print">
|
||||||
|
<form method="get" style="display:flex;gap:8px;align-items:center;">
|
||||||
|
<label style="color:var(--text-muted);font-size:13px;">Zeitraum:</label>
|
||||||
|
<select class="input" name="days" onchange="this.form.submit()">
|
||||||
|
<?php foreach ([7,30,90,365] as $d): ?>
|
||||||
|
<option value="<?= $d ?>" <?= $days===$d?'selected':'' ?>><?= $d ?> Tage</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<a class="btn btn-s" href="?export=daily&days=<?= $days ?>">⬇️ CSV (täglich)</a>
|
||||||
|
<a class="btn btn-s" href="?export=per_site">⬇️ CSV (pro Site)</a>
|
||||||
|
<a class="btn btn-s" href="?export=per_user">⬇️ CSV (pro Nutzer)</a>
|
||||||
|
<button class="btn btn-s" onclick="window.print()">🖨️ Drucken/PDF</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid4">
|
||||||
|
<div class="stat"><div class="n"><?= (int)$totals['total'] ?></div><div class="l">Vouchers gesamt</div></div>
|
||||||
|
<div class="stat"><div class="n"><?= (int)$totals['valid'] ?></div><div class="l">Gültig</div></div>
|
||||||
|
<div class="stat"><div class="n"><?= (int)$totals['used'] ?></div><div class="l">Verwendet</div></div>
|
||||||
|
<div class="stat"><div class="n"><?= $inPeriod ?></div><div class="l">In <?= $days ?> Tagen erstellt</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Erstellte Voucher (<?= $days ?> Tage)</h2>
|
||||||
|
<canvas id="chart" height="90"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Pro Site</h2>
|
||||||
|
<table><tr><th>Site</th><th>Gesamt</th><th>Gültig</th><th>Verwendet</th><th>Abgelaufen</th></tr>
|
||||||
|
<?php foreach ($perSite as $r): ?>
|
||||||
|
<tr><td><?= htmlspecialchars($r['name']) ?></td><td><?= (int)$r['total'] ?></td><td><?= (int)$r['valid'] ?></td><td><?= (int)$r['used'] ?></td><td><?= (int)$r['expired'] ?></td></tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Top-Nutzer</h2>
|
||||||
|
<table><tr><th>Benutzer</th><th>Voucher erstellt</th></tr>
|
||||||
|
<?php foreach ($perUser as $r): ?>
|
||||||
|
<tr><td><?= htmlspecialchars($r['name'] ?? '–') ?></td><td><?= (int)$r['c'] ?></td></tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($perUser)): ?><tr><td colspan="2" style="color:var(--text-muted);">Keine Daten</td></tr><?php endif; ?>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /main-content -->
|
||||||
|
<script src="../assets/global.js"></script>
|
||||||
|
<script>
|
||||||
|
new Chart(document.getElementById('chart'), {
|
||||||
|
type:'line',
|
||||||
|
data:{ labels: <?= json_encode($chartLabels) ?>, datasets:[{ label:'Voucher', data: <?= json_encode($chartData) ?>, borderColor:'#667eea', backgroundColor:'rgba(102,126,234,.15)', fill:true, tension:.3 }] },
|
||||||
|
options:{ plugins:{legend:{display:false}}, scales:{y:{beginAtZero:true,ticks:{precision:0}}} }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
194
admin/security.php
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
<?php
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
require_once __DIR__ . '/../includes/Auth.php';
|
||||||
|
|
||||||
|
$auth = new Auth();
|
||||||
|
$auth->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;
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Zwei-Faktor-Authentifizierung – <?= htmlspecialchars($appTitle) ?></title>
|
||||||
|
<?php if (!$totpEnabled && $hasPassword): ?>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
|
||||||
|
body { background:linear-gradient(135deg,#667eea 0%,#764ba2 100%); min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px; }
|
||||||
|
.card { background:#fff; border-radius:18px; box-shadow:0 20px 60px rgba(0,0,0,.3); max-width:480px; width:100%; padding:36px; }
|
||||||
|
h1 { font-size:22px; color:#333; margin-bottom:6px; }
|
||||||
|
.sub { color:#777; font-size:14px; margin-bottom:24px; }
|
||||||
|
.alert { padding:12px 14px; border-radius:9px; font-size:14px; margin-bottom:18px; }
|
||||||
|
.alert-error { background:#fee; border:1px solid #fcc; color:#c33; }
|
||||||
|
.alert-ok { background:#efe; border:1px solid #cfc; color:#2a7; }
|
||||||
|
.status { display:inline-flex; align-items:center; gap:8px; padding:6px 12px; border-radius:8px; font-size:13px; font-weight:600; margin-bottom:20px; }
|
||||||
|
.on { background:#e3f6ea; color:#2a7; } .off { background:#fdeaea; color:#c33; }
|
||||||
|
.qr { display:flex; justify-content:center; margin:18px 0; }
|
||||||
|
.secret { font-family:monospace; background:#f5f6fa; padding:10px; border-radius:8px; text-align:center; letter-spacing:2px; word-break:break-all; font-size:14px; margin-bottom:18px; }
|
||||||
|
ol { margin:0 0 18px 18px; color:#555; font-size:14px; line-height:1.7; }
|
||||||
|
label { display:block; font-size:14px; color:#555; margin-bottom:8px; font-weight:500; }
|
||||||
|
input[type=text] { width:100%; padding:13px; border:2px solid #e0e0e0; border-radius:10px; font-size:18px; letter-spacing:6px; text-align:center; }
|
||||||
|
.btn { width:100%; padding:14px; border:none; border-radius:10px; font-size:15px; font-weight:600; cursor:pointer; margin-top:14px; }
|
||||||
|
.btn-primary { background:#667eea; color:#fff; } .btn-danger { background:#e25555; color:#fff; }
|
||||||
|
.back { display:block; text-align:center; margin-top:20px; color:#667eea; text-decoration:none; font-size:14px; }
|
||||||
|
.codes-box { background:#fff8e6; border:1px solid #ffe3a3; border-radius:10px; padding:16px; margin-bottom:18px; }
|
||||||
|
.codes-box strong { font-size:15px; } .codes-box p { color:#8a6d2f; font-size:13px; margin:6px 0 12px; }
|
||||||
|
.codes { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||||
|
.codes span { font-family:monospace; background:#fff; border:1px solid #ffe3a3; border-radius:6px; padding:8px; text-align:center; letter-spacing:2px; font-size:14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>🔐 Zwei-Faktor-Authentifizierung</h1>
|
||||||
|
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
|
||||||
|
|
||||||
|
<?php if ($setupRequired && !$totpEnabled): ?>
|
||||||
|
<div class="alert alert-error">Aus Sicherheitsgründen ist 2FA für Administratoren verpflichtend. Bitte jetzt einrichten.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
|
||||||
|
<?php if ($success): ?><div class="alert alert-ok"><?= htmlspecialchars($success) ?></div><?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($backupCodes)): ?>
|
||||||
|
<div class="codes-box">
|
||||||
|
<strong>🔑 Recovery-Codes</strong>
|
||||||
|
<p>Bewahren Sie diese sicher auf. Jeder Code funktioniert <em>einmal</em>, falls Sie keinen Zugriff auf Ihre App haben.</p>
|
||||||
|
<div class="codes">
|
||||||
|
<?php foreach ($backupCodes as $c): ?><span><?= htmlspecialchars($c) ?></span><?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!$hasPassword): ?>
|
||||||
|
<div class="status off">● Nicht verfügbar</div>
|
||||||
|
<p class="sub">Ihr Konto meldet sich über Microsoft 365 an. 2FA wird dort in Ihrem Microsoft-Konto verwaltet.</p>
|
||||||
|
<?php elseif ($totpEnabled): ?>
|
||||||
|
<div class="status on">● Aktiv</div>
|
||||||
|
<p class="sub">Bei jeder Anmeldung wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.<br>
|
||||||
|
Verbleibende Recovery-Codes: <strong><?= (int)$auth->backupCodesRemaining($user) ?></strong></p>
|
||||||
|
<form method="post" style="margin-bottom:10px;">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
<button type="submit" name="regen_codes" class="btn btn-secondary" style="background:#eef0ff;color:#5a63d6;width:100%;">Recovery-Codes neu erzeugen</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" onsubmit="return confirm('2FA wirklich deaktivieren?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
<button type="submit" name="disable_totp" class="btn btn-danger">2FA deaktivieren</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="status off">● Inaktiv</div>
|
||||||
|
<ol>
|
||||||
|
<li>Authenticator-App öffnen (Google Authenticator, Authy, Microsoft Authenticator …)</li>
|
||||||
|
<li>QR-Code scannen <em>oder</em> Secret manuell eingeben</li>
|
||||||
|
<li>Den angezeigten 6-stelligen Code unten eingeben</li>
|
||||||
|
</ol>
|
||||||
|
<div class="qr"><div id="qrcode"></div></div>
|
||||||
|
<div class="secret"><?= htmlspecialchars($setupSecret) ?></div>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
||||||
|
<label for="code">6-stelliger Code</label>
|
||||||
|
<input type="text" id="code" name="code" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required>
|
||||||
|
<button type="submit" name="enable_totp" class="btn btn-primary">2FA aktivieren</button>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
new QRCode(document.getElementById('qrcode'), {
|
||||||
|
text: <?= json_encode($otpUri) ?>, width: 180, height: 180,
|
||||||
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?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>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -30,13 +30,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
|
||||||
$expireMin = (int)($_POST['expire_minutes'] ?? 480);
|
$expireMin = (int)($_POST['expire_minutes'] ?? 480);
|
||||||
$description = trim($_POST['description'] ?? '');
|
$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 (empty($name)) throw new Exception(__('error_name_req'));
|
||||||
if ($maxUses < 1) $maxUses = 1;
|
if ($maxUses < 1) $maxUses = 1;
|
||||||
if ($expireMin < 1) $expireMin = 60;
|
if ($expireMin < 1) $expireMin = 60;
|
||||||
|
|
||||||
$db->execute(
|
$db->execute(
|
||||||
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, created_by) VALUES (?, ?, ?, ?, ?)",
|
"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, $_SESSION['user_id']]
|
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
|
||||||
);
|
);
|
||||||
flashSet(__('templates_added'));
|
flashSet(__('templates_added'));
|
||||||
header('Location: templates.php');
|
header('Location: templates.php');
|
||||||
|
|
@ -60,11 +64,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
|
||||||
$description = trim($_POST['description'] ?? '');
|
$description = trim($_POST['description'] ?? '');
|
||||||
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
$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'));
|
if (empty($name)) throw new Exception(__('error_name_req'));
|
||||||
|
|
||||||
$db->execute(
|
$db->execute(
|
||||||
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, is_active=? WHERE 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, $isActive, $id]
|
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
|
||||||
);
|
);
|
||||||
flashSet(__('templates_updated'));
|
flashSet(__('templates_updated'));
|
||||||
header('Location: templates.php');
|
header('Location: templates.php');
|
||||||
|
|
@ -209,7 +217,7 @@ $adminBase = '';
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>)"
|
<button onclick="openEditModal(<?= $t['id'] ?>, '<?= htmlspecialchars($t['name'], ENT_QUOTES) ?>', <?= (int)$t['max_uses'] ?>, <?= (int)$t['expire_minutes'] ?>, '<?= htmlspecialchars($t['description'] ?? '', ENT_QUOTES) ?>', <?= (int)$t['is_active'] ?>, <?= (int)($t['qos_rate_max_down'] ?? 0) ?>, <?= (int)($t['qos_rate_max_up'] ?? 0) ?>, <?= (int)($t['qos_usage_quota'] ?? 0) ?>)"
|
||||||
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
|
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
|
||||||
<form method="post" style="display:inline;"
|
<form method="post" style="display:inline;"
|
||||||
onsubmit="return confirm('<?= addslashes(__('confirm_delete_template')) ?>')">
|
onsubmit="return confirm('<?= addslashes(__('confirm_delete_template')) ?>')">
|
||||||
|
|
@ -255,6 +263,11 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="Kurze Beschreibung für Ihr Team"></textarea></div>
|
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" rows="2" placeholder="Kurze Beschreibung für Ihr Team"></textarea></div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||||
|
<div class="form-group"><label>Download (kbit/s)</label><input type="number" name="qos_rate_max_down" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
|
<div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
|
<div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
|
</div>
|
||||||
<div style="display:flex;gap:10px;margin-top:20px;">
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
<button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
<button type="submit" name="add_template" class="btn btn-primary" style="flex:1;"><i class="fas fa-save"></i> <?= __('btn_save') ?></button>
|
||||||
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
|
|
@ -287,6 +300,11 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" id="editDesc" rows="2"></textarea></div>
|
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" id="editDesc" rows="2"></textarea></div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||||
|
<div class="form-group"><label>Download (kbit/s)</label><input type="number" name="qos_rate_max_down" id="editQosDown" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
|
<div class="form-group"><label>Upload (kbit/s)</label><input type="number" name="qos_rate_max_up" id="editQosUp" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
|
<div class="form-group"><label>Datenlimit (MB)</label><input type="number" name="qos_usage_quota" id="editQosQuota" min="0" placeholder="0 = unbegrenzt"></div>
|
||||||
|
</div>
|
||||||
<div class="checkbox-group" style="margin-bottom:20px;">
|
<div class="checkbox-group" style="margin-bottom:20px;">
|
||||||
<input type="checkbox" name="is_active" id="editActive">
|
<input type="checkbox" name="is_active" id="editActive">
|
||||||
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
||||||
|
|
@ -304,13 +322,16 @@ $adminBase = '';
|
||||||
<script>
|
<script>
|
||||||
function openAddModal() { document.getElementById('addModal').classList.add('active'); }
|
function openAddModal() { document.getElementById('addModal').classList.add('active'); }
|
||||||
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
||||||
function openEditModal(id, name, maxUses, expMin, desc, isActive) {
|
function openEditModal(id, name, maxUses, expMin, desc, isActive, qosDown, qosUp, qosQuota) {
|
||||||
document.getElementById('editId').value = id;
|
document.getElementById('editId').value = id;
|
||||||
document.getElementById('editName').value = name;
|
document.getElementById('editName').value = name;
|
||||||
document.getElementById('editMaxUses').value = maxUses;
|
document.getElementById('editMaxUses').value = maxUses;
|
||||||
document.getElementById('editExpireMin').value = expMin;
|
document.getElementById('editExpireMin').value = expMin;
|
||||||
document.getElementById('editDesc').value = desc;
|
document.getElementById('editDesc').value = desc;
|
||||||
document.getElementById('editActive').checked = isActive == 1;
|
document.getElementById('editActive').checked = isActive == 1;
|
||||||
|
document.getElementById('editQosDown').value = qosDown || '';
|
||||||
|
document.getElementById('editQosUp').value = qosUp || '';
|
||||||
|
document.getElementById('editQosQuota').value = qosQuota || '';
|
||||||
document.getElementById('editModal').classList.add('active');
|
document.getElementById('editModal').classList.add('active');
|
||||||
}
|
}
|
||||||
['addModal','editModal'].forEach(id => {
|
['addModal','editModal'].forEach(id => {
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,17 @@ if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['toggle_user'])) {
|
||||||
} else { $error = __('error_csrf'); }
|
} else { $error = __('error_csrf'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2FA eines Benutzers zurücksetzen (Admin-Hilfe bei verlorenem Authenticator)
|
||||||
|
// POST + PRG wie die uebrigen state-aendernden Aktionen
|
||||||
|
if ($_SERVER['REQUEST_METHOD']==='POST' && isset($_POST['reset_2fa'])) {
|
||||||
|
if ($auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||||||
|
$auth->disableTotp((int)$_POST['reset_2fa']);
|
||||||
|
flashSet('2FA des Benutzers wurde zurückgesetzt.');
|
||||||
|
header('Location: users.php');
|
||||||
|
exit;
|
||||||
|
} else { $error = __('error_csrf'); }
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($success) && empty($error) && ($flash = flashGet())) {
|
if (empty($success) && empty($error) && ($flash = flashGet())) {
|
||||||
$success = $flash['message'];
|
$success = $flash['message'];
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +337,17 @@ $currentPage = 'users';
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($user['totp_enabled'])): ?>
|
||||||
|
<form method="post" style="display:inline;"
|
||||||
|
onsubmit="return confirm('2FA für <?= htmlspecialchars($user['email'], ENT_QUOTES) ?> zurücksetzen?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
<input type="hidden" name="reset_2fa" value="<?= $user['id'] ?>">
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm"
|
||||||
|
title="2FA zurücksetzen" aria-label="2FA zurücksetzen">
|
||||||
|
<i class="fas fa-user-shield"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
<form method="post" style="display:inline;"
|
<form method="post" style="display:inline;"
|
||||||
onsubmit="return confirm('<?= addslashes(__('confirm_delete_user')) ?>')">
|
onsubmit="return confirm('<?= addslashes(__('confirm_delete_user')) ?>')">
|
||||||
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,25 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Voucher-Code per E-Mail (erneut) versenden
|
||||||
|
if (isset($_POST['ajax_resend']) && isset($_POST['voucher_id']) && isset($_POST['site_id'])) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if (!$auth->validateCsrfToken($_POST['csrf_token']??'')) { echo json_encode(['success'=>false,'message'=>__('error_csrf')]); exit; }
|
||||||
|
require_once __DIR__ . '/../includes/Mailer.php';
|
||||||
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { echo json_encode(['success'=>false,'message'=>'Ungültige E-Mail-Adresse']); exit; }
|
||||||
|
$siteId = (int)$_POST['site_id'];
|
||||||
|
$site = $db->fetchOne("SELECT * FROM sites WHERE id=?", [$siteId]);
|
||||||
|
$v = $db->fetchOne("SELECT * FROM vouchers WHERE unifi_voucher_id=? AND site_id=?", [$_POST['voucher_id'], $siteId]);
|
||||||
|
if (!$site || !$v) { echo json_encode(['success'=>false,'message'=>'Voucher nicht gefunden']); exit; }
|
||||||
|
$mailer = new Mailer();
|
||||||
|
$code = strpos($v['voucher_code'], '-') !== false ? $v['voucher_code'] : implode('-', str_split($v['voucher_code'], 5));
|
||||||
|
$ok = $mailer->sendVoucherEmail($email, $code, $site['name'], (int)$v['max_uses']);
|
||||||
|
$auth->writeAuditLog($_SESSION['user_id'], 'voucher_resend', 'voucher', $v['id'], "Code an $email gesendet");
|
||||||
|
echo json_encode(['success'=>$ok, 'message'=>$ok ? 'E-Mail versendet.' : 'Versand fehlgeschlagen.']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
|
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active=1 ORDER BY name");
|
||||||
$siteStats = [];
|
$siteStats = [];
|
||||||
foreach ($sites as $site) {
|
foreach ($sites as $site) {
|
||||||
|
|
@ -384,7 +403,10 @@ function renderVouchers() {
|
||||||
<td>${statusBadge}</td>
|
<td>${statusBadge}</td>
|
||||||
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
|
<td><div class="usage-info"><span>${v.used}/${v.quota>0?v.quota:'∞'}</span>${v.quota>0?`<div class="usage-bar"><div class="usage-bar-fill" style="width:${usagePct}%"></div></div>`:''}</div></td>
|
||||||
<td>${remaining?`<span style="color:var(--success)"><i class="fas fa-clock"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} Min.</small></td>
|
<td>${remaining?`<span style="color:var(--success)"><i class="fas fa-clock"></i> ${remaining}</span><br>`:''}<small style="color:var(--text-muted)">${v.duration} Min.</small></td>
|
||||||
<td><button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button></td>
|
<td style="white-space:nowrap;">
|
||||||
|
<button onclick="resendVoucher('${v._id}','${escapeHtml(v.formatted_code||'')}')" class="btn btn-secondary btn-sm" title="Per E-Mail senden"><i class="fas fa-envelope"></i></button>
|
||||||
|
<button onclick="deleteVoucher('${v._id}')" class="btn btn-danger btn-sm" title="<?= __('btn_delete') ?>"><i class="fas fa-trash"></i></button>
|
||||||
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -408,6 +430,20 @@ function renderVouchers() {
|
||||||
|
|
||||||
function escapeHtml(t) { const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
|
function escapeHtml(t) { const d=document.createElement('div'); d.textContent=t; return d.innerHTML; }
|
||||||
|
|
||||||
|
async function resendVoucher(voucherId, code) {
|
||||||
|
const email = prompt('Code ' + code + ' senden an (E-Mail):');
|
||||||
|
if (!email) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('ajax_resend','1'); fd.append('voucher_id',voucherId);
|
||||||
|
fd.append('site_id', currentSiteId); fd.append('email', email);
|
||||||
|
fd.append('csrf_token', csrfToken);
|
||||||
|
try {
|
||||||
|
const r = await fetch('vouchers.php', {method:'POST', body:fd});
|
||||||
|
const d = await r.json();
|
||||||
|
(window.showToast ? showToast(d.message, d.success?'success':'error') : alert(d.message));
|
||||||
|
} catch(e){ alert('Fehler: '+e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteVoucher(voucherId) {
|
async function deleteVoucher(voucherId) {
|
||||||
if (!confirm('<?= addslashes(__('confirm_delete_voucher')) ?>')) return;
|
if (!confirm('<?= addslashes(__('confirm_delete_voucher')) ?>')) return;
|
||||||
const row = document.getElementById(`voucher-${voucherId}`);
|
const row = document.getElementById(`voucher-${voucherId}`);
|
||||||
|
|
|
||||||
53
api/bootstrap.php
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Gemeinsamer Bootstrap für die REST-API-Endpunkte.
|
||||||
|
* Lädt die Basis, authentifiziert den API-Schlüssel und stellt JSON-Helfer bereit.
|
||||||
|
*/
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../config.php';
|
||||||
|
require_once __DIR__ . '/../includes/Database.php';
|
||||||
|
require_once __DIR__ . '/../includes/UniFiController.php';
|
||||||
|
require_once __DIR__ . '/../includes/ApiKey.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
function api_json($data, $status = 200) {
|
||||||
|
http_response_code($status);
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function api_body() {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
if ($raw === '' || $raw === false) return [];
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
api_json(['error' => 'database_unavailable'], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKeyRow = ApiKey::verify(ApiKey::fromRequest(), $db);
|
||||||
|
if (!$apiKeyRow) {
|
||||||
|
api_json(['error' => 'unauthorized', 'message' => 'Gültiger API-Schlüssel erforderlich (Authorization: Bearer …)'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate-Limit pro Schlüssel
|
||||||
|
if (!ApiKey::checkRateLimit($apiKeyRow, $db)) {
|
||||||
|
header('Retry-After: 60');
|
||||||
|
api_json(['error' => 'rate_limited', 'message' => 'Rate-Limit überschritten. Bitte später erneut versuchen.'], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erzwingt einen Scope für den aktuellen Schlüssel. */
|
||||||
|
function api_require_scope($needed) {
|
||||||
|
global $apiKeyRow;
|
||||||
|
if (!ApiKey::hasScope($apiKeyRow, $needed)) {
|
||||||
|
api_json(['error' => 'forbidden', 'message' => "Schlüssel hat keinen '$needed'-Scope"], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
api/openapi.php
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* OpenAPI 3.0 Spezifikation der REST-API (zum Import in Postman/Swagger).
|
||||||
|
* GET /api/openapi.php
|
||||||
|
*/
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
$base = $scheme . '://' . $host . rtrim(dirname($_SERVER['SCRIPT_NAME']), '/');
|
||||||
|
|
||||||
|
$spec = [
|
||||||
|
'openapi' => '3.0.3',
|
||||||
|
'info' => [
|
||||||
|
'title' => 'UniFi Voucher Tool API',
|
||||||
|
'version' => '1.0.0',
|
||||||
|
'description' => 'REST-API zum Erstellen und Abrufen von WLAN-Vouchers. Authentifizierung per API-Schlüssel (Authorization: Bearer … oder X-API-Key).',
|
||||||
|
],
|
||||||
|
'servers' => [['url' => $base]],
|
||||||
|
'components' => [
|
||||||
|
'securitySchemes' => [
|
||||||
|
'bearerAuth' => ['type' => 'http', 'scheme' => 'bearer'],
|
||||||
|
'apiKeyAuth' => ['type' => 'apiKey', 'in' => 'header', 'name' => 'X-API-Key'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'security' => [['bearerAuth' => []], ['apiKeyAuth' => []]],
|
||||||
|
'paths' => [
|
||||||
|
'/sites.php' => [
|
||||||
|
'get' => [
|
||||||
|
'summary' => 'Aktive Sites auflisten',
|
||||||
|
'description' => 'Erfordert Scope read.',
|
||||||
|
'responses' => ['200' => ['description' => 'Liste der Sites']],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'/vouchers.php' => [
|
||||||
|
'get' => [
|
||||||
|
'summary' => 'Voucher einer Site auflisten',
|
||||||
|
'description' => 'Erfordert Scope read.',
|
||||||
|
'parameters' => [[
|
||||||
|
'name' => 'site_id', 'in' => 'query', 'required' => true,
|
||||||
|
'schema' => ['type' => 'integer'],
|
||||||
|
]],
|
||||||
|
'responses' => ['200' => ['description' => 'Liste der Voucher']],
|
||||||
|
],
|
||||||
|
'post' => [
|
||||||
|
'summary' => 'Voucher erstellen',
|
||||||
|
'description' => 'Erfordert Scope write.',
|
||||||
|
'requestBody' => [
|
||||||
|
'required' => true,
|
||||||
|
'content' => ['application/json' => ['schema' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'required' => ['site_id', 'name'],
|
||||||
|
'properties' => [
|
||||||
|
'site_id' => ['type' => 'integer'],
|
||||||
|
'name' => ['type' => 'string'],
|
||||||
|
'max_uses' => ['type' => 'integer', 'default' => 1],
|
||||||
|
'expire_minutes' => ['type' => 'integer', 'default' => 480],
|
||||||
|
'qos' => ['type' => 'object', 'properties' => [
|
||||||
|
'down' => ['type' => 'integer', 'description' => 'Download kbit/s'],
|
||||||
|
'up' => ['type' => 'integer', 'description' => 'Upload kbit/s'],
|
||||||
|
'quota_mb' => ['type' => 'integer', 'description' => 'Datenkontingent MB'],
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
]]],
|
||||||
|
],
|
||||||
|
'responses' => [
|
||||||
|
'201' => ['description' => 'Voucher erstellt'],
|
||||||
|
'401' => ['description' => 'Nicht authentifiziert'],
|
||||||
|
'403' => ['description' => 'Fehlender Scope'],
|
||||||
|
'429' => ['description' => 'Rate-Limit überschritten'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
echo json_encode($spec, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||||
15
api/sites.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/sites.php – Liste aktiver Sites (für API-Clients).
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
api_json(['error' => 'method_not_allowed'], 405);
|
||||||
|
}
|
||||||
|
api_require_scope('read');
|
||||||
|
|
||||||
|
$sites = $db->fetchAll("SELECT id, name, site_id FROM sites WHERE is_active = 1 ORDER BY name");
|
||||||
|
api_json(['sites' => array_map(function ($s) {
|
||||||
|
return ['id' => (int)$s['id'], 'name' => $s['name'], 'site_id' => $s['site_id']];
|
||||||
|
}, $sites)]);
|
||||||
91
api/vouchers.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* REST-Endpunkt für Voucher.
|
||||||
|
*
|
||||||
|
* GET /api/vouchers.php?site_id=<id> – Voucher einer Site auflisten (aus DB)
|
||||||
|
* POST /api/vouchers.php – Voucher erstellen
|
||||||
|
* Body (JSON): {
|
||||||
|
* "site_id": 1, "name": "API Gast", "max_uses": 1,
|
||||||
|
* "expire_minutes": 480,
|
||||||
|
* "qos": { "down": 10000, "up": 2000, "quota_mb": 500 } (optional)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/Notifier.php';
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
|
||||||
|
if ($method === 'GET') {
|
||||||
|
api_require_scope('read');
|
||||||
|
$siteId = (int)($_GET['site_id'] ?? 0);
|
||||||
|
if ($siteId <= 0) {
|
||||||
|
api_json(['error' => 'invalid_request', 'message' => 'site_id erforderlich'], 400);
|
||||||
|
}
|
||||||
|
$rows = $db->fetchAll(
|
||||||
|
"SELECT voucher_code, voucher_name, max_uses, expire_minutes, status, used_count, created_at, expires_at
|
||||||
|
FROM vouchers WHERE site_id = ? ORDER BY created_at DESC LIMIT 200",
|
||||||
|
[$siteId]
|
||||||
|
);
|
||||||
|
api_json(['vouchers' => $rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
api_require_scope('write');
|
||||||
|
$body = api_body();
|
||||||
|
$siteId = (int)($body['site_id'] ?? 0);
|
||||||
|
$name = trim((string)($body['name'] ?? ''));
|
||||||
|
$maxUses = (int)($body['max_uses'] ?? 1);
|
||||||
|
$expireMinutes = (int)($body['expire_minutes'] ?? 480);
|
||||||
|
|
||||||
|
if ($siteId <= 0) api_json(['error' => 'invalid_request', 'message' => 'site_id erforderlich'], 400);
|
||||||
|
if ($name === '') api_json(['error' => 'invalid_request', 'message' => 'name erforderlich'], 400);
|
||||||
|
if ($maxUses < 1) $maxUses = 1;
|
||||||
|
if ($expireMinutes < 1) $expireMinutes = 480;
|
||||||
|
|
||||||
|
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||||||
|
if (!$site) {
|
||||||
|
api_json(['error' => 'not_found', 'message' => 'Site nicht gefunden'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$qosIn = is_array($body['qos'] ?? null) ? $body['qos'] : [];
|
||||||
|
$qos = [
|
||||||
|
'down' => max(0, (int)($qosIn['down'] ?? 0)),
|
||||||
|
'up' => max(0, (int)($qosIn['up'] ?? 0)),
|
||||||
|
'quota_mb' => max(0, (int)($qosIn['quota_mb'] ?? 0)),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fullName = date('Y-m-d') . '_' . $name;
|
||||||
|
$controller = new UniFiController(
|
||||||
|
$site['unifi_controller_url'],
|
||||||
|
$site['unifi_username'],
|
||||||
|
Crypto::decrypt($site['unifi_password']),
|
||||||
|
$site['site_id']
|
||||||
|
);
|
||||||
|
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
|
||||||
|
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
|
||||||
|
api_json(['error' => 'upstream_error', 'message' => 'UniFi lieferte keinen gültigen Voucher'], 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->execute(
|
||||||
|
"INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
|
||||||
|
VALUES (?, NULL, ?, ?, ?, ?, ?)",
|
||||||
|
[$siteId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
|
||||||
|
);
|
||||||
|
|
||||||
|
Notifier::voucherCreated(1, $site['name'], 'API: ' . $apiKeyRow['name']);
|
||||||
|
|
||||||
|
api_json([
|
||||||
|
'success' => true,
|
||||||
|
'code' => $voucher['code'],
|
||||||
|
'formatted_code' => $voucher['formatted_code'],
|
||||||
|
'site' => $site['name'],
|
||||||
|
'max_uses' => $maxUses,
|
||||||
|
'expire_minutes' => $expireMinutes,
|
||||||
|
], 201);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
api_json(['error' => 'server_error', 'message' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api_json(['error' => 'method_not_allowed'], 405);
|
||||||
21
composer.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "friloo/unifi-voucher-tool",
|
||||||
|
"description": "Webbasiertes WLAN-Voucher-Management für UniFi OS",
|
||||||
|
"license": "MIT",
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^9.6",
|
||||||
|
"phpstan/phpstan": "^1.11"
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "phpunit",
|
||||||
|
"stan": "phpstan analyse"
|
||||||
|
}
|
||||||
|
}
|
||||||
136
cron_cleanup.php
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Cron-Script für Aufräumarbeiten & Datenhaltung (DSGVO).
|
||||||
|
*
|
||||||
|
* Aufruf via URL: https://domain.de/cron_cleanup.php?token=DEIN_TOKEN
|
||||||
|
* Aufruf via CLI: php cron_cleanup.php DEIN_TOKEN
|
||||||
|
*
|
||||||
|
* Empfohlenes Intervall: täglich.
|
||||||
|
*
|
||||||
|
* Gesteuert über Einstellungen (0 = deaktiviert):
|
||||||
|
* cleanup_expired_days – abgelaufene Voucher nach N Tagen aus DB löschen
|
||||||
|
* cleanup_audit_days – Audit-Log-Einträge nach N Tagen löschen
|
||||||
|
* cleanup_login_days – Login-Versuche nach N Tagen löschen (Default 30)
|
||||||
|
* Abgelaufene/benutzte Passwort-Reset-Tokens werden immer entfernt.
|
||||||
|
*/
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
|
||||||
|
$isCli = php_sapi_name() === 'cli';
|
||||||
|
if (!$isCli) {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
|
require_once __DIR__ . '/includes/Notifier.php';
|
||||||
|
|
||||||
|
function out($data, $isCli) {
|
||||||
|
if ($isCli) {
|
||||||
|
echo ($data['success'] ? 'SUCCESS' : 'ERROR') . ': ' . ($data['message'] ?? '') . PHP_EOL;
|
||||||
|
if (!empty($data['deleted'])) {
|
||||||
|
foreach ($data['deleted'] as $k => $v) echo " - $k: $v" . PHP_EOL;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
out(['success' => false, 'message' => 'DB-Fehler: ' . $e->getMessage()], $isCli);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token prüfen (gleicher Token wie cron_sync)
|
||||||
|
$cronToken = $db->getSetting('cron_token', '');
|
||||||
|
$provided = $isCli ? ($argv[1] ?? '') : ($_GET['token'] ?? '');
|
||||||
|
if (empty($cronToken)) {
|
||||||
|
out(['success' => false, 'message' => 'Kein Cron-Token konfiguriert.'], $isCli);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!hash_equals($cronToken, (string)$provided)) {
|
||||||
|
out(['success' => false, 'message' => 'Ungültiger Token'], $isCli);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiredDays = (int)$db->getSetting('cleanup_expired_days', 0);
|
||||||
|
$auditDays = (int)$db->getSetting('cleanup_audit_days', 0);
|
||||||
|
$loginDays = (int)$db->getSetting('cleanup_login_days', 30);
|
||||||
|
|
||||||
|
$deleted = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Abgelaufene Voucher aus der DB entfernen (nur lokale Historie)
|
||||||
|
if ($expiredDays > 0) {
|
||||||
|
$stmt = $db->query(
|
||||||
|
"DELETE FROM vouchers WHERE status = 'expired'
|
||||||
|
AND expires_at IS NOT NULL AND expires_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
|
||||||
|
[$expiredDays]
|
||||||
|
);
|
||||||
|
$deleted['vouchers_expired'] = $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit-Log nach Aufbewahrungsfrist löschen
|
||||||
|
if ($auditDays > 0) {
|
||||||
|
try {
|
||||||
|
$stmt = $db->query(
|
||||||
|
"DELETE FROM audit_log WHERE created_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
|
||||||
|
[$auditDays]
|
||||||
|
);
|
||||||
|
$deleted['audit_log'] = $stmt->rowCount();
|
||||||
|
} catch (Exception $e) { /* Tabelle evtl. nicht vorhanden */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alte Login-Versuche entfernen
|
||||||
|
if ($loginDays > 0) {
|
||||||
|
try {
|
||||||
|
$stmt = $db->query(
|
||||||
|
"DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL ? DAY)",
|
||||||
|
[$loginDays]
|
||||||
|
);
|
||||||
|
$deleted['login_attempts'] = $stmt->rowCount();
|
||||||
|
} catch (Exception $e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abgelaufene / benutzte Passwort-Reset-Tokens immer entfernen
|
||||||
|
try {
|
||||||
|
$stmt = $db->query(
|
||||||
|
"DELETE FROM password_reset_tokens WHERE used = 1 OR expires_at < NOW()"
|
||||||
|
);
|
||||||
|
$deleted['reset_tokens'] = $stmt->rowCount();
|
||||||
|
} catch (Exception $e) { /* Tabelle evtl. nicht vorhanden */ }
|
||||||
|
|
||||||
|
// Optionaler täglicher Update-Check mit Webhook-Hinweis (Updater isoliert,
|
||||||
|
// daher nur falls vorhanden und nur einmal pro Tag).
|
||||||
|
$bootstrap = __DIR__ . '/updater/bootstrap.php';
|
||||||
|
if (is_file($bootstrap)) {
|
||||||
|
$lastCheck = $db->getSetting('last_update_check', '');
|
||||||
|
if (!$lastCheck || strtotime($lastCheck) < strtotime('-23 hours')) {
|
||||||
|
try {
|
||||||
|
require_once $bootstrap;
|
||||||
|
$mgr = \Updater\UpdaterFactory::create($db, null);
|
||||||
|
$res = $mgr->checkForUpdates();
|
||||||
|
if (!empty($res['has_update'])) {
|
||||||
|
Notifier::updateAvailable($res['latest_sha'] ?? '');
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) { /* Update-Check nie blockierend */ }
|
||||||
|
$db->setSetting('last_update_check', date('Y-m-d H:i:s'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->query(
|
||||||
|
"INSERT INTO settings (setting_key, setting_value) VALUES ('last_cleanup', NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE setting_value = NOW()"
|
||||||
|
);
|
||||||
|
|
||||||
|
out([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Cleanup abgeschlossen',
|
||||||
|
'deleted' => $deleted,
|
||||||
|
'timestamp' => date('Y-m-d H:i:s'),
|
||||||
|
], $isCli);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
out(['success' => false, 'message' => 'Fehler: ' . $e->getMessage(), 'deleted' => $deleted], $isCli);
|
||||||
|
}
|
||||||
|
|
@ -90,6 +90,7 @@ set_exception_handler(function($e) use ($isCli) {
|
||||||
require_once __DIR__ . '/config.php';
|
require_once __DIR__ . '/config.php';
|
||||||
require_once __DIR__ . '/includes/Database.php';
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
require_once __DIR__ . '/includes/UniFiController.php';
|
require_once __DIR__ . '/includes/UniFiController.php';
|
||||||
|
require_once __DIR__ . '/includes/Notifier.php';
|
||||||
|
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
|
@ -196,6 +197,9 @@ try {
|
||||||
$siteResult['error'] = $e->getMessage();
|
$siteResult['error'] = $e->getMessage();
|
||||||
$totalStats['sites_failed']++;
|
$totalStats['sites_failed']++;
|
||||||
logMessage(" FEHLER - " . $e->getMessage(), $isCli);
|
logMessage(" FEHLER - " . $e->getMessage(), $isCli);
|
||||||
|
if (class_exists('Notifier')) {
|
||||||
|
Notifier::controllerUnreachable($site['name'], $e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$results[] = $siteResult;
|
$results[] = $siteResult;
|
||||||
|
|
|
||||||
31
database.sql
|
|
@ -31,6 +31,9 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||||
`is_admin` TINYINT(1) DEFAULT 0,
|
`is_admin` TINYINT(1) DEFAULT 0,
|
||||||
`is_active` TINYINT(1) DEFAULT 1,
|
`is_active` TINYINT(1) DEFAULT 1,
|
||||||
`microsoft_id` VARCHAR(255) UNIQUE,
|
`microsoft_id` VARCHAR(255) UNIQUE,
|
||||||
|
`totp_secret` VARCHAR(64) NULL,
|
||||||
|
`totp_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
`totp_backup_codes` TEXT NULL,
|
||||||
`last_login` TIMESTAMP NULL,
|
`last_login` TIMESTAMP NULL,
|
||||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
@ -54,6 +57,9 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
|
||||||
`max_uses` INT NOT NULL DEFAULT 1,
|
`max_uses` INT NOT NULL DEFAULT 1,
|
||||||
`expire_minutes` INT NOT NULL DEFAULT 480,
|
`expire_minutes` INT NOT NULL DEFAULT 480,
|
||||||
`description` VARCHAR(500),
|
`description` VARCHAR(500),
|
||||||
|
`qos_rate_max_down` INT NULL,
|
||||||
|
`qos_rate_max_up` INT NULL,
|
||||||
|
`qos_usage_quota` INT NULL,
|
||||||
`is_active` TINYINT(1) DEFAULT 1,
|
`is_active` TINYINT(1) DEFAULT 1,
|
||||||
`created_by` INT,
|
`created_by` INT,
|
||||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
@ -61,6 +67,29 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
|
||||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
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,
|
||||||
|
`scope` VARCHAR(16) NOT NULL DEFAULT 'write',
|
||||||
|
`rate_limit` INT NOT NULL DEFAULT 0,
|
||||||
|
`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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `vouchers` (
|
CREATE TABLE IF NOT EXISTS `vouchers` (
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
`site_id` INT NOT NULL,
|
`site_id` INT NOT NULL,
|
||||||
|
|
@ -86,7 +115,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,
|
||||||
|
|
|
||||||
38
docker-compose.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_NAME: unifi_voucher
|
||||||
|
DB_USER: unifi_voucher
|
||||||
|
DB_PASS: change_me
|
||||||
|
# Dauerhaft setzen! Sonst koennen verschluesselte Werte nach Neustart
|
||||||
|
# nicht mehr gelesen werden. Erzeugen: php -r "echo base64_encode(random_bytes(32));"
|
||||||
|
APP_KEY: ""
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:11
|
||||||
|
environment:
|
||||||
|
MARIADB_DATABASE: unifi_voucher
|
||||||
|
MARIADB_USER: unifi_voucher
|
||||||
|
MARIADB_PASSWORD: change_me
|
||||||
|
MARIADB_ROOT_PASSWORD: change_me_root
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
- ./database.sql:/docker-entrypoint-initdb.d/01_schema.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
29
docker/entrypoint.sh
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CONFIG=/var/www/html/config.php
|
||||||
|
|
||||||
|
# config.php aus Umgebungsvariablen erzeugen, falls noch nicht vorhanden und
|
||||||
|
# DB-Variablen gesetzt sind. Sonst kann der Web-Installer (install.php) genutzt
|
||||||
|
# werden. APP_KEY wird einmalig generiert und sollte als ENV persistiert werden.
|
||||||
|
if [ ! -f "$CONFIG" ] && [ -n "$DB_HOST" ] && [ -n "$DB_NAME" ]; then
|
||||||
|
if [ -z "$APP_KEY" ]; then
|
||||||
|
APP_KEY=$(php -r 'echo base64_encode(random_bytes(32));')
|
||||||
|
echo "[entrypoint] WARN: kein APP_KEY gesetzt – generiere einmaligen Schluessel."
|
||||||
|
echo "[entrypoint] Fuer dauerhaften Betrieb APP_KEY als ENV setzen: $APP_KEY"
|
||||||
|
fi
|
||||||
|
cat > "$CONFIG" <<PHP
|
||||||
|
<?php
|
||||||
|
define('DB_HOST', getenv('DB_HOST') ?: '${DB_HOST}');
|
||||||
|
define('DB_NAME', getenv('DB_NAME') ?: '${DB_NAME}');
|
||||||
|
define('DB_USER', getenv('DB_USER') ?: '${DB_USER}');
|
||||||
|
define('DB_PASS', getenv('DB_PASS') ?: '${DB_PASS}');
|
||||||
|
define('APP_KEY', getenv('APP_KEY') ?: '${APP_KEY}');
|
||||||
|
define('SESSION_LIFETIME', (int)(getenv('SESSION_LIFETIME') ?: 3600));
|
||||||
|
date_default_timezone_set(getenv('TZ') ?: 'Europe/Berlin');
|
||||||
|
PHP
|
||||||
|
chown www-data:www-data "$CONFIG"
|
||||||
|
echo "[entrypoint] config.php aus ENV erzeugt."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 216 KiB |
BIN
docs/screenshots/api-keys.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 603 KiB After Width: | Height: | Size: 1.1 MiB |
BIN
docs/screenshots/integrations.png
Normal file
|
After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 882 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 1.2 MiB |
BIN
docs/screenshots/two-factor.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 922 KiB After Width: | Height: | Size: 1,024 KiB |
|
Before Width: | Height: | Size: 934 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 823 KiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1.3 MiB |
|
|
@ -35,12 +35,21 @@ $success = '';
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
$email = trim($_POST['email'] ?? '');
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
|
||||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
// Einfacher Throttle: max. 3 Anfragen pro 15 Minuten je Session (gegen Spam)
|
||||||
|
$now = time();
|
||||||
|
$rl = array_values(array_filter($_SESSION['pwreset_times'] ?? [], fn($t) => ($now - $t) < 900));
|
||||||
|
if (count($rl) >= 3) {
|
||||||
|
$error = 'Zu viele Anfragen. Bitte warten Sie einige Minuten.';
|
||||||
|
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
$error = __('error_email_invalid');
|
$error = __('error_email_invalid');
|
||||||
} else {
|
} else {
|
||||||
// IP-Rate-Limit gegen Mail-Bombing: max. 5 Reset-Anfragen / 15 Min.
|
$rl[] = $now;
|
||||||
// Bei Limit trotzdem die generische Erfolgsmeldung zeigen (keine
|
$_SESSION['pwreset_times'] = $rl;
|
||||||
// Information darueber preisgeben, ob das Konto existiert).
|
|
||||||
|
// Zusaetzlich zum Session-Throttle: IP-Rate-Limit gegen Mail-Bombing
|
||||||
|
// (max. 5 Reset-Anfragen / 15 Min., umgeht Cookie-Loeschen). Bei Limit
|
||||||
|
// trotzdem die generische Erfolgsmeldung zeigen (keine Information
|
||||||
|
// darueber preisgeben, ob das Konto existiert).
|
||||||
$resetLimited = throttleHit($db, 'password_reset', 5, 15) === true;
|
$resetLimited = throttleHit($db, 'password_reset', 5, 15) === true;
|
||||||
|
|
||||||
$user = $resetLimited
|
$user = $resetLimited
|
||||||
|
|
|
||||||
59
health.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Health-/Status-Endpunkt für Monitoring/Uptime-Checks.
|
||||||
|
*
|
||||||
|
* GET /health.php – Basis-Status (DB erreichbar), kein Geheimnis
|
||||||
|
* GET /health.php?deep=1&token=CRON_TOKEN – zusätzlich Controller-Erreichbarkeit
|
||||||
|
*
|
||||||
|
* Antwortet HTTP 200 (ok) oder 503 (degraded/fail).
|
||||||
|
*/
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
|
|
||||||
|
$result = ['status' => 'ok', 'time' => date('c'), 'checks' => []];
|
||||||
|
$httpStatus = 200;
|
||||||
|
|
||||||
|
// DB
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$db->fetchOne("SELECT 1 AS ok");
|
||||||
|
$result['checks']['database'] = 'ok';
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$result['checks']['database'] = 'fail';
|
||||||
|
$result['status'] = 'fail';
|
||||||
|
$httpStatus = 503;
|
||||||
|
echo json_encode($result);
|
||||||
|
http_response_code($httpStatus);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['checks']['active_sites'] = (int)($db->fetchOne("SELECT COUNT(*) c FROM sites WHERE is_active=1")['c'] ?? 0);
|
||||||
|
|
||||||
|
// Tiefer Check (Controller) nur mit gültigem Cron-Token
|
||||||
|
if (isset($_GET['deep']) && $_GET['deep'] == '1') {
|
||||||
|
$token = $db->getSetting('cron_token', '');
|
||||||
|
if ($token === '' || !hash_equals($token, (string)($_GET['token'] ?? ''))) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(['status' => 'forbidden', 'message' => 'deep check erfordert gültigen token']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/includes/UniFiController.php';
|
||||||
|
$controllers = [];
|
||||||
|
foreach ($db->fetchAll("SELECT * FROM sites WHERE is_active=1") as $site) {
|
||||||
|
$r = UniFiController::testConnection(
|
||||||
|
$site['unifi_controller_url'], $site['unifi_username'],
|
||||||
|
Crypto::decrypt($site['unifi_password']), $site['site_id']
|
||||||
|
);
|
||||||
|
$ok = ($r === true);
|
||||||
|
$controllers[$site['name']] = $ok ? 'ok' : 'unreachable';
|
||||||
|
if (!$ok) { $result['status'] = 'degraded'; $httpStatus = 503; }
|
||||||
|
}
|
||||||
|
$result['checks']['controllers'] = $controllers;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code($httpStatus);
|
||||||
|
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
103
includes/ApiKey.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* ApiKey – Erzeugung & Verifizierung von API-Schlüsseln für die REST-API.
|
||||||
|
*
|
||||||
|
* Schlüsselformat: uvt_<prefix(8)><secret(32)>
|
||||||
|
* Gespeichert wird nur der SHA-256-Hash plus ein indexierbares Präfix – der
|
||||||
|
* Klartext-Schlüssel wird dem Admin genau einmal bei der Erstellung gezeigt.
|
||||||
|
*/
|
||||||
|
class ApiKey {
|
||||||
|
/** Neuen Schlüssel erzeugen. Gibt plain/prefix/hash zurück. */
|
||||||
|
public static function generate() {
|
||||||
|
$prefix = bin2hex(random_bytes(4)); // 8 Zeichen
|
||||||
|
$secret = bin2hex(random_bytes(16)); // 32 Zeichen
|
||||||
|
$plain = 'uvt_' . $prefix . $secret;
|
||||||
|
return [
|
||||||
|
'plain' => $plain,
|
||||||
|
'prefix' => $prefix,
|
||||||
|
'hash' => hash('sha256', $plain),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifiziert einen Klartext-Schlüssel gegen die DB. Aktualisiert
|
||||||
|
* last_used_at und gibt den Key-Datensatz zurück – oder false.
|
||||||
|
*/
|
||||||
|
public static function verify($plain, $db) {
|
||||||
|
if (!is_string($plain) || strpos($plain, 'uvt_') !== 0 || strlen($plain) < 44) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$prefix = substr($plain, 4, 8);
|
||||||
|
$hash = hash('sha256', $plain);
|
||||||
|
try {
|
||||||
|
$row = $db->fetchOne(
|
||||||
|
"SELECT * FROM api_keys WHERE key_prefix = ? AND is_active = 1",
|
||||||
|
[$prefix]
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!$row || !hash_equals($row['key_hash'], $hash)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$db->query("UPDATE api_keys SET last_used_at = NOW() WHERE id = ?", [$row['id']]);
|
||||||
|
} catch (\Exception $e) { /* ignore */ }
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed-Window-Rate-Limit pro Schlüssel (Anfragen/Minute). rate_limit = 0
|
||||||
|
* bedeutet unbegrenzt. Gibt true zurück, wenn die Anfrage erlaubt ist.
|
||||||
|
*/
|
||||||
|
public static function checkRateLimit($row, $db) {
|
||||||
|
$limit = (int)($row['rate_limit'] ?? 0);
|
||||||
|
if ($limit <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// alte Treffer (>60s) aufräumen
|
||||||
|
$db->query("DELETE FROM api_key_hits WHERE api_key_id = ? AND hit_at < DATE_SUB(NOW(), INTERVAL 60 SECOND)", [$row['id']]);
|
||||||
|
$cnt = $db->fetchOne("SELECT COUNT(*) AS c FROM api_key_hits WHERE api_key_id = ?", [$row['id']]);
|
||||||
|
if ($cnt && (int)$cnt['c'] >= $limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$db->query("INSERT INTO api_key_hits (api_key_id) VALUES (?)", [$row['id']]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return true; // Bei Fehlern (z.B. Tabelle fehlt) nicht blockieren
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft, ob der Schlüssel den geforderten Scope hat ('read' < 'write'). */
|
||||||
|
public static function hasScope($row, $needed) {
|
||||||
|
$scope = $row['scope'] ?? 'write';
|
||||||
|
if ($needed === 'read') {
|
||||||
|
return in_array($scope, ['read', 'write'], true);
|
||||||
|
}
|
||||||
|
return $scope === 'write';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest den Schlüssel aus dem Request (Authorization: Bearer / X-API-Key). */
|
||||||
|
public static function fromRequest() {
|
||||||
|
$headers = [];
|
||||||
|
if (function_exists('getallheaders')) {
|
||||||
|
foreach (getallheaders() as $k => $v) {
|
||||||
|
$headers[strtolower($k)] = $v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($headers['authorization']) && preg_match('/Bearer\s+(\S+)/i', $headers['authorization'], $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
if (!empty($headers['x-api-key'])) {
|
||||||
|
return trim($headers['x-api-key']);
|
||||||
|
}
|
||||||
|
if (!empty($_SERVER['HTTP_X_API_KEY'])) {
|
||||||
|
return trim($_SERVER['HTTP_X_API_KEY']);
|
||||||
|
}
|
||||||
|
if (!empty($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/Bearer\s+(\S+)/i', $_SERVER['HTTP_AUTHORIZATION'], $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/Totp.php';
|
||||||
|
require_once __DIR__ . '/Notifier.php';
|
||||||
|
require_once __DIR__ . '/Crypto.php';
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
private $db;
|
private $db;
|
||||||
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
|
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
|
||||||
|
|
@ -20,15 +24,49 @@ class Auth {
|
||||||
ini_set('session.cookie_secure', 1);
|
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()) {
|
if (!session_start()) {
|
||||||
die("Session konnte nicht gestartet werden");
|
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
|
// Benutzer einloggen
|
||||||
public function login($email, $password) {
|
public function login($email, $password) {
|
||||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
$ip = $this->clientIp();
|
||||||
|
|
||||||
if ($this->isRateLimited($ip, $email)) {
|
if ($this->isRateLimited($ip, $email)) {
|
||||||
return 'rate_limited';
|
return 'rate_limited';
|
||||||
|
|
@ -41,6 +79,15 @@ class Auth {
|
||||||
|
|
||||||
if ($user && password_verify($password, $user['password_hash'])) {
|
if ($user && password_verify($password, $user['password_hash'])) {
|
||||||
$this->clearLoginAttempts($ip, $email);
|
$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->setUserSession($user);
|
||||||
$this->updateLastLogin($user['id']);
|
$this->updateLastLogin($user['id']);
|
||||||
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
|
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
|
||||||
|
|
@ -51,11 +98,135 @@ class Auth {
|
||||||
return false;
|
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) {
|
public function writeAuditLog($userId, $action, $entityType = null, $entityId = null, $details = null) {
|
||||||
try {
|
try {
|
||||||
$this->db->execute(
|
$this->db->execute(
|
||||||
"INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)",
|
"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) {
|
} catch (\Exception $e) {
|
||||||
// audit_log table may not exist on old installs
|
// audit_log table may not exist on old installs
|
||||||
|
|
@ -297,6 +468,45 @@ class Auth {
|
||||||
header('Location: /index.php?error=access_denied');
|
header('Location: /index.php?error=access_denied');
|
||||||
exit;
|
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
|
// Login erforderlich
|
||||||
|
|
|
||||||
56
includes/Captcha.php
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Captcha – Schutz der öffentlichen (anonymen) Voucher-Erstellung.
|
||||||
|
*
|
||||||
|
* Modi (Setting captcha_mode):
|
||||||
|
* 'off' – deaktiviert
|
||||||
|
* 'math' – selbst-enthaltenes Rechen-Captcha (keine externen Dienste)
|
||||||
|
* 'hcaptcha' – hCaptcha (Setting captcha_site_key / captcha_secret)
|
||||||
|
*
|
||||||
|
* Reine PHP-Standardlib + cURL (für hCaptcha-Verifizierung).
|
||||||
|
*/
|
||||||
|
class Captcha {
|
||||||
|
public static function mode($db) {
|
||||||
|
$m = (string)$db->getSetting('captcha_mode', 'off');
|
||||||
|
return in_array($m, ['off', 'math', 'hcaptcha'], true) ? $m : 'off';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Frage für das Math-Captcha erzeugen und Antwort in Session hinterlegen. */
|
||||||
|
public static function newMathChallenge() {
|
||||||
|
$a = random_int(1, 9);
|
||||||
|
$b = random_int(1, 9);
|
||||||
|
$_SESSION['captcha_answer'] = (string)($a + $b);
|
||||||
|
return "$a + $b";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft die Captcha-Antwort des aktuellen Requests. */
|
||||||
|
public static function verify($db) {
|
||||||
|
$mode = self::mode($db);
|
||||||
|
if ($mode === 'off') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($mode === 'math') {
|
||||||
|
$expected = $_SESSION['captcha_answer'] ?? null;
|
||||||
|
unset($_SESSION['captcha_answer']); // einmalig
|
||||||
|
$given = trim((string)($_POST['captcha'] ?? ''));
|
||||||
|
return $expected !== null && hash_equals((string)$expected, $given);
|
||||||
|
}
|
||||||
|
if ($mode === 'hcaptcha') {
|
||||||
|
$resp = $_POST['h-captcha-response'] ?? '';
|
||||||
|
if ($resp === '') return false;
|
||||||
|
$secret = (string)$db->getSetting('captcha_secret', '');
|
||||||
|
$ch = curl_init('https://hcaptcha.com/siteverify');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => http_build_query(['secret' => $secret, 'response' => $resp]),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 8,
|
||||||
|
]);
|
||||||
|
$out = curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
$data = json_decode((string)$out, true);
|
||||||
|
return is_array($data) && !empty($data['success']);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
includes/DbSessionHandler.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,12 +33,23 @@ class Mailer {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send($to, $subject, $body, $isHtml = false) {
|
public function send($to, $subject, $body, $isHtml = false) {
|
||||||
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
|
||||||
// Fallback auf PHP mail()
|
$attempts = 2;
|
||||||
return $this->sendWithPhpMail($to, $subject, $body);
|
for ($i = 1; $i <= $attempts; $i++) {
|
||||||
|
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
||||||
|
$ok = $this->sendWithPhpMail($to, $subject, $body);
|
||||||
|
} else {
|
||||||
|
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
||||||
|
}
|
||||||
|
if ($ok) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($i < $attempts) {
|
||||||
|
usleep(500000); // 0,5s vor erneutem Versuch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
|
||||||
return $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendWithPhpMail($to, $subject, $body) {
|
private function sendWithPhpMail($to, $subject, $body) {
|
||||||
|
|
|
||||||
71
includes/Notifier.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Notifier – sendet Ereignis-Benachrichtigungen an einen konfigurierten
|
||||||
|
* Webhook (Slack / Microsoft Teams / generischer JSON-Endpunkt).
|
||||||
|
*
|
||||||
|
* Gesteuert über Einstellungen:
|
||||||
|
* webhook_enabled (0/1), webhook_url
|
||||||
|
*
|
||||||
|
* Sendet ein Slack/Teams-kompatibles { "text": "..." }-Payload plus ein
|
||||||
|
* strukturiertes "event"-Feld. Fehler werden still ignoriert – eine
|
||||||
|
* Benachrichtigung darf nie den eigentlichen Vorgang blockieren.
|
||||||
|
*/
|
||||||
|
class Notifier {
|
||||||
|
/** Generisches Event senden. */
|
||||||
|
public static function send($text, array $data = []) {
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
if ((string)$db->getSetting('webhook_enabled', '0') !== '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$url = trim((string)$db->getSetting('webhook_url', ''));
|
||||||
|
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_encode(array_merge(['text' => $text], $data ? ['event' => $data] : []));
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_POSTFIELDS => $payload,
|
||||||
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 5,
|
||||||
|
CURLOPT_CONNECTTIMEOUT => 3,
|
||||||
|
]);
|
||||||
|
@curl_exec($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Controller nicht erreichbar (z.B. beim Sync). */
|
||||||
|
public static function controllerUnreachable($siteName, $detail = '') {
|
||||||
|
self::send("⚠️ UniFi-Controller für \"{$siteName}\" nicht erreichbar." . ($detail ? " ({$detail})" : ''),
|
||||||
|
['type' => 'controller_unreachable', 'site' => $siteName, 'detail' => $detail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anmeldung von einer bisher unbekannten IP. */
|
||||||
|
public static function loginNewIp($email, $ip) {
|
||||||
|
self::send("🔐 Neue Anmeldung für {$email} von IP {$ip}.",
|
||||||
|
['type' => 'login_new_ip', 'email' => $email, 'ip' => $ip]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update verfügbar. */
|
||||||
|
public static function updateAvailable($sha) {
|
||||||
|
self::send("⬆️ Update verfügbar (" . substr((string)$sha, 0, 7) . "). Siehe Administration → System-Update.",
|
||||||
|
['type' => 'update_available', 'sha' => $sha]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bequemer Helfer für erstellte Voucher. */
|
||||||
|
public static function voucherCreated($count, $siteName, $byUser = null) {
|
||||||
|
$who = $byUser ? " von {$byUser}" : '';
|
||||||
|
$what = $count > 1 ? "{$count} Voucher" : 'Ein Voucher';
|
||||||
|
self::send(
|
||||||
|
"🎫 {$what} für \"{$siteName}\"{$who} erstellt.",
|
||||||
|
['type' => 'voucher_created', 'count' => $count, 'site' => $siteName, 'user' => $byUser]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
includes/Sms.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Sms – Versand von Voucher-Codes per SMS über Twilio.
|
||||||
|
*
|
||||||
|
* Settings: sms_enabled (0/1), twilio_sid, twilio_token, twilio_from
|
||||||
|
* Reine PHP-Standardlib + cURL. Fehler werden geloggt, nie geworfen.
|
||||||
|
*/
|
||||||
|
class Sms {
|
||||||
|
public static function enabled($db) {
|
||||||
|
return (string)$db->getSetting('sms_enabled', '0') === '1'
|
||||||
|
&& $db->getSetting('twilio_sid', '') !== ''
|
||||||
|
&& $db->getSetting('twilio_token', '') !== ''
|
||||||
|
&& $db->getSetting('twilio_from', '') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sendet eine SMS. Gibt true bei Erfolg zurück. */
|
||||||
|
public static function send($db, $to, $text) {
|
||||||
|
if (!self::enabled($db)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$sid = (string)$db->getSetting('twilio_sid', '');
|
||||||
|
$token = (string)$db->getSetting('twilio_token', '');
|
||||||
|
$from = (string)$db->getSetting('twilio_from', '');
|
||||||
|
|
||||||
|
$ch = curl_init("https://api.twilio.com/2010-04-01/Accounts/" . rawurlencode($sid) . "/Messages.json");
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_POST => true,
|
||||||
|
CURLOPT_USERPWD => $sid . ':' . $token,
|
||||||
|
CURLOPT_POSTFIELDS => http_build_query(['From' => $from, 'To' => $to, 'Body' => $text]),
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
]);
|
||||||
|
$out = curl_exec($ch);
|
||||||
|
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
if ($code >= 200 && $code < 300) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
error_log("Sms(Twilio) Fehler HTTP $code: " . substr((string)$out, 0, 200));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
includes/Totp.php
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Totp – minimaler TOTP-Generator/-Validator nach RFC 6238 (HMAC-SHA1,
|
||||||
|
* 6 Stellen, 30s-Zeitfenster). Reine PHP-Standardlib, keine Abhaengigkeiten.
|
||||||
|
* Kompatibel mit Google Authenticator, Authy, Microsoft Authenticator etc.
|
||||||
|
*/
|
||||||
|
class Totp {
|
||||||
|
private const DIGITS = 6;
|
||||||
|
private const PERIOD = 30;
|
||||||
|
private const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
|
||||||
|
/** Erzeugt ein neues Base32-Secret (Standard 16 Zeichen = 80 bit). */
|
||||||
|
public static function generateSecret($length = 16) {
|
||||||
|
$secret = '';
|
||||||
|
$bytes = random_bytes($length);
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$secret .= self::BASE32[ord($bytes[$i]) & 31];
|
||||||
|
}
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktueller Code fuer ein Secret. */
|
||||||
|
public static function code($secret, $timeSlice = null) {
|
||||||
|
if ($timeSlice === null) {
|
||||||
|
$timeSlice = (int) floor(time() / self::PERIOD);
|
||||||
|
}
|
||||||
|
$key = self::base32Decode($secret);
|
||||||
|
// 8-Byte Big-Endian Counter
|
||||||
|
$binTime = pack('N*', 0) . pack('N*', $timeSlice);
|
||||||
|
$hash = hash_hmac('sha1', $binTime, $key, true);
|
||||||
|
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
|
||||||
|
$part = substr($hash, $offset, 4);
|
||||||
|
$value = unpack('N', $part)[1] & 0x7FFFFFFF;
|
||||||
|
$mod = $value % (10 ** self::DIGITS);
|
||||||
|
return str_pad((string)$mod, self::DIGITS, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft einen Code mit Toleranzfenster (+/- $window Zeitschritte gegen
|
||||||
|
* Uhren-Drift). Konstante-Zeit-Vergleich gegen Timing-Angriffe.
|
||||||
|
*/
|
||||||
|
public static function verify($secret, $code, $window = 1) {
|
||||||
|
if (!preg_match('/^\d{6}$/', (string)$code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$current = (int) floor(time() / self::PERIOD);
|
||||||
|
for ($i = -$window; $i <= $window; $i++) {
|
||||||
|
if (hash_equals(self::code($secret, $current + $i), (string)$code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** otpauth://-URI fuer QR-Code-Provisionierung. */
|
||||||
|
public static function provisioningUri($secret, $accountName, $issuer) {
|
||||||
|
$label = rawurlencode($issuer . ':' . $accountName);
|
||||||
|
$params = http_build_query([
|
||||||
|
'secret' => $secret,
|
||||||
|
'issuer' => $issuer,
|
||||||
|
'algorithm' => 'SHA1',
|
||||||
|
'digits' => self::DIGITS,
|
||||||
|
'period' => self::PERIOD,
|
||||||
|
]);
|
||||||
|
return "otpauth://totp/$label?$params";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function base32Decode($b32) {
|
||||||
|
$b32 = strtoupper(rtrim($b32, '='));
|
||||||
|
$buffer = 0; $bitsLeft = 0; $out = '';
|
||||||
|
for ($i = 0; $i < strlen($b32); $i++) {
|
||||||
|
$val = strpos(self::BASE32, $b32[$i]);
|
||||||
|
if ($val === false) continue;
|
||||||
|
$buffer = ($buffer << 5) | $val;
|
||||||
|
$bitsLeft += 5;
|
||||||
|
if ($bitsLeft >= 8) {
|
||||||
|
$bitsLeft -= 8;
|
||||||
|
$out .= chr(($buffer >> $bitsLeft) & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -161,8 +161,9 @@ class UniFiController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Einzelnen Voucher erstellen
|
// Einzelnen Voucher erstellen
|
||||||
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480) {
|
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||||
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1);
|
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) {
|
||||||
|
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1, $options);
|
||||||
return $vouchers[0];
|
return $vouchers[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,9 +176,10 @@ class UniFiController {
|
||||||
* identifiziert. Der fruehere Fallback "global neuester Voucher" konnte
|
* identifiziert. Der fruehere Fallback "global neuester Voucher" konnte
|
||||||
* bei parallelen Erstellungen fremde Codes liefern und wurde entfernt.
|
* bei parallelen Erstellungen fremde Codes liefern und wurde entfernt.
|
||||||
*
|
*
|
||||||
|
* @param array $options Optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||||
* @return array Liste von ['code','formatted_code','unifi_id','create_time']
|
* @return array Liste von ['code','formatted_code','unifi_id','create_time']
|
||||||
*/
|
*/
|
||||||
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1) {
|
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1, $options = []) {
|
||||||
$count = max(1, (int)$count);
|
$count = max(1, (int)$count);
|
||||||
$data = [
|
$data = [
|
||||||
'cmd' => 'create-voucher',
|
'cmd' => 'create-voucher',
|
||||||
|
|
@ -187,6 +189,17 @@ class UniFiController {
|
||||||
'quota' => (int)$maxUses
|
'quota' => (int)$maxUses
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Bandbreiten-/Datenlimits (UniFi QoS) optional setzen
|
||||||
|
$down = isset($options['down']) ? (int)$options['down'] : 0;
|
||||||
|
$up = isset($options['up']) ? (int)$options['up'] : 0;
|
||||||
|
$bytes = isset($options['quota_mb']) ? (int)$options['quota_mb'] : 0;
|
||||||
|
if ($down > 0 || $up > 0 || $bytes > 0) {
|
||||||
|
$data['qos_overwrite'] = true;
|
||||||
|
if ($down > 0) $data['down'] = $down; // kbit/s
|
||||||
|
if ($up > 0) $data['up'] = $up; // kbit/s
|
||||||
|
if ($bytes > 0) $data['bytes'] = $bytes; // Megabyte
|
||||||
|
}
|
||||||
|
|
||||||
$response = $this->apiRequest("/proxy/network/api/s/{$this->siteId}/cmd/hotspot", $data);
|
$response = $this->apiRequest("/proxy/network/api/s/{$this->siteId}/cmd/hotspot", $data);
|
||||||
|
|
||||||
if (!isset($response['data'][0]['create_time'])) {
|
if (!isset($response['data'][0]['create_time'])) {
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,27 @@ $lang = I18n::getLanguage();
|
||||||
<li><a href="<?= $adminBase ?? '' ?>audit_log.php" class="<?= $currentPage === 'audit_log' ? 'active' : '' ?>">
|
<li><a href="<?= $adminBase ?? '' ?>audit_log.php" class="<?= $currentPage === 'audit_log' ? 'active' : '' ?>">
|
||||||
<i class="fas fa-history"></i> <?= __('nav_audit_log') ?>
|
<i class="fas fa-history"></i> <?= __('nav_audit_log') ?>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>reports.php" class="<?= $currentPage === 'reports' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-chart-line"></i> <?= __('nav_reports') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>import.php" class="<?= $currentPage === 'import' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-file-import"></i> <?= __('nav_import') ?>
|
||||||
|
</a></li>
|
||||||
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
|
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
|
||||||
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
|
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>api_keys.php" class="<?= $currentPage === 'api_keys' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-key"></i> <?= __('nav_api_keys') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>security.php" class="<?= $currentPage === 'security' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-user-shield"></i> <?= __('nav_security') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>integrations.php" class="<?= $currentPage === 'integrations' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-plug"></i> <?= __('nav_integrations') ?>
|
||||||
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>backup.php" class="<?= $currentPage === 'backup' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-database"></i> <?= __('nav_backup') ?>
|
||||||
|
</a></li>
|
||||||
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
|
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
|
||||||
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
|
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
|
||||||
100
index.php
|
|
@ -16,6 +16,9 @@ require_once __DIR__ . '/includes/Database.php';
|
||||||
require_once __DIR__ . '/includes/Auth.php';
|
require_once __DIR__ . '/includes/Auth.php';
|
||||||
require_once __DIR__ . '/includes/UniFiController.php';
|
require_once __DIR__ . '/includes/UniFiController.php';
|
||||||
require_once __DIR__ . '/includes/Mailer.php';
|
require_once __DIR__ . '/includes/Mailer.php';
|
||||||
|
require_once __DIR__ . '/includes/Notifier.php';
|
||||||
|
require_once __DIR__ . '/includes/Captcha.php';
|
||||||
|
require_once __DIR__ . '/includes/Sms.php';
|
||||||
require_once __DIR__ . '/includes/I18n.php';
|
require_once __DIR__ . '/includes/I18n.php';
|
||||||
require_once __DIR__ . '/includes/Helpers.php';
|
require_once __DIR__ . '/includes/Helpers.php';
|
||||||
|
|
||||||
|
|
@ -87,6 +90,26 @@ function formatDuration($minutes) {
|
||||||
return __('dur_minutes', ['n' => $minutes]);
|
return __('dur_minutes', ['n' => $minutes]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionales Tageslimit pro (Nicht-Admin-)Benutzer (Setting
|
||||||
|
* user_daily_voucher_limit, 0 = aus). Verhindert übermäßige Erstellung.
|
||||||
|
*/
|
||||||
|
function userDailyLimitExceeded($db, $auth, $additional = 1) {
|
||||||
|
if (!$auth->isLoggedIn() || $auth->isAdmin()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$limit = (int)$db->getSetting('user_daily_voucher_limit', 0);
|
||||||
|
if ($limit <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$uid = $_SESSION['user_id'] ?? 0;
|
||||||
|
$today = (int)($db->fetchOne(
|
||||||
|
"SELECT COUNT(*) c FROM vouchers WHERE user_id=? AND DATE(created_at)=CURDATE()",
|
||||||
|
[$uid]
|
||||||
|
)['c'] ?? 0);
|
||||||
|
return ($today + $additional) > $limit;
|
||||||
|
}
|
||||||
|
|
||||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
$logoUrl = $db->getSetting('logo_url', '');
|
$logoUrl = $db->getSetting('logo_url', '');
|
||||||
$faviconUrl = $db->getSetting('favicon_url', '');
|
$faviconUrl = $db->getSetting('favicon_url', '');
|
||||||
|
|
@ -135,7 +158,7 @@ if ($auth->isLoggedIn()) {
|
||||||
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
|
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
|
||||||
|
|
||||||
// Helper: create one voucher and save to DB
|
// Helper: create one voucher and save to DB
|
||||||
function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId) {
|
function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) {
|
||||||
$datum = date('Y-m-d');
|
$datum = date('Y-m-d');
|
||||||
$fullName = $datum . '_' . $voucherName;
|
$fullName = $datum . '_' . $voucherName;
|
||||||
$controller = new UniFiController(
|
$controller = new UniFiController(
|
||||||
|
|
@ -145,7 +168,7 @@ function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $us
|
||||||
$site['site_id'],
|
$site['site_id'],
|
||||||
$site['ssl_verify'] ?? 0
|
$site['ssl_verify'] ?? 0
|
||||||
);
|
);
|
||||||
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes);
|
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
|
||||||
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
|
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
|
||||||
throw new Exception(__('error_voucher_invalid'));
|
throw new Exception(__('error_voucher_invalid'));
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +197,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
||||||
$error = __('error_csrf');
|
$error = __('error_csrf');
|
||||||
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited($db)) {
|
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited($db)) {
|
||||||
$error = __('error_rate_limited');
|
$error = __('error_rate_limited');
|
||||||
|
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
|
||||||
|
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
$siteId = (int)($_POST['site_id'] ?? 0);
|
$siteId = (int)($_POST['site_id'] ?? 0);
|
||||||
|
|
@ -189,14 +214,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
||||||
if ($sendEmail && !filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
|
if ($sendEmail && !filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
|
||||||
|
|
||||||
if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
||||||
|
if (userDailyLimitExceeded($db, $auth, 1)) throw new Exception('Tageslimit für Voucher erreicht.');
|
||||||
|
|
||||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||||||
if (!$site) throw new Exception(__('error_site_not_found'));
|
if (!$site) throw new Exception(__('error_site_not_found'));
|
||||||
|
|
||||||
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null;
|
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null;
|
||||||
$voucherData = doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId);
|
$qos = [
|
||||||
|
'down' => max(0, (int)($_POST['qos_down'] ?? 0)),
|
||||||
|
'up' => max(0, (int)($_POST['qos_up'] ?? 0)),
|
||||||
|
'quota_mb' => max(0, (int)($_POST['qos_quota'] ?? 0)),
|
||||||
|
];
|
||||||
|
$voucherData = doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos);
|
||||||
$voucherCode = $voucherData['code'];
|
$voucherCode = $voucherData['code'];
|
||||||
$voucherCreated = true;
|
$voucherCreated = true;
|
||||||
|
Notifier::voucherCreated(1, $site['name'], $_SESSION['user_name'] ?? null);
|
||||||
|
|
||||||
if ($sendEmail && !empty($recipientEmail)) {
|
if ($sendEmail && !empty($recipientEmail)) {
|
||||||
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
|
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
|
||||||
|
|
@ -204,6 +236,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
||||||
} else {
|
} else {
|
||||||
$success = __('voucher_created_ok');
|
$success = __('voucher_created_ok');
|
||||||
}
|
}
|
||||||
|
// Optional: Code per SMS (Twilio)
|
||||||
|
$recipientPhone = trim((string)($_POST['recipient_phone'] ?? ''));
|
||||||
|
if (isset($_POST['send_sms']) && $recipientPhone !== '' && Sms::enabled($db)) {
|
||||||
|
$smsText = ($appTitle ? $appTitle . ': ' : '') . 'WLAN-Code ' . $voucherCode;
|
||||||
|
$success .= Sms::send($db, $recipientPhone, $smsText) ? ' SMS versendet.' : ' (SMS fehlgeschlagen)';
|
||||||
|
}
|
||||||
|
|
||||||
$auth->writeAuditLog($userId, 'voucher_create', 'voucher', null,
|
$auth->writeAuditLog($userId, 'voucher_create', 'voucher', null,
|
||||||
"Voucher '{$voucherName}' für {$site['name']}" . ($userId === null ? ' (öffentlich)' : ''));
|
"Voucher '{$voucherName}' für {$site['name']}" . ($userId === null ? ' (öffentlich)' : ''));
|
||||||
|
|
@ -239,11 +277,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
|
||||||
if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit]));
|
if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit]));
|
||||||
if ($siteId <= 0) throw new Exception(__('error_site_req'));
|
if ($siteId <= 0) throw new Exception(__('error_site_req'));
|
||||||
if (!$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
if (!$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
||||||
|
if (userDailyLimitExceeded($db, $auth, $bulkCount)) throw new Exception('Tageslimit für Voucher erreicht.');
|
||||||
|
|
||||||
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||||||
if (!$site) throw new Exception(__('error_site_not_found'));
|
if (!$site) throw new Exception(__('error_site_not_found'));
|
||||||
|
|
||||||
$userId = $_SESSION['user_id'] ?? null;
|
$userId = $_SESSION['user_id'] ?? null;
|
||||||
|
$qos = [
|
||||||
|
'down' => max(0, (int)($_POST['qos_down'] ?? 0)),
|
||||||
|
'up' => max(0, (int)($_POST['qos_up'] ?? 0)),
|
||||||
|
'quota_mb' => max(0, (int)($_POST['qos_quota'] ?? 0)),
|
||||||
|
];
|
||||||
|
|
||||||
// Alle Voucher in EINEM UniFi-API-Call erstellen ('n'-Parameter)
|
// Alle Voucher in EINEM UniFi-API-Call erstellen ('n'-Parameter)
|
||||||
// statt pro Voucher Login + Voucherliste abzurufen.
|
// statt pro Voucher Login + Voucherliste abzurufen.
|
||||||
|
|
@ -255,7 +299,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
|
||||||
$site['site_id'],
|
$site['site_id'],
|
||||||
$site['ssl_verify'] ?? 0
|
$site['ssl_verify'] ?? 0
|
||||||
);
|
);
|
||||||
$created = $controller->createVouchers($fullName, $maxUses, $expireMinutes, $bulkCount);
|
$created = $controller->createVouchers($fullName, $maxUses, $expireMinutes, $bulkCount, $qos);
|
||||||
$expiryTs = time() + ($expireMinutes * 60);
|
$expiryTs = time() + ($expireMinutes * 60);
|
||||||
|
|
||||||
foreach ($created as $i => $voucher) {
|
foreach ($created as $i => $voucher) {
|
||||||
|
|
@ -274,6 +318,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Notifier::voucherCreated(count($created), $site['name'], $_SESSION['user_name'] ?? null);
|
||||||
$auth->writeAuditLog($userId, 'voucher_bulk', 'voucher', null,
|
$auth->writeAuditLog($userId, 'voucher_bulk', 'voucher', null,
|
||||||
count($created) . " Vouchers '{$voucherName}' für {$site['name']}");
|
count($created) . " Vouchers '{$voucherName}' für {$site['name']}");
|
||||||
|
|
||||||
|
|
@ -319,6 +364,19 @@ if ($stickyMaxUses < 1 || $stickyMaxUses > $maxUsesLimit) $stickyMaxUses = $defa
|
||||||
// Anonyme Gaeste wissen oft nicht, was sie als Namen eintragen sollen -> Default
|
// Anonyme Gaeste wissen oft nicht, was sie als Namen eintragen sollen -> Default
|
||||||
if ($stickyName === '' && !$auth->isLoggedIn()) $stickyName = __('voucher_name_default');
|
if ($stickyName === '' && !$auth->isLoggedIn()) $stickyName = __('voucher_name_default');
|
||||||
|
|
||||||
|
// Captcha nur für anonyme öffentliche Erstellung
|
||||||
|
$captchaMode = !$auth->isLoggedIn() ? Captcha::mode($db) : 'off';
|
||||||
|
$captchaQuestion = $captchaMode === 'math' ? Captcha::newMathChallenge() : '';
|
||||||
|
$hcaptchaSiteKey = $captchaMode === 'hcaptcha' ? $db->getSetting('captcha_site_key', '') : '';
|
||||||
|
$smsEnabled = Sms::enabled($db);
|
||||||
|
$captchaHtml = '';
|
||||||
|
if ($captchaMode === 'math') {
|
||||||
|
$captchaHtml = '<div class="form-group"><label>Sicherheitsfrage: Wie viel ist ' . htmlspecialchars($captchaQuestion) . '?</label>'
|
||||||
|
. '<input type="text" name="captcha" inputmode="numeric" required></div>';
|
||||||
|
} elseif ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== '') {
|
||||||
|
$captchaHtml = '<div class="form-group"><div class="h-captcha" data-sitekey="' . htmlspecialchars($hcaptchaSiteKey) . '"></div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Build print HTML for each voucher
|
// Build print HTML for each voucher
|
||||||
function buildPrintCard($template, $data, $instructionHeader, $instructionText, $appTitle) {
|
function buildPrintCard($template, $data, $instructionHeader, $instructionText, $appTitle) {
|
||||||
$instructions = $instructionHeader || $instructionText
|
$instructions = $instructionHeader || $instructionText
|
||||||
|
|
@ -341,6 +399,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
|
<link rel="icon" href="<?= htmlspecialchars($faviconUrl) ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<link rel="stylesheet" href="assets/global.css">
|
<link rel="stylesheet" href="assets/global.css">
|
||||||
|
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
|
||||||
|
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
|
||||||
<?php if ($voucherCreated): ?>
|
<?php if ($voucherCreated): ?>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSe2keRB6Q5pBUtIxCY7bQMsVB0ANBpd6JDg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||||
|
|
@ -564,6 +625,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<option value="<?= (int)$tpl['id'] ?>"
|
<option value="<?= (int)$tpl['id'] ?>"
|
||||||
data-max-uses="<?= (int)$tpl['max_uses'] ?>"
|
data-max-uses="<?= (int)$tpl['max_uses'] ?>"
|
||||||
data-expire="<?= (int)$tpl['expire_minutes'] ?>"
|
data-expire="<?= (int)$tpl['expire_minutes'] ?>"
|
||||||
|
data-qos-down="<?= (int)($tpl['qos_rate_max_down'] ?? 0) ?>"
|
||||||
|
data-qos-up="<?= (int)($tpl['qos_rate_max_up'] ?? 0) ?>"
|
||||||
|
data-qos-quota="<?= (int)($tpl['qos_usage_quota'] ?? 0) ?>"
|
||||||
data-desc="<?= htmlspecialchars($tpl['description'] ?? '') ?>">
|
data-desc="<?= htmlspecialchars($tpl['description'] ?? '') ?>">
|
||||||
<?= htmlspecialchars($tpl['name']) ?> –
|
<?= htmlspecialchars($tpl['name']) ?> –
|
||||||
<?= (int)$tpl['max_uses'] ?> <?= __('label_devices') ?>,
|
<?= (int)$tpl['max_uses'] ?> <?= __('label_devices') ?>,
|
||||||
|
|
@ -581,6 +645,10 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<input type="hidden" name="create_voucher" value="1">
|
<input type="hidden" name="create_voucher" value="1">
|
||||||
<input type="hidden" name="expire_minutes" id="expire_minutes" value="<?= $defaultExpire ?>">
|
<input type="hidden" name="expire_minutes" id="expire_minutes" value="<?= $defaultExpire ?>">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
|
||||||
|
<input type="hidden" name="qos_down" class="qos-down-field" value="0">
|
||||||
|
<input type="hidden" name="qos_up" class="qos-up-field" value="0">
|
||||||
|
<input type="hidden" name="qos_quota" class="qos-quota-field" value="0">
|
||||||
|
<?= $captchaHtml ?>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="voucher_name"><?= __('voucher_name_label') ?></label>
|
<label for="voucher_name"><?= __('voucher_name_label') ?></label>
|
||||||
|
|
@ -627,6 +695,19 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($smsEnabled): ?>
|
||||||
|
<div class="email-option">
|
||||||
|
<div class="email-checkbox-wrapper">
|
||||||
|
<input type="checkbox" id="send_sms" name="send_sms" onchange="document.getElementById('sms_field').style.display=this.checked?'block':'none'">
|
||||||
|
<label for="send_sms">📱 Code per SMS versenden</label>
|
||||||
|
</div>
|
||||||
|
<div id="sms_field" style="display:none;margin-top:12px;">
|
||||||
|
<label for="recipient_phone">Telefonnummer (international, z.B. +49170…)</label>
|
||||||
|
<input type="tel" id="recipient_phone" name="recipient_phone" placeholder="+49170123456">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<button type="submit" class="btn" id="submitBtn">
|
<button type="submit" class="btn" id="submitBtn">
|
||||||
<?= __('voucher_create_btn') ?>
|
<?= __('voucher_create_btn') ?>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -640,6 +721,10 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<input type="hidden" name="create_bulk" value="1">
|
<input type="hidden" name="create_bulk" value="1">
|
||||||
<input type="hidden" name="expire_minutes" id="bulk_expire_minutes" value="<?= $defaultExpire ?>">
|
<input type="hidden" name="expire_minutes" id="bulk_expire_minutes" value="<?= $defaultExpire ?>">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
|
||||||
|
<input type="hidden" name="qos_down" class="qos-down-field" value="0">
|
||||||
|
<input type="hidden" name="qos_up" class="qos-up-field" value="0">
|
||||||
|
<input type="hidden" name="qos_quota" class="qos-quota-field" value="0">
|
||||||
|
<?= $captchaHtml ?>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
|
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
|
||||||
|
|
@ -754,6 +839,13 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
const bmuEl = document.getElementById('bulk_max_uses');
|
const bmuEl = document.getElementById('bulk_max_uses');
|
||||||
if (bmuEl) bmuEl.value = maxUses;
|
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');
|
const descEl = document.getElementById('template_desc');
|
||||||
if (descEl) descEl.textContent = opt.dataset.desc || '';
|
if (descEl) descEl.textContent = opt.dataset.desc || '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ return [
|
||||||
'nav_vouchers' => 'Live Vouchers',
|
'nav_vouchers' => 'Live Vouchers',
|
||||||
'nav_templates' => 'Voucher-Profile',
|
'nav_templates' => 'Voucher-Profile',
|
||||||
'nav_audit_log' => 'Audit-Log',
|
'nav_audit_log' => 'Audit-Log',
|
||||||
|
'nav_reports' => 'Reporting',
|
||||||
|
'nav_import' => 'Voucher-Import',
|
||||||
'nav_settings' => 'Einstellungen',
|
'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_update' => 'System-Update',
|
||||||
'nav_back' => 'Zurück zur Startseite',
|
'nav_back' => 'Zurück zur Startseite',
|
||||||
'nav_administration'=> 'Administration',
|
'nav_administration'=> 'Administration',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ return [
|
||||||
'nav_vouchers' => 'Live Vouchers',
|
'nav_vouchers' => 'Live Vouchers',
|
||||||
'nav_templates' => 'Voucher Profiles',
|
'nav_templates' => 'Voucher Profiles',
|
||||||
'nav_audit_log' => 'Audit Log',
|
'nav_audit_log' => 'Audit Log',
|
||||||
|
'nav_reports' => 'Reporting',
|
||||||
|
'nav_import' => 'Voucher Import',
|
||||||
'nav_settings' => 'Settings',
|
'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_update' => 'System Update',
|
||||||
'nav_back' => 'Back to Home',
|
'nav_back' => 'Back to Home',
|
||||||
'nav_administration'=> 'Administration',
|
'nav_administration'=> 'Administration',
|
||||||
|
|
|
||||||
68
login.php
|
|
@ -19,8 +19,21 @@ I18n::init();
|
||||||
|
|
||||||
$error = '';
|
$error = '';
|
||||||
$success = '';
|
$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 {
|
try {
|
||||||
$email = trim($_POST['email'] ?? '');
|
$email = trim($_POST['email'] ?? '');
|
||||||
$password = $_POST['password'] ?? '';
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
@ -32,6 +45,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
if ($result === true) {
|
if ($result === true) {
|
||||||
header('Location: index.php');
|
header('Location: index.php');
|
||||||
exit;
|
exit;
|
||||||
|
} elseif ($result === 'totp_required') {
|
||||||
|
$show2fa = true;
|
||||||
} elseif ($result === 'rate_limited') {
|
} elseif ($result === 'rate_limited') {
|
||||||
$error = __('login_error_rate');
|
$error = __('login_error_rate');
|
||||||
} else {
|
} 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 {
|
try {
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
|
|
@ -75,6 +95,27 @@ try {
|
||||||
$m365LoginUrl = "https://login.microsoftonline.com/$m365TenantId/oauth2/v2.0/authorize?" . http_build_query($params);
|
$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';
|
$showLocalLogin = isset($_GET['local']) && $_GET['local'] === '1';
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|
@ -147,7 +188,23 @@ try {
|
||||||
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
|
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($m365Enabled && !$showLocalLogin): ?>
|
<?php if ($show2fa): ?>
|
||||||
|
<form method="post">
|
||||||
|
<p style="color:var(--text-secondary,#666);font-size:14px;margin-bottom:18px;">
|
||||||
|
Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
|
||||||
|
– oder einen Ihrer Recovery-Codes.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totp_code">Code</label>
|
||||||
|
<input type="text" id="totp_code" name="totp_code" maxlength="9"
|
||||||
|
autocomplete="one-time-code" required autofocus
|
||||||
|
placeholder="123456 oder XXXX-XXXX"
|
||||||
|
style="letter-spacing:3px;text-align:center;font-size:18px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Bestätigen</button>
|
||||||
|
</form>
|
||||||
|
<a href="login.php" class="local-login-link">Abbrechen</a>
|
||||||
|
<?php elseif ($m365Enabled && !$showLocalLogin): ?>
|
||||||
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
|
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
|
||||||
<path fill="#f35325" d="M1 1h10v10H1z"/>
|
<path fill="#f35325" d="M1 1h10v10H1z"/>
|
||||||
|
|
@ -188,6 +245,13 @@ try {
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!$show2fa && $oidcEnabled): ?>
|
||||||
|
<div class="divider"><span><?= __('or') ?></span></div>
|
||||||
|
<a href="<?= htmlspecialchars($oidcLoginUrl) ?>" class="btn" style="display:block;text-align:center;text-decoration:none;background:#444;">
|
||||||
|
🔑 <?= htmlspecialchars($oidcName) ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($publicAccess): ?>
|
<?php if ($publicAccess): ?>
|
||||||
<a href="index.php" class="back-link"><?= __('login_back') ?></a>
|
<a href="index.php" class="back-link"><?= __('login_back') ?></a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
|
||||||
101
oidc_callback.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Generischer OpenID-Connect-Callback (Authorization-Code-Flow).
|
||||||
|
* Konfiguration unter Administration → Integration & Wartung.
|
||||||
|
* Nutzt denselben Account-Verknüpfungs-Mechanismus wie M365 (externe SSO-ID).
|
||||||
|
*/
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
ini_set('log_errors', 1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/config.php';
|
||||||
|
require_once __DIR__ . '/includes/Database.php';
|
||||||
|
require_once __DIR__ . '/includes/Auth.php';
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$auth = new Auth();
|
||||||
|
|
||||||
|
$clientId = $db->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. <a href="login.php">Zurück zum Login</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($_GET['error'])) {
|
||||||
|
die('OIDC-Fehler: ' . htmlspecialchars($_GET['error']) . '<br><a href="login.php">Zurück zum Login</a>');
|
||||||
|
}
|
||||||
|
if (!isset($_GET['code'])) {
|
||||||
|
die('Kein Authorization Code erhalten.<br><a href="login.php">Zurück zum Login</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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).<br><a href="login.php">Zurück zum Login</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 . ').<br><a href="login.php">Zurück zum Login</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 . ').<br><a href="login.php">Zurück zum Login</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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).<br><a href="login.php">Zurück zum Login</a>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) . '<br><a href="login.php">Zurück zum Login</a>');
|
||||||
|
}
|
||||||
6
phpstan.neon
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
parameters:
|
||||||
|
level: 5
|
||||||
|
paths:
|
||||||
|
- includes/Totp.php
|
||||||
|
- includes/Crypto.php
|
||||||
|
- includes/ApiKey.php
|
||||||
11
phpunit.xml.dist
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
failOnWarning="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="unit">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
35
tests/ApiKeyTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/ApiKey.php';
|
||||||
|
|
||||||
|
final class ApiKeyTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testGenerateFormat(): void
|
||||||
|
{
|
||||||
|
$k = \ApiKey::generate();
|
||||||
|
$this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
tests/CryptoTest.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
if (!defined('APP_KEY')) {
|
||||||
|
define('APP_KEY', base64_encode(random_bytes(32)));
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/../includes/Crypto.php';
|
||||||
|
|
||||||
|
final class CryptoTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testRoundtrip(): void
|
||||||
|
{
|
||||||
|
$plain = 'geheim;mit"sonder@zeichen';
|
||||||
|
$cipher = \Crypto::encrypt($plain);
|
||||||
|
$this->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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/MigrationSplitterTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../updater/MigrationRunner.php';
|
||||||
|
|
||||||
|
final class MigrationSplitterTest extends TestCase
|
||||||
|
{
|
||||||
|
private function split(string $sql): array
|
||||||
|
{
|
||||||
|
$rc = new \ReflectionClass(\Updater\MigrationRunner::class);
|
||||||
|
$inst = $rc->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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
46
tests/TotpTest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/Totp.php';
|
||||||
|
|
||||||
|
final class TotpTest extends TestCase
|
||||||
|
{
|
||||||
|
/** RFC 6238 Testvektoren (SHA1, 6 Stellen, Seed "12345678901234567890"). */
|
||||||
|
public function testRfc6238Vectors(): void
|
||||||
|
{
|
||||||
|
$secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; // Base32 des RFC-Seeds
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
updater/migrations/0002_extended_features.sql
Normal file
|
|
@ -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;
|
||||||
16
updater/migrations/0003_maturity_features.sql
Normal file
|
|
@ -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;
|
||||||
5
updater/migrations/0004_db_sessions.sql
Normal 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;
|
||||||