Unifi-Voucher-Tool/admin/security.php
Friederich Loheide 6da46f040a Alle Frontend-Assets lokal ausliefern statt über CDNs
Inter, Font Awesome, Chart.js, qrcodejs und TinyMCE liegen jetzt unter
assets/vendor/ und werden vom eigenen Server ausgeliefert.

Warum:
- Datenschutz: bisher ging bei jedem Seitenaufruf die IP der Nutzer an
  Google Fonts, cdnjs, jsDelivr und Tiny Cloud
- Funktion: UniFi-Installationen stehen oft in abgeschotteten Netzen –
  dort fehlten bisher Schrift, Icons, Diagramme und Editor

Neu: includes/Ui.php
- Ui::head()/Ui::script() binden die Assets ein und hängen einen
  Versionsstempel an (?v=filemtime), damit Browser nach einem Update
  nicht das alte CSS aus dem Cache nehmen
- Ui::themeScript() setzt das Theme aus der gespeicherten Auswahl oder
  – wenn keine vorliegt – aus prefers-color-scheme; global.js folgt
  Systemwechseln live, solange nichts manuell gewählt wurde
- <meta name="color-scheme"> ergänzt, damit Formularelemente passen

Der TinyMCE-API-Key entfällt: der Editor läuft immer lokal (GPL-Variante),
inklusive deutscher Oberfläche, wenn die App auf Deutsch steht.

Neu: tools/demo/build.py – baut aus dem Projekt eine Demo-Instanz ohne
Datenbank (Stubs für Database/Auth, feste Beispieldaten). Damit lassen
sich Screenshots reproduzierbar erzeugen und alle Seiten einmal rendern
(Smoke-Test), ohne eine MySQL-Instanz aufzusetzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:23:44 +00:00

178 lines
7.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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/Ui.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): ?>
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
<?php endif; ?>
<?= Ui::head($db, '../') ?>
</head>
<body class="app-body focus-page">
<div class="focus-card card">
<div class="focus-head">
<span class="focus-icon"><i class="fas fa-shield-halved"></i></span>
<div>
<h1>Zwei-Faktor-Authentifizierung</h1>
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
</div>
</div>
<?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><i class="fas fa-key"></i> 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"><i class="fas fa-circle-minus"></i> 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"><i class="fas fa-circle-check"></i> 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 btn-lg btn-block">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 btn-lg btn-block">2FA deaktivieren</button>
</form>
<?php else: ?>
<div class="status off"><i class="fas fa-circle-minus"></i> 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" class="code-input" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" required placeholder="123456">
<button type="submit" name="enable_totp" class="btn btn-primary btn-lg btn-block" style="margin-top:14px;">2FA aktivieren</button>
</form>
<script>
new QRCode(document.getElementById('qrcode'), {
text: <?= json_encode($otpUri) ?>, width: 168, height: 168,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
</script>
<?php endif; ?>
<?php if ($dbSessions): ?>
<hr>
<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 btn-secondary btn-lg btn-block">Auf allen anderen Geräten abmelden</button>
</form>
<?php endif; ?>
<div class="auth-links"><a class="back-link" href="../index.php"><i class="fas fa-arrow-left"></i> Zurück</a></div>
</div>
</body>
</html>