Merge origin/main: Review-Branch mit v2.4.0-Features zusammenführen
Konfliktauflösung kombiniert beide Seiten: - Auth: Secure-Cookie-Flag + DB-Session-Handler (main) - UniFiController: createVouchers (n-Parameter, 1 API-Call) + QoS-Optionen (main) - index.php: IP-Rate-Limit/PRG/Sticky-Forms + CAPTCHA/SMS/QoS/Tageslimit (main) - users.php: 2FA-Reset (main) auf POST+PRG umgestellt wie übrige Aktionen - forgot_password: Session-Throttle (main) + IP-Throttle kombiniert - Eigene Migrationen wegen Nummernkollision auf 0005/0006 umbenannt https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
This commit is contained in:
commit
1a2f86ed3d
65 changed files with 3136 additions and 32 deletions
103
includes/ApiKey.php
Normal file
103
includes/ApiKey.php
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?php
|
||||
/**
|
||||
* ApiKey – Erzeugung & Verifizierung von API-Schlüsseln für die REST-API.
|
||||
*
|
||||
* Schlüsselformat: uvt_<prefix(8)><secret(32)>
|
||||
* Gespeichert wird nur der SHA-256-Hash plus ein indexierbares Präfix – der
|
||||
* Klartext-Schlüssel wird dem Admin genau einmal bei der Erstellung gezeigt.
|
||||
*/
|
||||
class ApiKey {
|
||||
/** Neuen Schlüssel erzeugen. Gibt plain/prefix/hash zurück. */
|
||||
public static function generate() {
|
||||
$prefix = bin2hex(random_bytes(4)); // 8 Zeichen
|
||||
$secret = bin2hex(random_bytes(16)); // 32 Zeichen
|
||||
$plain = 'uvt_' . $prefix . $secret;
|
||||
return [
|
||||
'plain' => $plain,
|
||||
'prefix' => $prefix,
|
||||
'hash' => hash('sha256', $plain),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiziert einen Klartext-Schlüssel gegen die DB. Aktualisiert
|
||||
* last_used_at und gibt den Key-Datensatz zurück – oder false.
|
||||
*/
|
||||
public static function verify($plain, $db) {
|
||||
if (!is_string($plain) || strpos($plain, 'uvt_') !== 0 || strlen($plain) < 44) {
|
||||
return false;
|
||||
}
|
||||
$prefix = substr($plain, 4, 8);
|
||||
$hash = hash('sha256', $plain);
|
||||
try {
|
||||
$row = $db->fetchOne(
|
||||
"SELECT * FROM api_keys WHERE key_prefix = ? AND is_active = 1",
|
||||
[$prefix]
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
if (!$row || !hash_equals($row['key_hash'], $hash)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$db->query("UPDATE api_keys SET last_used_at = NOW() WHERE id = ?", [$row['id']]);
|
||||
} catch (\Exception $e) { /* ignore */ }
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed-Window-Rate-Limit pro Schlüssel (Anfragen/Minute). rate_limit = 0
|
||||
* bedeutet unbegrenzt. Gibt true zurück, wenn die Anfrage erlaubt ist.
|
||||
*/
|
||||
public static function checkRateLimit($row, $db) {
|
||||
$limit = (int)($row['rate_limit'] ?? 0);
|
||||
if ($limit <= 0) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
// alte Treffer (>60s) aufräumen
|
||||
$db->query("DELETE FROM api_key_hits WHERE api_key_id = ? AND hit_at < DATE_SUB(NOW(), INTERVAL 60 SECOND)", [$row['id']]);
|
||||
$cnt = $db->fetchOne("SELECT COUNT(*) AS c FROM api_key_hits WHERE api_key_id = ?", [$row['id']]);
|
||||
if ($cnt && (int)$cnt['c'] >= $limit) {
|
||||
return false;
|
||||
}
|
||||
$db->query("INSERT INTO api_key_hits (api_key_id) VALUES (?)", [$row['id']]);
|
||||
} catch (\Exception $e) {
|
||||
return true; // Bei Fehlern (z.B. Tabelle fehlt) nicht blockieren
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Prüft, ob der Schlüssel den geforderten Scope hat ('read' < 'write'). */
|
||||
public static function hasScope($row, $needed) {
|
||||
$scope = $row['scope'] ?? 'write';
|
||||
if ($needed === 'read') {
|
||||
return in_array($scope, ['read', 'write'], true);
|
||||
}
|
||||
return $scope === 'write';
|
||||
}
|
||||
|
||||
/** Liest den Schlüssel aus dem Request (Authorization: Bearer / X-API-Key). */
|
||||
public static function fromRequest() {
|
||||
$headers = [];
|
||||
if (function_exists('getallheaders')) {
|
||||
foreach (getallheaders() as $k => $v) {
|
||||
$headers[strtolower($k)] = $v;
|
||||
}
|
||||
}
|
||||
if (!empty($headers['authorization']) && preg_match('/Bearer\s+(\S+)/i', $headers['authorization'], $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
if (!empty($headers['x-api-key'])) {
|
||||
return trim($headers['x-api-key']);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_API_KEY'])) {
|
||||
return trim($_SERVER['HTTP_X_API_KEY']);
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/Bearer\s+(\S+)/i', $_SERVER['HTTP_AUTHORIZATION'], $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
<?php
|
||||
require_once __DIR__ . '/Totp.php';
|
||||
require_once __DIR__ . '/Notifier.php';
|
||||
require_once __DIR__ . '/Crypto.php';
|
||||
|
||||
class Auth {
|
||||
private $db;
|
||||
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
|
||||
|
|
@ -20,15 +24,49 @@ class Auth {
|
|||
ini_set('session.cookie_secure', 1);
|
||||
}
|
||||
|
||||
// Opt-in: Sessions in der DB ablegen (für "überall abmelden" / Skalierung)
|
||||
try {
|
||||
if ($this->db->getSetting('session_driver', 'php') === 'db') {
|
||||
require_once __DIR__ . '/DbSessionHandler.php';
|
||||
$ttl = defined('SESSION_LIFETIME') ? (int)SESSION_LIFETIME : 3600;
|
||||
session_set_save_handler(new DbSessionHandler($this->db, $ttl), true);
|
||||
}
|
||||
} catch (\Throwable $e) { /* Fallback: Standard-PHP-Sessions */ }
|
||||
|
||||
if (!session_start()) {
|
||||
die("Session konnte nicht gestartet werden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
public function login($email, $password) {
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
$ip = $this->clientIp();
|
||||
|
||||
if ($this->isRateLimited($ip, $email)) {
|
||||
return 'rate_limited';
|
||||
|
|
@ -41,6 +79,15 @@ class Auth {
|
|||
|
||||
if ($user && password_verify($password, $user['password_hash'])) {
|
||||
$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->notifyIfNewIp($user, $ip);
|
||||
$this->setUserSession($user);
|
||||
$this->updateLastLogin($user['id']);
|
||||
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
|
||||
|
|
@ -51,11 +98,135 @@ class Auth {
|
|||
return false;
|
||||
}
|
||||
|
||||
/** Webhook bei Login von einer für diesen Nutzer bisher unbekannten IP. */
|
||||
private function notifyIfNewIp($user, $ip) {
|
||||
try {
|
||||
$seen = $this->db->fetchOne(
|
||||
"SELECT 1 FROM audit_log WHERE user_id = ? AND action = 'user_login' AND ip_address = ? LIMIT 1",
|
||||
[$user['id'], $ip]
|
||||
);
|
||||
if (!$seen) {
|
||||
Notifier::loginNewIp($user['email'], $ip);
|
||||
}
|
||||
} catch (\Exception $e) { /* nie blockierend */ }
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
// Entweder gueltiger TOTP-Code ODER ein Recovery-/Backup-Code
|
||||
// (Secret wird verschluesselt gespeichert; Klartext-Fallback via decrypt)
|
||||
$ok = Totp::verify(Crypto::decrypt($user['totp_secret']), $code);
|
||||
if (!$ok && $this->consumeBackupCode($user, $code)) {
|
||||
$ok = true;
|
||||
$this->writeAuditLog($user['id'], 'user_login_backup_code', 'user', $user['id'], 'Login per Recovery-Code');
|
||||
}
|
||||
if (!$ok) {
|
||||
$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->notifyIfNewIp($user, $this->clientIp());
|
||||
$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 und Recovery-Codes erzeugen.
|
||||
* @return array Klartext-Recovery-Codes (nur hier einmalig verfuegbar)
|
||||
*/
|
||||
public function enableTotp($userId, $secret) {
|
||||
$codes = $this->generateBackupCodes();
|
||||
$hashes = array_map(function ($c) { return hash('sha256', $c); }, $codes);
|
||||
$this->db->query(
|
||||
"UPDATE users SET totp_secret = ?, totp_enabled = 1, totp_backup_codes = ? WHERE id = ?",
|
||||
[Crypto::encrypt($secret), json_encode($hashes), $userId]
|
||||
);
|
||||
$this->writeAuditLog($userId, 'totp_enabled', 'user', $userId, '2FA aktiviert');
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/** 2FA fuer einen Benutzer deaktivieren. */
|
||||
public function disableTotp($userId) {
|
||||
$this->db->query(
|
||||
"UPDATE users SET totp_secret = NULL, totp_enabled = 0, totp_backup_codes = NULL WHERE id = ?",
|
||||
[$userId]
|
||||
);
|
||||
$this->writeAuditLog($userId, 'totp_disabled', 'user', $userId, '2FA deaktiviert');
|
||||
}
|
||||
|
||||
/** Neue Recovery-Codes erzeugen (8 Stueck, Format XXXX-XXXX). */
|
||||
public function generateBackupCodes($count = 8) {
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$raw = strtoupper(bin2hex(random_bytes(4))); // 8 Hex-Zeichen
|
||||
$codes[] = substr($raw, 0, 4) . '-' . substr($raw, 4, 4);
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/** Recovery-Codes neu erzeugen und speichern; gibt Klartext zurueck. */
|
||||
public function regenerateBackupCodes($userId) {
|
||||
$codes = $this->generateBackupCodes();
|
||||
$hashes = array_map(function ($c) { return hash('sha256', $c); }, $codes);
|
||||
$this->db->query("UPDATE users SET totp_backup_codes = ? WHERE id = ?", [json_encode($hashes), $userId]);
|
||||
$this->writeAuditLog($userId, 'totp_backup_regenerated', 'user', $userId, 'Recovery-Codes neu erzeugt');
|
||||
return $codes;
|
||||
}
|
||||
|
||||
/** Anzahl noch nicht verbrauchter Recovery-Codes. */
|
||||
public function backupCodesRemaining($user) {
|
||||
$list = json_decode($user['totp_backup_codes'] ?? '[]', true);
|
||||
return is_array($list) ? count($list) : 0;
|
||||
}
|
||||
|
||||
/** Prueft & verbraucht einen Recovery-Code (konstante Zeit). */
|
||||
private function consumeBackupCode($user, $code) {
|
||||
$code = strtoupper(trim($code));
|
||||
$list = json_decode($user['totp_backup_codes'] ?? '[]', true);
|
||||
if (!is_array($list) || empty($list)) {
|
||||
return false;
|
||||
}
|
||||
$hash = hash('sha256', $code);
|
||||
$matched = false;
|
||||
$remaining = [];
|
||||
foreach ($list as $h) {
|
||||
if (!$matched && hash_equals($h, $hash)) {
|
||||
$matched = true; // diesen verbrauchen (nicht behalten)
|
||||
} else {
|
||||
$remaining[] = $h;
|
||||
}
|
||||
}
|
||||
if ($matched) {
|
||||
$this->db->query("UPDATE users SET totp_backup_codes = ? WHERE id = ?", [json_encode($remaining), $user['id']]);
|
||||
}
|
||||
return $matched;
|
||||
}
|
||||
|
||||
public function writeAuditLog($userId, $action, $entityType = null, $entityId = null, $details = null) {
|
||||
try {
|
||||
$this->db->execute(
|
||||
"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) {
|
||||
// audit_log table may not exist on old installs
|
||||
|
|
@ -297,8 +468,47 @@ class Auth {
|
|||
header('Location: /index.php?error=access_denied');
|
||||
exit;
|
||||
}
|
||||
// Optionale Richtlinie: 2FA fuer Admins erzwingen. Admins ohne aktives
|
||||
// 2FA werden zur Einrichtung umgeleitet (security.php nutzt requireLogin,
|
||||
// daher keine Endlosschleife).
|
||||
try {
|
||||
if ((string)$this->db->getSetting('enforce_2fa_admins', '0') === '1') {
|
||||
$user = $this->getCurrentUser();
|
||||
$script = basename($_SERVER['SCRIPT_NAME'] ?? '');
|
||||
if ($user && !empty($user['password_hash']) && empty($user['totp_enabled'])
|
||||
&& $script !== 'security.php') {
|
||||
header('Location: security.php?setup_required=1');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) { /* Richtlinie nie blockierend */ }
|
||||
}
|
||||
|
||||
/** Anzahl aktiver (nicht abgelaufener) DB-Sessions des aktuellen Nutzers. */
|
||||
public function activeSessionCount() {
|
||||
if (!$this->isLoggedIn()) return 0;
|
||||
try {
|
||||
$r = $this->db->fetchOne(
|
||||
"SELECT COUNT(*) c FROM sessions WHERE user_id = ? AND expires_at > NOW()",
|
||||
[$_SESSION['user_id']]
|
||||
);
|
||||
return (int)($r['c'] ?? 0);
|
||||
} catch (\Exception $e) { return 0; }
|
||||
}
|
||||
|
||||
/** Alle anderen Sessions des Nutzers beenden ("überall abmelden"). */
|
||||
public function logoutOtherSessions() {
|
||||
if (!$this->isLoggedIn()) return;
|
||||
try {
|
||||
$current = session_id();
|
||||
$this->db->query(
|
||||
"DELETE FROM sessions WHERE user_id = ? AND id != ?",
|
||||
[$_SESSION['user_id'], $current]
|
||||
);
|
||||
$this->writeAuditLog($_SESSION['user_id'], 'logout_other_sessions', 'user', $_SESSION['user_id'], 'Andere Sessions beendet');
|
||||
} catch (\Exception $e) { /* nur bei DB-Sessions wirksam */ }
|
||||
}
|
||||
|
||||
// Login erforderlich
|
||||
public function requireLogin() {
|
||||
if (!$this->isLoggedIn()) {
|
||||
|
|
|
|||
56
includes/Captcha.php
Normal file
56
includes/Captcha.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
/**
|
||||
* Captcha – Schutz der öffentlichen (anonymen) Voucher-Erstellung.
|
||||
*
|
||||
* Modi (Setting captcha_mode):
|
||||
* 'off' – deaktiviert
|
||||
* 'math' – selbst-enthaltenes Rechen-Captcha (keine externen Dienste)
|
||||
* 'hcaptcha' – hCaptcha (Setting captcha_site_key / captcha_secret)
|
||||
*
|
||||
* Reine PHP-Standardlib + cURL (für hCaptcha-Verifizierung).
|
||||
*/
|
||||
class Captcha {
|
||||
public static function mode($db) {
|
||||
$m = (string)$db->getSetting('captcha_mode', 'off');
|
||||
return in_array($m, ['off', 'math', 'hcaptcha'], true) ? $m : 'off';
|
||||
}
|
||||
|
||||
/** Frage für das Math-Captcha erzeugen und Antwort in Session hinterlegen. */
|
||||
public static function newMathChallenge() {
|
||||
$a = random_int(1, 9);
|
||||
$b = random_int(1, 9);
|
||||
$_SESSION['captcha_answer'] = (string)($a + $b);
|
||||
return "$a + $b";
|
||||
}
|
||||
|
||||
/** Prüft die Captcha-Antwort des aktuellen Requests. */
|
||||
public static function verify($db) {
|
||||
$mode = self::mode($db);
|
||||
if ($mode === 'off') {
|
||||
return true;
|
||||
}
|
||||
if ($mode === 'math') {
|
||||
$expected = $_SESSION['captcha_answer'] ?? null;
|
||||
unset($_SESSION['captcha_answer']); // einmalig
|
||||
$given = trim((string)($_POST['captcha'] ?? ''));
|
||||
return $expected !== null && hash_equals((string)$expected, $given);
|
||||
}
|
||||
if ($mode === 'hcaptcha') {
|
||||
$resp = $_POST['h-captcha-response'] ?? '';
|
||||
if ($resp === '') return false;
|
||||
$secret = (string)$db->getSetting('captcha_secret', '');
|
||||
$ch = curl_init('https://hcaptcha.com/siteverify');
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => http_build_query(['secret' => $secret, 'response' => $resp]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 8,
|
||||
]);
|
||||
$out = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
$data = json_decode((string)$out, true);
|
||||
return is_array($data) && !empty($data['success']);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
75
includes/DbSessionHandler.php
Normal file
75
includes/DbSessionHandler.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
/**
|
||||
* DbSessionHandler – speichert PHP-Sessions in der `sessions`-Tabelle.
|
||||
*
|
||||
* Opt-in über Setting `session_driver = db` (Standard 'php' = unverändert).
|
||||
* Ermöglicht "überall abmelden" und eine Übersicht aktiver Sessions.
|
||||
*
|
||||
* Alle DB-Operationen sind fehlertolerant gekapselt – schlägt etwas fehl,
|
||||
* degradiert die Session still, statt die Anwendung lahmzulegen.
|
||||
*/
|
||||
class DbSessionHandler implements SessionHandlerInterface
|
||||
{
|
||||
private $db;
|
||||
private $ttl;
|
||||
|
||||
public function __construct($db, $ttl = 3600)
|
||||
{
|
||||
$this->db = $db;
|
||||
$this->ttl = max(300, (int)$ttl);
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function open($path, $name) { return true; }
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function close() { return true; }
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function read($id)
|
||||
{
|
||||
try {
|
||||
$row = $this->db->fetchOne(
|
||||
"SELECT data FROM sessions WHERE id = ? AND expires_at > NOW()", [$id]
|
||||
);
|
||||
return $row && $row['data'] !== null ? (string)$row['data'] : '';
|
||||
} catch (\Throwable $e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function write($id, $data)
|
||||
{
|
||||
try {
|
||||
$uid = isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
|
||||
$expires = date('Y-m-d H:i:s', time() + $this->ttl);
|
||||
$this->db->query(
|
||||
"INSERT INTO sessions (id, user_id, data, expires_at) VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), data = VALUES(data), expires_at = VALUES(expires_at)",
|
||||
[$id, $uid, $data, $expires]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// still ignorieren
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function destroy($id)
|
||||
{
|
||||
try {
|
||||
$this->db->query("DELETE FROM sessions WHERE id = ?", [$id]);
|
||||
} catch (\Throwable $e) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
#[\ReturnTypeWillChange]
|
||||
public function gc($max_lifetime)
|
||||
{
|
||||
try {
|
||||
$this->db->query("DELETE FROM sessions WHERE expires_at < NOW()");
|
||||
} catch (\Throwable $e) {}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -33,12 +33,23 @@ class Mailer {
|
|||
}
|
||||
|
||||
public function send($to, $subject, $body, $isHtml = false) {
|
||||
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
||||
// Fallback auf PHP mail()
|
||||
return $this->sendWithPhpMail($to, $subject, $body);
|
||||
// Bis zu 2 Versuche bei vorübergehenden Zustellfehlern (Retry).
|
||||
$attempts = 2;
|
||||
for ($i = 1; $i <= $attempts; $i++) {
|
||||
if (!$this->smtpEnabled || empty($this->smtpHost)) {
|
||||
$ok = $this->sendWithPhpMail($to, $subject, $body);
|
||||
} else {
|
||||
$ok = $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
||||
}
|
||||
if ($ok) {
|
||||
return true;
|
||||
}
|
||||
if ($i < $attempts) {
|
||||
usleep(500000); // 0,5s vor erneutem Versuch
|
||||
}
|
||||
}
|
||||
|
||||
return $this->sendWithSmtp($to, $subject, $body, $isHtml);
|
||||
error_log("Mailer: Zustellung an {$to} nach {$attempts} Versuchen fehlgeschlagen.");
|
||||
return false;
|
||||
}
|
||||
|
||||
private function sendWithPhpMail($to, $subject, $body) {
|
||||
|
|
|
|||
71
includes/Notifier.php
Normal file
71
includes/Notifier.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
/**
|
||||
* Notifier – sendet Ereignis-Benachrichtigungen an einen konfigurierten
|
||||
* Webhook (Slack / Microsoft Teams / generischer JSON-Endpunkt).
|
||||
*
|
||||
* Gesteuert über Einstellungen:
|
||||
* webhook_enabled (0/1), webhook_url
|
||||
*
|
||||
* Sendet ein Slack/Teams-kompatibles { "text": "..." }-Payload plus ein
|
||||
* strukturiertes "event"-Feld. Fehler werden still ignoriert – eine
|
||||
* Benachrichtigung darf nie den eigentlichen Vorgang blockieren.
|
||||
*/
|
||||
class Notifier {
|
||||
/** Generisches Event senden. */
|
||||
public static function send($text, array $data = []) {
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
if ((string)$db->getSetting('webhook_enabled', '0') !== '1') {
|
||||
return;
|
||||
}
|
||||
$url = trim((string)$db->getSetting('webhook_url', ''));
|
||||
if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = json_encode(array_merge(['text' => $text], $data ? ['event' => $data] : []));
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
]);
|
||||
@curl_exec($ch);
|
||||
curl_close($ch);
|
||||
}
|
||||
|
||||
/** Controller nicht erreichbar (z.B. beim Sync). */
|
||||
public static function controllerUnreachable($siteName, $detail = '') {
|
||||
self::send("⚠️ UniFi-Controller für \"{$siteName}\" nicht erreichbar." . ($detail ? " ({$detail})" : ''),
|
||||
['type' => 'controller_unreachable', 'site' => $siteName, 'detail' => $detail]);
|
||||
}
|
||||
|
||||
/** Anmeldung von einer bisher unbekannten IP. */
|
||||
public static function loginNewIp($email, $ip) {
|
||||
self::send("🔐 Neue Anmeldung für {$email} von IP {$ip}.",
|
||||
['type' => 'login_new_ip', 'email' => $email, 'ip' => $ip]);
|
||||
}
|
||||
|
||||
/** Update verfügbar. */
|
||||
public static function updateAvailable($sha) {
|
||||
self::send("⬆️ Update verfügbar (" . substr((string)$sha, 0, 7) . "). Siehe Administration → System-Update.",
|
||||
['type' => 'update_available', 'sha' => $sha]);
|
||||
}
|
||||
|
||||
/** Bequemer Helfer für erstellte Voucher. */
|
||||
public static function voucherCreated($count, $siteName, $byUser = null) {
|
||||
$who = $byUser ? " von {$byUser}" : '';
|
||||
$what = $count > 1 ? "{$count} Voucher" : 'Ein Voucher';
|
||||
self::send(
|
||||
"🎫 {$what} für \"{$siteName}\"{$who} erstellt.",
|
||||
['type' => 'voucher_created', 'count' => $count, 'site' => $siteName, 'user' => $byUser]
|
||||
);
|
||||
}
|
||||
}
|
||||
42
includes/Sms.php
Normal file
42
includes/Sms.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
/**
|
||||
* Sms – Versand von Voucher-Codes per SMS über Twilio.
|
||||
*
|
||||
* Settings: sms_enabled (0/1), twilio_sid, twilio_token, twilio_from
|
||||
* Reine PHP-Standardlib + cURL. Fehler werden geloggt, nie geworfen.
|
||||
*/
|
||||
class Sms {
|
||||
public static function enabled($db) {
|
||||
return (string)$db->getSetting('sms_enabled', '0') === '1'
|
||||
&& $db->getSetting('twilio_sid', '') !== ''
|
||||
&& $db->getSetting('twilio_token', '') !== ''
|
||||
&& $db->getSetting('twilio_from', '') !== '';
|
||||
}
|
||||
|
||||
/** Sendet eine SMS. Gibt true bei Erfolg zurück. */
|
||||
public static function send($db, $to, $text) {
|
||||
if (!self::enabled($db)) {
|
||||
return false;
|
||||
}
|
||||
$sid = (string)$db->getSetting('twilio_sid', '');
|
||||
$token = (string)$db->getSetting('twilio_token', '');
|
||||
$from = (string)$db->getSetting('twilio_from', '');
|
||||
|
||||
$ch = curl_init("https://api.twilio.com/2010-04-01/Accounts/" . rawurlencode($sid) . "/Messages.json");
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_USERPWD => $sid . ':' . $token,
|
||||
CURLOPT_POSTFIELDS => http_build_query(['From' => $from, 'To' => $to, 'Body' => $text]),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 10,
|
||||
]);
|
||||
$out = curl_exec($ch);
|
||||
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
if ($code >= 200 && $code < 300) {
|
||||
return true;
|
||||
}
|
||||
error_log("Sms(Twilio) Fehler HTTP $code: " . substr((string)$out, 0, 200));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -161,8 +161,9 @@ class UniFiController {
|
|||
}
|
||||
|
||||
// Einzelnen Voucher erstellen
|
||||
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480) {
|
||||
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1);
|
||||
// $options: optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||
public function createVoucher($voucherName, $maxUses, $expireMinutes = 480, $options = []) {
|
||||
$vouchers = $this->createVouchers($voucherName, $maxUses, $expireMinutes, 1, $options);
|
||||
return $vouchers[0];
|
||||
}
|
||||
|
||||
|
|
@ -175,9 +176,10 @@ class UniFiController {
|
|||
* identifiziert. Der fruehere Fallback "global neuester Voucher" konnte
|
||||
* bei parallelen Erstellungen fremde Codes liefern und wurde entfernt.
|
||||
*
|
||||
* @param array $options Optionale QoS-Limits ['down' => kbps, 'up' => kbps, 'quota_mb' => MB]
|
||||
* @return array Liste von ['code','formatted_code','unifi_id','create_time']
|
||||
*/
|
||||
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1) {
|
||||
public function createVouchers($voucherName, $maxUses, $expireMinutes = 480, $count = 1, $options = []) {
|
||||
$count = max(1, (int)$count);
|
||||
$data = [
|
||||
'cmd' => 'create-voucher',
|
||||
|
|
@ -187,6 +189,17 @@ class UniFiController {
|
|||
'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);
|
||||
|
||||
if (!isset($response['data'][0]['create_time'])) {
|
||||
|
|
|
|||
|
|
@ -110,9 +110,27 @@ $lang = I18n::getLanguage();
|
|||
<li><a href="<?= $adminBase ?? '' ?>audit_log.php" class="<?= $currentPage === 'audit_log' ? 'active' : '' ?>">
|
||||
<i class="fas fa-history"></i> <?= __('nav_audit_log') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>reports.php" class="<?= $currentPage === 'reports' ? 'active' : '' ?>">
|
||||
<i class="fas fa-chart-line"></i> <?= __('nav_reports') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>import.php" class="<?= $currentPage === 'import' ? 'active' : '' ?>">
|
||||
<i class="fas fa-file-import"></i> <?= __('nav_import') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>settings.php" class="<?= $currentPage === 'settings' ? 'active' : '' ?>">
|
||||
<i class="fas fa-cog"></i> <?= __('nav_settings') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>api_keys.php" class="<?= $currentPage === 'api_keys' ? 'active' : '' ?>">
|
||||
<i class="fas fa-key"></i> <?= __('nav_api_keys') ?>
|
||||
</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 ?? '' ?>integrations.php" class="<?= $currentPage === 'integrations' ? 'active' : '' ?>">
|
||||
<i class="fas fa-plug"></i> <?= __('nav_integrations') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>backup.php" class="<?= $currentPage === 'backup' ? 'active' : '' ?>">
|
||||
<i class="fas fa-database"></i> <?= __('nav_backup') ?>
|
||||
</a></li>
|
||||
<li><a href="<?= $adminBase ?? '' ?>update.php" class="<?= $currentPage === 'update' ? 'active' : '' ?>">
|
||||
<i class="fas fa-sync-alt"></i> <?= __('nav_update') ?>
|
||||
</a></li>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue