Erweiterte Features (1/2): Trusted-Proxy-IP, Bandbreitenlimits, 2FA
- Schema: updater/migrations/0002 + database.sql (users.totp_*, voucher_templates qos_*, neue Tabelle api_keys) - Trusted-Proxy-IP: Auth::clientIp() wertet X-Forwarded-For nur hinter konfiguriertem trusted_proxy aus (korrektes Rate-Limit/Audit hinter Proxy) - Bandbreiten-/Datenlimits: UniFiController::createVoucher akzeptiert QoS (down/up kbit/s, Datenkontingent MB); Voucher-Profile speichern Limits, Voucher-Formular reicht sie via Template-Quick-Select durch - 2FA (TOTP, RFC 6238): includes/Totp.php (gegen RFC-Testvektoren verifiziert), zweistufiger Login, admin/security.php zum Aktivieren/Deaktivieren mit QR, Nav-Link + i18n
This commit is contained in:
parent
51485810b4
commit
eec28f77b8
12 changed files with 458 additions and 16 deletions
137
admin/security.php
Normal file
137
admin/security.php
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<?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 = '';
|
||||
$hasPassword = !empty($user['password_hash']);
|
||||
$totpEnabled = !empty($user['totp_enabled']);
|
||||
|
||||
// 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 {
|
||||
$auth->enableTotp($user['id'], $secret);
|
||||
unset($_SESSION['totp_setup_secret']);
|
||||
$totpEnabled = true;
|
||||
$success = 'Zwei-Faktor-Authentifizierung wurde aktiviert.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
?>
|
||||
<!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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🔐 Zwei-Faktor-Authentifizierung</h1>
|
||||
<p class="sub">Konto: <?= htmlspecialchars($user['email']) ?></p>
|
||||
|
||||
<?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 (!$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.</p>
|
||||
<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; ?>
|
||||
|
||||
<a class="back" href="../index.php">← Zurück</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -29,13 +29,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_template'])) {
|
|||
$expireMin = (int)($_POST['expire_minutes'] ?? 480);
|
||||
$description = trim($_POST['description'] ?? '');
|
||||
|
||||
$qosDown = max(0, (int)($_POST['qos_rate_max_down'] ?? 0)) ?: null;
|
||||
$qosUp = max(0, (int)($_POST['qos_rate_max_up'] ?? 0)) ?: null;
|
||||
$qosQuota = max(0, (int)($_POST['qos_usage_quota'] ?? 0)) ?: null;
|
||||
|
||||
if (empty($name)) throw new Exception(__('error_name_req'));
|
||||
if ($maxUses < 1) $maxUses = 1;
|
||||
if ($expireMin < 1) $expireMin = 60;
|
||||
|
||||
$db->execute(
|
||||
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, created_by) VALUES (?, ?, ?, ?, ?)",
|
||||
[$name, $maxUses, $expireMin, $description, $_SESSION['user_id']]
|
||||
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, qos_rate_max_down, qos_rate_max_up, qos_usage_quota, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
|
||||
);
|
||||
$success = __('templates_added');
|
||||
} catch (Exception $e) {
|
||||
|
|
@ -57,11 +61,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
|
|||
$description = trim($_POST['description'] ?? '');
|
||||
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
||||
|
||||
$qosDown = max(0, (int)($_POST['qos_rate_max_down'] ?? 0)) ?: null;
|
||||
$qosUp = max(0, (int)($_POST['qos_rate_max_up'] ?? 0)) ?: null;
|
||||
$qosQuota = max(0, (int)($_POST['qos_usage_quota'] ?? 0)) ?: null;
|
||||
|
||||
if (empty($name)) throw new Exception(__('error_name_req'));
|
||||
|
||||
$db->execute(
|
||||
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, is_active=? WHERE id=?",
|
||||
[$name, $maxUses, $expireMin, $description, $isActive, $id]
|
||||
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, qos_rate_max_down=?, qos_rate_max_up=?, qos_usage_quota=?, is_active=? WHERE id=?",
|
||||
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
|
||||
);
|
||||
$success = __('templates_updated');
|
||||
} catch (Exception $e) {
|
||||
|
|
@ -204,7 +212,7 @@ $adminBase = '';
|
|||
<?php endif; ?>
|
||||
</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>
|
||||
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||
onclick="return confirm('Profil wirklich löschen?')"
|
||||
|
|
@ -244,6 +252,11 @@ $adminBase = '';
|
|||
</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 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;">
|
||||
<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>
|
||||
|
|
@ -276,6 +289,11 @@ $adminBase = '';
|
|||
</div>
|
||||
</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;">
|
||||
<input type="checkbox" name="is_active" id="editActive">
|
||||
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
||||
|
|
@ -293,13 +311,16 @@ $adminBase = '';
|
|||
<script>
|
||||
function openAddModal() { document.getElementById('addModal').classList.add('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('editName').value = name;
|
||||
document.getElementById('editMaxUses').value = maxUses;
|
||||
document.getElementById('editExpireMin').value = expMin;
|
||||
document.getElementById('editDesc').value = desc;
|
||||
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');
|
||||
}
|
||||
['addModal','editModal'].forEach(id => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue