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

@ -92,7 +92,7 @@ if ($auth->isLoggedIn()) {
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
// 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');
$fullName = $datum . '_' . $voucherName;
$controller = new UniFiController(
@ -101,7 +101,7 @@ function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $us
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes);
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
throw new Exception(__('error_voucher_invalid'));
}
@ -150,7 +150,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
if (!$site) throw new Exception(__('error_site_not_found'));
$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'];
$voucherCreated = true;
@ -192,9 +197,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
if (!$site) throw new Exception(__('error_site_not_found'));
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : 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)),
];
for ($i = 0; $i < $bulkCount; $i++) {
$bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId);
$bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId, $qos);
}
$bulkCreated = true;
@ -452,6 +462,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<option value="<?= (int)$tpl['id'] ?>"
data-max-uses="<?= (int)$tpl['max_uses'] ?>"
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'] ?? '') ?>">
<?= htmlspecialchars($tpl['name']) ?>
<?= (int)$tpl['max_uses'] ?> <?= __('label_devices') ?>,
@ -469,6 +482,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<input type="hidden" name="create_voucher" value="1">
<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="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">
<div class="form-group">
<label for="voucher_name"><?= __('voucher_name_label') ?></label>
@ -526,6 +542,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
<input type="hidden" name="create_bulk" value="1">
<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="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">
<div class="form-group">
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
@ -631,6 +650,13 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
const bmuEl = document.getElementById('bulk_max_uses');
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');
if (descEl) descEl.textContent = opt.dataset.desc || '';
}