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

137
admin/security.php Normal file
View 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>

View file

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

View file

@ -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,

View file

@ -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
View 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;
}
}

View file

@ -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'])) {

View file

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

View file

@ -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 || '';
} }

View file

@ -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',

View file

@ -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',

View file

@ -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"/>

View 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;