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:
Claude 2026-06-05 19:39:28 +00:00
parent 51485810b4
commit eec28f77b8
No known key found for this signature in database
12 changed files with 458 additions and 16 deletions

View file

@ -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 => {