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);
|
$expireMin = (int)($_POST['expire_minutes'] ?? 480);
|
||||||
$description = trim($_POST['description'] ?? '');
|
$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 (empty($name)) throw new Exception(__('error_name_req'));
|
||||||
if ($maxUses < 1) $maxUses = 1;
|
if ($maxUses < 1) $maxUses = 1;
|
||||||
if ($expireMin < 1) $expireMin = 60;
|
if ($expireMin < 1) $expireMin = 60;
|
||||||
|
|
||||||
$db->execute(
|
$db->execute(
|
||||||
"INSERT INTO voucher_templates (name, max_uses, expire_minutes, description, created_by) VALUES (?, ?, ?, ?, ?)",
|
"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, $_SESSION['user_id']]
|
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $_SESSION['user_id']]
|
||||||
);
|
);
|
||||||
$success = __('templates_added');
|
$success = __('templates_added');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|
@ -57,11 +61,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_template'])) {
|
||||||
$description = trim($_POST['description'] ?? '');
|
$description = trim($_POST['description'] ?? '');
|
||||||
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
$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'));
|
if (empty($name)) throw new Exception(__('error_name_req'));
|
||||||
|
|
||||||
$db->execute(
|
$db->execute(
|
||||||
"UPDATE voucher_templates SET name=?, max_uses=?, expire_minutes=?, description=?, is_active=? WHERE 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, $isActive, $id]
|
[$name, $maxUses, $expireMin, $description, $qosDown, $qosUp, $qosQuota, $isActive, $id]
|
||||||
);
|
);
|
||||||
$success = __('templates_updated');
|
$success = __('templates_updated');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
|
@ -204,7 +212,7 @@ $adminBase = '';
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
class="btn btn-secondary btn-small"><i class="fas fa-edit"></i></button>
|
||||||
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
<a href="?delete=<?= $t['id'] ?>&token=<?= $auth->getCsrfToken() ?>"
|
||||||
onclick="return confirm('Profil wirklich löschen?')"
|
onclick="return confirm('Profil wirklich löschen?')"
|
||||||
|
|
@ -244,6 +252,11 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
</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 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;">
|
<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="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>
|
<button type="button" onclick="closeModal('addModal')" class="btn btn-secondary"><?= __('btn_cancel') ?></button>
|
||||||
|
|
@ -276,6 +289,11 @@ $adminBase = '';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label><?= __('templates_desc') ?></label><textarea name="description" id="editDesc" rows="2"></textarea></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;">
|
<div class="checkbox-group" style="margin-bottom:20px;">
|
||||||
<input type="checkbox" name="is_active" id="editActive">
|
<input type="checkbox" name="is_active" id="editActive">
|
||||||
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
<label for="editActive" style="margin:0;"><?= __('status_active') ?></label>
|
||||||
|
|
@ -293,13 +311,16 @@ $adminBase = '';
|
||||||
<script>
|
<script>
|
||||||
function openAddModal() { document.getElementById('addModal').classList.add('active'); }
|
function openAddModal() { document.getElementById('addModal').classList.add('active'); }
|
||||||
function closeModal(id) { document.getElementById(id).classList.remove('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('editId').value = id;
|
||||||
document.getElementById('editName').value = name;
|
document.getElementById('editName').value = name;
|
||||||
document.getElementById('editMaxUses').value = maxUses;
|
document.getElementById('editMaxUses').value = maxUses;
|
||||||
document.getElementById('editExpireMin').value = expMin;
|
document.getElementById('editExpireMin').value = expMin;
|
||||||
document.getElementById('editDesc').value = desc;
|
document.getElementById('editDesc').value = desc;
|
||||||
document.getElementById('editActive').checked = isActive == 1;
|
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');
|
document.getElementById('editModal').classList.add('active');
|
||||||
}
|
}
|
||||||
['addModal','editModal'].forEach(id => {
|
['addModal','editModal'].forEach(id => {
|
||||||
|
|
|
||||||
19
database.sql
19
database.sql
|
|
@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||||
`is_admin` TINYINT(1) DEFAULT 0,
|
`is_admin` TINYINT(1) DEFAULT 0,
|
||||||
`is_active` TINYINT(1) DEFAULT 1,
|
`is_active` TINYINT(1) DEFAULT 1,
|
||||||
`microsoft_id` VARCHAR(255) UNIQUE,
|
`microsoft_id` VARCHAR(255) UNIQUE,
|
||||||
|
`totp_secret` VARCHAR(64) NULL,
|
||||||
|
`totp_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
`last_login` TIMESTAMP NULL,
|
`last_login` TIMESTAMP NULL,
|
||||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
@ -53,6 +55,9 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
|
||||||
`max_uses` INT NOT NULL DEFAULT 1,
|
`max_uses` INT NOT NULL DEFAULT 1,
|
||||||
`expire_minutes` INT NOT NULL DEFAULT 480,
|
`expire_minutes` INT NOT NULL DEFAULT 480,
|
||||||
`description` VARCHAR(500),
|
`description` VARCHAR(500),
|
||||||
|
`qos_rate_max_down` INT NULL,
|
||||||
|
`qos_rate_max_up` INT NULL,
|
||||||
|
`qos_usage_quota` INT NULL,
|
||||||
`is_active` TINYINT(1) DEFAULT 1,
|
`is_active` TINYINT(1) DEFAULT 1,
|
||||||
`created_by` INT,
|
`created_by` INT,
|
||||||
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
@ -60,6 +65,20 @@ CREATE TABLE IF NOT EXISTS `voucher_templates` (
|
||||||
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `api_keys` (
|
||||||
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`key_prefix` VARCHAR(16) NOT NULL,
|
||||||
|
`key_hash` VARCHAR(255) NOT NULL,
|
||||||
|
`created_by` INT,
|
||||||
|
`last_used_at` TIMESTAMP NULL,
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL,
|
||||||
|
INDEX `idx_prefix` (`key_prefix`),
|
||||||
|
INDEX `idx_active` (`is_active`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `vouchers` (
|
CREATE TABLE IF NOT EXISTS `vouchers` (
|
||||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
`site_id` INT NOT NULL,
|
`site_id` INT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/Totp.php';
|
||||||
|
|
||||||
class Auth {
|
class Auth {
|
||||||
private $db;
|
private $db;
|
||||||
|
|
||||||
|
|
@ -21,9 +23,34 @@ class Auth {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ermittelt die echte Client-IP. Hinter einem konfigurierten Trusted-Proxy
|
||||||
|
* (Setting `trusted_proxy`, kommaseparierte IP-Liste) wird die erste IP aus
|
||||||
|
* X-Forwarded-For verwendet, sonst REMOTE_ADDR. Verhindert, dass ein
|
||||||
|
* Reverse-Proxy alle Clients als dieselbe IP erscheinen laesst (Rate-Limit).
|
||||||
|
*/
|
||||||
|
public function clientIp() {
|
||||||
|
$remote = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
try {
|
||||||
|
$trusted = (string)$this->db->getSetting('trusted_proxy', '');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$trusted = '';
|
||||||
|
}
|
||||||
|
if ($trusted === '' || empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
return $remote;
|
||||||
|
}
|
||||||
|
$trustedList = array_filter(array_map('trim', explode(',', $trusted)));
|
||||||
|
if (!in_array($remote, $trustedList, true)) {
|
||||||
|
return $remote; // Anfrage kam nicht vom Trusted-Proxy -> XFF ignorieren
|
||||||
|
}
|
||||||
|
$parts = array_filter(array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])));
|
||||||
|
$client = $parts[0] ?? $remote;
|
||||||
|
return filter_var($client, FILTER_VALIDATE_IP) ? $client : $remote;
|
||||||
|
}
|
||||||
|
|
||||||
// Benutzer einloggen
|
// Benutzer einloggen
|
||||||
public function login($email, $password) {
|
public function login($email, $password) {
|
||||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
$ip = $this->clientIp();
|
||||||
|
|
||||||
if ($this->isRateLimited($ip, $email)) {
|
if ($this->isRateLimited($ip, $email)) {
|
||||||
return 'rate_limited';
|
return 'rate_limited';
|
||||||
|
|
@ -36,6 +63,14 @@ class Auth {
|
||||||
|
|
||||||
if ($user && password_verify($password, $user['password_hash'])) {
|
if ($user && password_verify($password, $user['password_hash'])) {
|
||||||
$this->clearLoginAttempts($ip, $email);
|
$this->clearLoginAttempts($ip, $email);
|
||||||
|
|
||||||
|
// 2FA aktiv? Dann Login zunaechst nur "vormerken" und Code anfordern.
|
||||||
|
if (!empty($user['totp_enabled']) && !empty($user['totp_secret'])) {
|
||||||
|
$_SESSION['totp_pending_user_id'] = $user['id'];
|
||||||
|
$_SESSION['totp_pending_time'] = time();
|
||||||
|
return 'totp_required';
|
||||||
|
}
|
||||||
|
|
||||||
$this->setUserSession($user);
|
$this->setUserSession($user);
|
||||||
$this->updateLastLogin($user['id']);
|
$this->updateLastLogin($user['id']);
|
||||||
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
|
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
|
||||||
|
|
@ -46,11 +81,54 @@ class Auth {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Liegt ein Login vor, der noch auf den 2FA-Code wartet? */
|
||||||
|
public function isTotpPending() {
|
||||||
|
return isset($_SESSION['totp_pending_user_id'])
|
||||||
|
&& isset($_SESSION['totp_pending_time'])
|
||||||
|
&& (time() - (int)$_SESSION['totp_pending_time']) < 300; // 5 Min Fenster
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schliesst einen 2FA-Login mit dem eingegebenen Code ab. */
|
||||||
|
public function verifyTotpLogin($code) {
|
||||||
|
if (!$this->isTotpPending()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$user = $this->db->fetchOne(
|
||||||
|
"SELECT * FROM users WHERE id = ? AND is_active = 1",
|
||||||
|
[$_SESSION['totp_pending_user_id']]
|
||||||
|
);
|
||||||
|
if (!$user || empty($user['totp_secret'])) {
|
||||||
|
unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!Totp::verify($user['totp_secret'], $code)) {
|
||||||
|
$this->writeAuditLog($user['id'], 'user_login_2fa_failed', 'user', $user['id'], '2FA-Code falsch');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
unset($_SESSION['totp_pending_user_id'], $_SESSION['totp_pending_time']);
|
||||||
|
$this->setUserSession($user);
|
||||||
|
$this->updateLastLogin($user['id']);
|
||||||
|
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich (2FA)');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2FA fuer einen Benutzer aktivieren (nach erfolgreicher Code-Verifikation). */
|
||||||
|
public function enableTotp($userId, $secret) {
|
||||||
|
$this->db->query("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE id = ?", [$secret, $userId]);
|
||||||
|
$this->writeAuditLog($userId, 'totp_enabled', 'user', $userId, '2FA aktiviert');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2FA fuer einen Benutzer deaktivieren. */
|
||||||
|
public function disableTotp($userId) {
|
||||||
|
$this->db->query("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE id = ?", [$userId]);
|
||||||
|
$this->writeAuditLog($userId, 'totp_disabled', 'user', $userId, '2FA deaktiviert');
|
||||||
|
}
|
||||||
|
|
||||||
public function writeAuditLog($userId, $action, $entityType = null, $entityId = null, $details = null) {
|
public function writeAuditLog($userId, $action, $entityType = null, $entityId = null, $details = null) {
|
||||||
try {
|
try {
|
||||||
$this->db->execute(
|
$this->db->execute(
|
||||||
"INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO audit_log (user_id, action, entity_type, entity_id, details, ip_address) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
[$userId, $action, $entityType, $entityId !== null ? (string)$entityId : null, $details, $_SERVER['REMOTE_ADDR'] ?? '']
|
[$userId, $action, $entityType, $entityId !== null ? (string)$entityId : null, $details, $this->clientIp()]
|
||||||
);
|
);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// audit_log table may not exist on old installs
|
// audit_log table may not exist on old installs
|
||||||
|
|
|
||||||
83
includes/Totp.php
Normal file
83
includes/Totp.php
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Totp – minimaler TOTP-Generator/-Validator nach RFC 6238 (HMAC-SHA1,
|
||||||
|
* 6 Stellen, 30s-Zeitfenster). Reine PHP-Standardlib, keine Abhaengigkeiten.
|
||||||
|
* Kompatibel mit Google Authenticator, Authy, Microsoft Authenticator etc.
|
||||||
|
*/
|
||||||
|
class Totp {
|
||||||
|
private const DIGITS = 6;
|
||||||
|
private const PERIOD = 30;
|
||||||
|
private const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
|
||||||
|
/** Erzeugt ein neues Base32-Secret (Standard 16 Zeichen = 80 bit). */
|
||||||
|
public static function generateSecret($length = 16) {
|
||||||
|
$secret = '';
|
||||||
|
$bytes = random_bytes($length);
|
||||||
|
for ($i = 0; $i < $length; $i++) {
|
||||||
|
$secret .= self::BASE32[ord($bytes[$i]) & 31];
|
||||||
|
}
|
||||||
|
return $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aktueller Code fuer ein Secret. */
|
||||||
|
public static function code($secret, $timeSlice = null) {
|
||||||
|
if ($timeSlice === null) {
|
||||||
|
$timeSlice = (int) floor(time() / self::PERIOD);
|
||||||
|
}
|
||||||
|
$key = self::base32Decode($secret);
|
||||||
|
// 8-Byte Big-Endian Counter
|
||||||
|
$binTime = pack('N*', 0) . pack('N*', $timeSlice);
|
||||||
|
$hash = hash_hmac('sha1', $binTime, $key, true);
|
||||||
|
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
|
||||||
|
$part = substr($hash, $offset, 4);
|
||||||
|
$value = unpack('N', $part)[1] & 0x7FFFFFFF;
|
||||||
|
$mod = $value % (10 ** self::DIGITS);
|
||||||
|
return str_pad((string)$mod, self::DIGITS, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft einen Code mit Toleranzfenster (+/- $window Zeitschritte gegen
|
||||||
|
* Uhren-Drift). Konstante-Zeit-Vergleich gegen Timing-Angriffe.
|
||||||
|
*/
|
||||||
|
public static function verify($secret, $code, $window = 1) {
|
||||||
|
if (!preg_match('/^\d{6}$/', (string)$code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$current = (int) floor(time() / self::PERIOD);
|
||||||
|
for ($i = -$window; $i <= $window; $i++) {
|
||||||
|
if (hash_equals(self::code($secret, $current + $i), (string)$code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** otpauth://-URI fuer QR-Code-Provisionierung. */
|
||||||
|
public static function provisioningUri($secret, $accountName, $issuer) {
|
||||||
|
$label = rawurlencode($issuer . ':' . $accountName);
|
||||||
|
$params = http_build_query([
|
||||||
|
'secret' => $secret,
|
||||||
|
'issuer' => $issuer,
|
||||||
|
'algorithm' => 'SHA1',
|
||||||
|
'digits' => self::DIGITS,
|
||||||
|
'period' => self::PERIOD,
|
||||||
|
]);
|
||||||
|
return "otpauth://totp/$label?$params";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function base32Decode($b32) {
|
||||||
|
$b32 = strtoupper(rtrim($b32, '='));
|
||||||
|
$buffer = 0; $bitsLeft = 0; $out = '';
|
||||||
|
for ($i = 0; $i < strlen($b32); $i++) {
|
||||||
|
$val = strpos(self::BASE32, $b32[$i]);
|
||||||
|
if ($val === false) continue;
|
||||||
|
$buffer = ($buffer << 5) | $val;
|
||||||
|
$bitsLeft += 5;
|
||||||
|
if ($bitsLeft >= 8) {
|
||||||
|
$bitsLeft -= 8;
|
||||||
|
$out .= chr(($buffer >> $bitsLeft) & 0xFF);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -156,7 +156,8 @@ class UniFiController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voucher erstellen
|
// Voucher erstellen
|
||||||
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480) {
|
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||||
|
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) {
|
||||||
$data = [
|
$data = [
|
||||||
'cmd' => 'create-voucher',
|
'cmd' => 'create-voucher',
|
||||||
'expire' => (int)$expireMinutes,
|
'expire' => (int)$expireMinutes,
|
||||||
|
|
@ -165,6 +166,17 @@ class UniFiController {
|
||||||
'quota' => (int)$maxUses
|
'quota' => (int)$maxUses
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Bandbreiten-/Datenlimits (UniFi QoS) optional setzen
|
||||||
|
$down = isset($options['down']) ? (int)$options['down'] : 0;
|
||||||
|
$up = isset($options['up']) ? (int)$options['up'] : 0;
|
||||||
|
$bytes = isset($options['quota_mb']) ? (int)$options['quota_mb'] : 0;
|
||||||
|
if ($down > 0 || $up > 0 || $bytes > 0) {
|
||||||
|
$data['qos_overwrite'] = true;
|
||||||
|
if ($down > 0) $data['down'] = $down; // kbit/s
|
||||||
|
if ($up > 0) $data['up'] = $up; // kbit/s
|
||||||
|
if ($bytes > 0) $data['bytes'] = $bytes; // Megabyte
|
||||||
|
}
|
||||||
|
|
||||||
$response = $this->apiRequest("/proxy/network/api/s/{$this->siteId}/cmd/hotspot", $data);
|
$response = $this->apiRequest("/proxy/network/api/s/{$this->siteId}/cmd/hotspot", $data);
|
||||||
|
|
||||||
if (!isset($response['data'][0]['create_time'])) {
|
if (!isset($response['data'][0]['create_time'])) {
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,9 @@ $lang = I18n::getLanguage();
|
||||||
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
|
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
|
||||||
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
|
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
<li><a href="<?= $adminBase ?? '' ?>security.php" class="<?= $currentPage === 'security' ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-user-shield"></i> <?= __('nav_security') ?>
|
||||||
|
</a></li>
|
||||||
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
|
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
|
||||||
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
|
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
|
||||||
34
index.php
34
index.php
|
|
@ -92,7 +92,7 @@ if ($auth->isLoggedIn()) {
|
||||||
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
|
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
|
||||||
|
|
||||||
// Helper: create one voucher and save to DB
|
// 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');
|
$datum = date('Y-m-d');
|
||||||
$fullName = $datum . '_' . $voucherName;
|
$fullName = $datum . '_' . $voucherName;
|
||||||
$controller = new UniFiController(
|
$controller = new UniFiController(
|
||||||
|
|
@ -101,7 +101,7 @@ function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $us
|
||||||
Crypto::decrypt($site['unifi_password']),
|
Crypto::decrypt($site['unifi_password']),
|
||||||
$site['site_id']
|
$site['site_id']
|
||||||
);
|
);
|
||||||
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes);
|
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
|
||||||
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
|
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
|
||||||
throw new Exception(__('error_voucher_invalid'));
|
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'));
|
if (!$site) throw new Exception(__('error_site_not_found'));
|
||||||
|
|
||||||
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null;
|
$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'];
|
$voucherCode = $voucherData['code'];
|
||||||
$voucherCreated = true;
|
$voucherCreated = true;
|
||||||
|
|
||||||
|
|
@ -192,9 +197,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
|
||||||
if (!$site) throw new Exception(__('error_site_not_found'));
|
if (!$site) throw new Exception(__('error_site_not_found'));
|
||||||
|
|
||||||
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null;
|
$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++) {
|
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;
|
$bulkCreated = true;
|
||||||
|
|
@ -452,6 +462,9 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
<option value="<?= (int)$tpl['id'] ?>"
|
<option value="<?= (int)$tpl['id'] ?>"
|
||||||
data-max-uses="<?= (int)$tpl['max_uses'] ?>"
|
data-max-uses="<?= (int)$tpl['max_uses'] ?>"
|
||||||
data-expire="<?= (int)$tpl['expire_minutes'] ?>"
|
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'] ?? '') ?>">
|
data-desc="<?= htmlspecialchars($tpl['description'] ?? '') ?>">
|
||||||
<?= htmlspecialchars($tpl['name']) ?> –
|
<?= htmlspecialchars($tpl['name']) ?> –
|
||||||
<?= (int)$tpl['max_uses'] ?> <?= __('label_devices') ?>,
|
<?= (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="create_voucher" value="1">
|
||||||
<input type="hidden" name="expire_minutes" id="expire_minutes" value="<?= $defaultExpire ?>">
|
<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="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">
|
<div class="form-group">
|
||||||
<label for="voucher_name"><?= __('voucher_name_label') ?></label>
|
<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="create_bulk" value="1">
|
||||||
<input type="hidden" name="expire_minutes" id="bulk_expire_minutes" value="<?= $defaultExpire ?>">
|
<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="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">
|
<div class="form-group">
|
||||||
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
|
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
|
||||||
|
|
@ -631,6 +650,13 @@ function buildPrintCard($template, $data, $instructionHeader, $instructionText,
|
||||||
const bmuEl = document.getElementById('bulk_max_uses');
|
const bmuEl = document.getElementById('bulk_max_uses');
|
||||||
if (bmuEl) bmuEl.value = maxUses;
|
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');
|
const descEl = document.getElementById('template_desc');
|
||||||
if (descEl) descEl.textContent = opt.dataset.desc || '';
|
if (descEl) descEl.textContent = opt.dataset.desc || '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ return [
|
||||||
'nav_templates' => 'Voucher-Profile',
|
'nav_templates' => 'Voucher-Profile',
|
||||||
'nav_audit_log' => 'Audit-Log',
|
'nav_audit_log' => 'Audit-Log',
|
||||||
'nav_settings' => 'Einstellungen',
|
'nav_settings' => 'Einstellungen',
|
||||||
|
'nav_security' => 'Sicherheit (2FA)',
|
||||||
'nav_update' => 'System-Update',
|
'nav_update' => 'System-Update',
|
||||||
'nav_back' => 'Zurück zur Startseite',
|
'nav_back' => 'Zurück zur Startseite',
|
||||||
'nav_administration'=> 'Administration',
|
'nav_administration'=> 'Administration',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ return [
|
||||||
'nav_templates' => 'Voucher Profiles',
|
'nav_templates' => 'Voucher Profiles',
|
||||||
'nav_audit_log' => 'Audit Log',
|
'nav_audit_log' => 'Audit Log',
|
||||||
'nav_settings' => 'Settings',
|
'nav_settings' => 'Settings',
|
||||||
|
'nav_security' => 'Security (2FA)',
|
||||||
'nav_update' => 'System Update',
|
'nav_update' => 'System Update',
|
||||||
'nav_back' => 'Back to Home',
|
'nav_back' => 'Back to Home',
|
||||||
'nav_administration'=> 'Administration',
|
'nav_administration'=> 'Administration',
|
||||||
|
|
|
||||||
38
login.php
38
login.php
|
|
@ -19,8 +19,21 @@ I18n::init();
|
||||||
|
|
||||||
$error = '';
|
$error = '';
|
||||||
$success = '';
|
$success = '';
|
||||||
|
$show2fa = false;
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['totp_code'])) {
|
||||||
|
// Zweiter Login-Schritt: 2FA-Code
|
||||||
|
try {
|
||||||
|
if ($auth->verifyTotpLogin(trim($_POST['totp_code']))) {
|
||||||
|
header('Location: index.php');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$error = 'Code ungültig oder abgelaufen. Bitte erneut versuchen.';
|
||||||
|
$show2fa = $auth->isTotpPending();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$error = 'Login-Fehler: ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
try {
|
try {
|
||||||
$email = trim($_POST['email'] ?? '');
|
$email = trim($_POST['email'] ?? '');
|
||||||
$password = $_POST['password'] ?? '';
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
@ -32,6 +45,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
if ($result === true) {
|
if ($result === true) {
|
||||||
header('Location: index.php');
|
header('Location: index.php');
|
||||||
exit;
|
exit;
|
||||||
|
} elseif ($result === 'totp_required') {
|
||||||
|
$show2fa = true;
|
||||||
} elseif ($result === 'rate_limited') {
|
} elseif ($result === 'rate_limited') {
|
||||||
$error = __('login_error_rate');
|
$error = __('login_error_rate');
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -43,6 +58,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direkter Aufruf mit ?2fa=1 (z.B. nach Redirect) und noch ausstehendem Login
|
||||||
|
if (!$show2fa && isset($_GET['2fa']) && $auth->isTotpPending()) {
|
||||||
|
$show2fa = true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db = Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||||||
|
|
@ -146,7 +166,21 @@ try {
|
||||||
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
|
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($m365Enabled && !$showLocalLogin): ?>
|
<?php if ($show2fa): ?>
|
||||||
|
<form method="post">
|
||||||
|
<p style="color:var(--text-secondary,#666);font-size:14px;margin-bottom:18px;">
|
||||||
|
Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="totp_code">Authentifizierungs-Code</label>
|
||||||
|
<input type="text" id="totp_code" name="totp_code" inputmode="numeric" pattern="[0-9]*"
|
||||||
|
maxlength="6" autocomplete="one-time-code" required autofocus
|
||||||
|
style="letter-spacing:6px;text-align:center;font-size:20px;">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Bestätigen</button>
|
||||||
|
</form>
|
||||||
|
<a href="login.php" class="local-login-link">Abbrechen</a>
|
||||||
|
<?php elseif ($m365Enabled && !$showLocalLogin): ?>
|
||||||
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
|
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
|
||||||
<path fill="#f35325" d="M1 1h10v10H1z"/>
|
<path fill="#f35325" d="M1 1h10v10H1z"/>
|
||||||
|
|
|
||||||
27
updater/migrations/0002_extended_features.sql
Normal file
27
updater/migrations/0002_extended_features.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- Updater-Migration: Schema fuer erweiterte Funktionen
|
||||||
|
-- * 2FA (TOTP) fuer lokale Accounts
|
||||||
|
-- * Bandbreiten-/Datenlimits in Voucher-Profilen
|
||||||
|
-- * REST-API-Schluessel
|
||||||
|
-- Idempotent gehalten; "duplicate column"/"already exists" werden vom
|
||||||
|
-- MigrationRunner ignoriert.
|
||||||
|
|
||||||
|
ALTER TABLE `users` ADD COLUMN `totp_secret` VARCHAR(64) NULL;
|
||||||
|
ALTER TABLE `users` ADD COLUMN `totp_enabled` TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE `voucher_templates` ADD COLUMN `qos_rate_max_down` INT NULL;
|
||||||
|
ALTER TABLE `voucher_templates` ADD COLUMN `qos_rate_max_up` INT NULL;
|
||||||
|
ALTER TABLE `voucher_templates` ADD COLUMN `qos_usage_quota` INT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `api_keys` (
|
||||||
|
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(255) NOT NULL,
|
||||||
|
`key_prefix` VARCHAR(16) NOT NULL,
|
||||||
|
`key_hash` VARCHAR(255) NOT NULL,
|
||||||
|
`created_by` INT,
|
||||||
|
`last_used_at` TIMESTAMP NULL,
|
||||||
|
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE SET NULL,
|
||||||
|
INDEX `idx_prefix` (`key_prefix`),
|
||||||
|
INDEX `idx_active` (`is_active`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue