diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5791447..5aaaad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,3 +32,26 @@ jobs: - name: Validate JSON language/migration assets run: | php -r 'foreach (glob("lang/*.php") as $f) { $a = require $f; if (!is_array($a)) { fwrite(STDERR, "Bad lang file: $f\n"); exit(1);} } echo "lang OK\n";' + + test: + name: Unit Tests & Static Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.2 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + extensions: pdo, pdo_mysql, curl, mbstring, json + tools: composer + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --no-progress + + - name: PHPUnit + run: vendor/bin/phpunit + + - name: PHPStan + run: vendor/bin/phpstan analyse --no-progress diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..b8be3e7 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,39 @@ +name: Docker Publish + +on: + push: + tags: [ "v*" ] + workflow_dispatch: + +jobs: + build-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index c090cfc..3d0f28c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,10 @@ # UniFi Voucher Management System – Container-Image FROM php:8.2-apache -# PHP-Extensions -RUN docker-php-ext-install pdo pdo_mysql \ +# 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 @@ -25,5 +27,10 @@ COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh EXPOSE 80 + +# Apache-Worker laufen als www-data (Privilege-Drop durch den Master). +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD curl -fsS http://localhost/health.php || exit 1 + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] CMD ["apache2-foreground"] diff --git a/admin/integrations.php b/admin/integrations.php index 0953bdb..e237ad2 100644 --- a/admin/integrations.php +++ b/admin/integrations.php @@ -24,6 +24,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save'])) { $error = __('error_csrf'); } else { $db->setSetting('enforce_2fa_admins', isset($_POST['enforce_2fa_admins']) ? '1' : '0'); + $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'] ?? '')); @@ -41,6 +42,7 @@ if (isset($_GET['test_webhook']) && isset($_GET['token']) && $auth->validateCsrf } $enforce2fa = $db->getSetting('enforce_2fa_admins', '0') === '1'; +$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', ''); @@ -86,6 +88,8 @@ label { display:block; font-size:14px; color:var(--text-secondary); margin:14px

Sicherheitsrichtlinie

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

+ +
diff --git a/admin/vouchers.php b/admin/vouchers.php index 46af577..020bb87 100644 --- a/admin/vouchers.php +++ b/admin/vouchers.php @@ -96,6 +96,25 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[ 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"); $siteStats = []; foreach ($sites as $site) { @@ -383,7 +402,10 @@ function renderVouchers() { ${statusBadge}
${v.used}/${v.quota>0?v.quota:'∞'}${v.quota>0?`
`:''}
${remaining?` ${remaining}
`:''}${v.duration} Min. - + + + + `; }); @@ -407,6 +429,20 @@ function renderVouchers() { 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) { if (!confirm('Voucher wirklich löschen?')) return; const row = document.getElementById(`voucher-${voucherId}`); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..945e17b --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/includes/Mailer.php b/includes/Mailer.php index 9fe53de..866b2eb 100644 --- a/includes/Mailer.php +++ b/includes/Mailer.php @@ -31,12 +31,23 @@ class Mailer { } public function send($to, $subject, $body, $isHtml = false) { - if (!$this->smtpEnabled || empty($this->smtpHost)) { - // Fallback auf PHP mail() - return $this->sendWithPhpMail($to, $subject, $body); + // Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry). + $attempts = 2; + 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 + } } - - return $this->sendWithSmtp($to, $subject, $body, $isHtml); + error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen."); + return false; } private function sendWithPhpMail($to, $subject, $body) { diff --git a/index.php b/index.php index d5743be..e90b7fc 100644 --- a/index.php +++ b/index.php @@ -46,6 +46,26 @@ function isVoucherRateLimited() { return false; } +/** + * 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'); $logoUrl = $db->getSetting('logo_url', ''); $instructionHeader = $db->getSetting('instruction_header', ''); @@ -146,6 +166,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) { 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 (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]); if (!$site) throw new Exception(__('error_site_not_found')); @@ -194,6 +215,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) { if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit])); if ($siteId <= 0) throw new Exception(__('error_site_req')); if ($auth->isLoggedIn() && !$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]); if (!$site) throw new Exception(__('error_site_not_found')); diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..818df76 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 5 + paths: + - includes/Totp.php + - includes/Crypto.php + - includes/ApiKey.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..c31cd71 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,11 @@ + + + + + tests + + + diff --git a/tests/ApiKeyTest.php b/tests/ApiKeyTest.php new file mode 100644 index 0000000..c7dbd73 --- /dev/null +++ b/tests/ApiKeyTest.php @@ -0,0 +1,35 @@ +assertStringStartsWith('uvt_', $k['plain']); + $this->assertSame(44, strlen($k['plain'])); + $this->assertSame(hash('sha256', $k['plain']), $k['hash']); + $this->assertSame(substr($k['plain'], 4, 8), $k['prefix']); + } + + public function testScopes(): void + { + $read = ['scope' => 'read']; + $write = ['scope' => 'write']; + $this->assertTrue(\ApiKey::hasScope($read, 'read')); + $this->assertFalse(\ApiKey::hasScope($read, 'write')); + $this->assertTrue(\ApiKey::hasScope($write, 'read')); + $this->assertTrue(\ApiKey::hasScope($write, 'write')); + } + + public function testFromRequestBearer(): void + { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer uvt_testkey123'; + $this->assertSame('uvt_testkey123', \ApiKey::fromRequest()); + unset($_SERVER['HTTP_AUTHORIZATION']); + } +} diff --git a/tests/CryptoTest.php b/tests/CryptoTest.php new file mode 100644 index 0000000..195d79b --- /dev/null +++ b/tests/CryptoTest.php @@ -0,0 +1,39 @@ +assertNotSame($plain, $cipher); + $this->assertTrue(\Crypto::isEncrypted($cipher)); + $this->assertSame($plain, \Crypto::decrypt($cipher)); + } + + public function testPlaintextPassthrough(): void + { + // Legacy-/Klartextwerte werden unverändert zurückgegeben. + $this->assertSame('altesKlartextPW', \Crypto::decrypt('altesKlartextPW')); + } + + public function testEmptyValues(): void + { + $this->assertSame('', \Crypto::encrypt('')); + $this->assertNull(\Crypto::decrypt(null)); + } + + public function testGenerateKeyLength(): void + { + $key = \Crypto::generateKey(); + $this->assertSame(32, strlen(base64_decode($key))); + } +} diff --git a/tests/MigrationSplitterTest.php b/tests/MigrationSplitterTest.php new file mode 100644 index 0000000..c3129ff --- /dev/null +++ b/tests/MigrationSplitterTest.php @@ -0,0 +1,42 @@ +newInstanceWithoutConstructor(); + $m = $rc->getMethod('splitStatements'); + $m->setAccessible(true); + $parts = $m->invoke($inst, $sql); + return array_values(array_filter(array_map('trim', $parts), fn($s) => $s !== '')); + } + + public function testIgnoresSemicolonsInStringsAndComments(): void + { + $sql = "INSERT INTO t (a) VALUES (\";semi;colon\"); -- comment; not split\n" + . "CREATE TABLE x (id INT); /* block ; comment */ INSERT INTO y VALUES (1);"; + $parts = $this->split($sql); + $this->assertCount(3, $parts); + } + + public function testSingleStatement(): void + { + $parts = $this->split("ALTER TABLE users ADD COLUMN foo INT"); + $this->assertCount(1, $parts); + } + + public function testIgnorableErrorDetection(): void + { + $rc = new \ReflectionClass(\Updater\MigrationRunner::class); + $inst = $rc->newInstanceWithoutConstructor(); + $this->assertTrue($inst->isIgnorableSqlError('Duplicate column name "x"', 'mysql')); + $this->assertTrue($inst->isIgnorableSqlError('Table already exists', 'mysql')); + $this->assertFalse($inst->isIgnorableSqlError('Syntax error near FROM', 'mysql')); + } +} diff --git a/tests/TotpTest.php b/tests/TotpTest.php new file mode 100644 index 0000000..5185398 --- /dev/null +++ b/tests/TotpTest.php @@ -0,0 +1,46 @@ +assertSame('287082', \Totp::code($secret, intdiv(59, 30))); + $this->assertSame('081804', \Totp::code($secret, intdiv(1111111109, 30))); + $this->assertSame('005924', \Totp::code($secret, intdiv(1234567890, 30))); + } + + public function testVerifyAcceptsCurrentCode(): void + { + $secret = \Totp::generateSecret(); + $code = \Totp::code($secret); + $this->assertTrue(\Totp::verify($secret, $code)); + } + + public function testVerifyRejectsWrongCode(): void + { + $secret = \Totp::generateSecret(); + $wrong = \Totp::code($secret) === '000000' ? '111111' : '000000'; + $this->assertFalse(\Totp::verify($secret, $wrong)); + } + + public function testVerifyRejectsMalformed(): void + { + $secret = \Totp::generateSecret(); + $this->assertFalse(\Totp::verify($secret, 'abcdef')); + $this->assertFalse(\Totp::verify($secret, '12345')); + } + + public function testProvisioningUri(): void + { + $uri = \Totp::provisioningUri('ABC', 'user@example.com', 'My App'); + $this->assertStringStartsWith('otpauth://totp/', $uri); + $this->assertStringContainsString('secret=ABC', $uri); + } +}