Security: OAuth-state, Verschlüsselung, Session-Timeout & weitere Härtung

- m365_callback.php: OAuth-state-Validierung gegen Login-CSRF
- includes/Crypto.php: Verschlüsselung-at-rest für UniFi-Passwörter
  (AES-256-GCM/libsodium) mit Klartext-Fallback für Bestandsinstallationen
- install.php: APP_KEY-Generierung + Reinstall nur mit Admin-Session
- Auth.php: absolutes Session-Timeout (SESSION_LIFETIME) durchsetzen
- index.php: CSRF + Throttle auch für anonyme öffentliche Voucher-Erstellung
- UniFiController.php: createVoucher liefert nicht mehr den falschen Code
  bei parallelen Erstellungen (note-Match statt blindes reset())
- display_errors in allen Entry-Points deaktiviert, log_errors aktiviert
- test.php & m365_debug.php hinter requireAdmin() (Info-Leak)
- m365_debug.php: abgeschnittene/kaputte Datei vervollständigt
This commit is contained in:
Claude 2026-06-05 18:45:47 +00:00
parent bf3e55a967
commit 3483da274f
No known key found for this signature in database
17 changed files with 306 additions and 36 deletions

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
@ -37,7 +38,7 @@ if (isset($_GET['ajax_stats'])) {
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
$controller->syncVouchersToDatabase($db, $site['id']);

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
@ -44,7 +45,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_site'])) {
// Mit neuem Passwort aktualisieren
$db->execute(
"UPDATE sites SET name = ?, site_id = ?, unifi_controller_url = ?, unifi_username = ?, unifi_password = ?, public_access = ? WHERE id = ?",
[$name, $siteIdStr, $controllerUrl, $username, $password, $publicAccess, $siteId]
[$name, $siteIdStr, $controllerUrl, $username, Crypto::encrypt($password), $publicAccess, $siteId]
);
} else {
// Ohne Passwort-Änderung
@ -88,7 +89,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_site'])) {
$db->execute(
"INSERT INTO sites (name, site_id, unifi_controller_url, unifi_username, unifi_password, public_access)
VALUES (?, ?, ?, ?, ?, ?)",
[$name, $siteId, $controllerUrl, $username, $password, $publicAccess]
[$name, $siteId, $controllerUrl, $username, Crypto::encrypt($password), $publicAccess]
);
$success = 'Site erfolgreich hinzugefügt!';

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/../config.php';
require_once __DIR__ . '/../includes/Database.php';
@ -68,7 +69,7 @@ if (isset($_GET['ajax_get_vouchers']) && isset($_GET['site_id'])) {
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
$controller->syncVouchersToDatabase($db, $siteId);
@ -149,7 +150,7 @@ if (isset($_POST['ajax_delete']) && isset($_POST['voucher_id']) && isset($_POST[
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);

View file

@ -6,6 +6,11 @@ define('DB_NAME', '');
define('DB_USER', '');
define('DB_PASS', '');
// Anwendungs-Schluessel fuer Verschluesselung-at-rest (UniFi-Passwoerter).
// Wird vom Installer automatisch mit einem zufaelligen Wert befuellt.
// Leer = keine Verschluesselung (Klartext, Legacy-Verhalten).
define('APP_KEY', '');
// Sitzungs-Einstellungen
define('SESSION_LIFETIME', 3600); // 1 Stunde

View file

@ -172,7 +172,7 @@ try {
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);

View file

@ -159,7 +159,19 @@ class Auth {
// Prüfen ob eingeloggt
public function isLoggedIn() {
return isset($_SESSION['user_id']) && isset($_SESSION['login_time']);
if (!isset($_SESSION['user_id']) || !isset($_SESSION['login_time'])) {
return false;
}
// Absolutes Session-Timeout durchsetzen (SESSION_LIFETIME aus config.php).
// Bisher wurde die Lebensdauer nie geprueft Sessions liefen unbegrenzt.
$lifetime = defined('SESSION_LIFETIME') ? (int)SESSION_LIFETIME : 3600;
if ($lifetime > 0 && (time() - (int)$_SESSION['login_time']) > $lifetime) {
$this->logout();
return false;
}
return true;
}
// Prüfen ob Admin

117
includes/Crypto.php Normal file
View file

@ -0,0 +1,117 @@
<?php
/**
* Crypto - Symmetrische Verschluesselung fuer sensible Felder (z.B. UniFi-Passwoerter).
*
* Designziele:
* - Verschluesselung-at-rest mit einem Schluessel (APP_KEY) aus der config.php.
* - Vollstaendige Abwaertskompatibilitaet: Bestehende Installationen ohne APP_KEY
* und bereits im Klartext gespeicherte Werte funktionieren unveraendert weiter.
* decrypt() gibt Werte, die nicht unserem Ciphertext-Format entsprechen,
* unveraendert zurueck (Klartext-Passthrough).
* - encrypt() verschluesselt nur, wenn ein APP_KEY vorhanden ist sonst Passthrough.
*
* Format des Ciphertexts: "enc:v1:" . base64(nonce|ciphertext)
*/
class Crypto {
private const PREFIX = 'enc:v1:';
/** Liefert den 32-Byte-Schluessel oder null, wenn kein/ungueltiger APP_KEY gesetzt ist. */
private static function key() {
if (!defined('APP_KEY') || APP_KEY === '') {
return null;
}
$key = base64_decode(APP_KEY, true);
if ($key === false || strlen($key) !== 32) {
return null;
}
return $key;
}
/** Erzeugt einen neuen, base64-kodierten 32-Byte-Schluessel fuer die config.php. */
public static function generateKey() {
return base64_encode(random_bytes(32));
}
/**
* Verschluesselt einen Klartext. Ohne gueltigen APP_KEY wird der Wert
* unveraendert zurueckgegeben (kein Bruch bestehender Installationen).
*/
public static function encrypt($plaintext) {
if ($plaintext === null || $plaintext === '') {
return $plaintext;
}
$key = self::key();
if ($key === null) {
return $plaintext; // Kein Schluessel -> Klartext (Legacy-Verhalten)
}
// Bevorzugt libsodium (PHP-Core seit 7.2), sonst OpenSSL.
if (function_exists('sodium_crypto_secretbox')) {
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = sodium_crypto_secretbox($plaintext, $nonce, $key);
return self::PREFIX . base64_encode($nonce . $cipher);
}
if (function_exists('openssl_encrypt')) {
$ivLen = openssl_cipher_iv_length('aes-256-gcm');
$iv = random_bytes($ivLen);
$tag = '';
$cipher = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($cipher === false) {
return $plaintext;
}
return self::PREFIX . base64_encode($iv . $tag . $cipher);
}
// Keine Krypto-Funktion verfuegbar -> Klartext (besser als Datenverlust)
return $plaintext;
}
/**
* Entschluesselt einen Wert. Nicht-verschluesselte Werte (Legacy/Klartext)
* werden unveraendert zurueckgegeben.
*/
public static function decrypt($value) {
if ($value === null || $value === '' || strpos($value, self::PREFIX) !== 0) {
return $value; // Klartext-Passthrough
}
$key = self::key();
if ($key === null) {
return $value;
}
$raw = base64_decode(substr($value, strlen(self::PREFIX)), true);
if ($raw === false) {
return $value;
}
if (function_exists('sodium_crypto_secretbox_open')) {
$nonceLen = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES;
if (strlen($raw) <= $nonceLen) {
return $value;
}
$nonce = substr($raw, 0, $nonceLen);
$cipher = substr($raw, $nonceLen);
$plain = sodium_crypto_secretbox_open($cipher, $nonce, $key);
return $plain === false ? $value : $plain;
}
if (function_exists('openssl_decrypt')) {
$ivLen = openssl_cipher_iv_length('aes-256-gcm');
$tagLen = 16;
if (strlen($raw) <= $ivLen + $tagLen) {
return $value;
}
$iv = substr($raw, 0, $ivLen);
$tag = substr($raw, $ivLen, $tagLen);
$cipher = substr($raw, $ivLen + $tagLen);
$plain = openssl_decrypt($cipher, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
return $plain === false ? $value : $plain;
}
return $value;
}
/** Prueft, ob ein Wert bereits in unserem verschluesselten Format vorliegt. */
public static function isEncrypted($value) {
return is_string($value) && strpos($value, self::PREFIX) === 0;
}
}

View file

@ -1,4 +1,6 @@
<?php
require_once __DIR__ . '/Crypto.php';
class UniFiController {
private $controllerUrl;
private $username;
@ -169,16 +171,44 @@ class UniFiController {
throw new Exception("Voucher konnte nicht erstellt werden");
}
// Voucher-Code abrufen
// Voucher-Code abrufen. WICHTIG: getVouchers() liefert die Voucher
// unsortiert zurueck ein blindes reset() kann bei parallelen
// Erstellungen den falschen (fremden) Code liefern. Daher gezielt
// nach dem soeben erstellten Voucher suchen: gleiche note + neueste
// create_time.
$vouchers = $this->getVouchers();
if (empty($vouchers)) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
}
// Neuesten Voucher zurückgeben
$latestVoucher = reset($vouchers);
$latestVoucher = null;
foreach ($vouchers as $voucher) {
// Nur Voucher mit passender Notiz beruecksichtigen
if (($voucher['note'] ?? null) !== $voucherName) {
continue;
}
if ($latestVoucher === null
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
$latestVoucher = $voucher;
}
}
// Fallback: falls keine note-Uebereinstimmung (z.B. Sonderzeichen),
// den global neuesten Voucher nehmen.
if ($latestVoucher === null) {
foreach ($vouchers as $voucher) {
if ($latestVoucher === null
|| ($voucher['create_time'] ?? 0) > ($latestVoucher['create_time'] ?? 0)) {
$latestVoucher = $voucher;
}
}
}
if ($latestVoucher === null || empty($latestVoucher['code'])) {
throw new Exception("Voucher-Code konnte nicht abgerufen werden");
}
return [
'code' => $latestVoucher['code'],
'formatted_code' => $this->formatVoucherCode($latestVoucher['code']),

View file

@ -1,7 +1,8 @@
<?php
// Error Reporting für Debugging
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
@ -13,6 +14,32 @@ $auth = new Auth();
$db = Database::getInstance();
$mailer = new Mailer();
/**
* Session-basierter Throttle fuer die anonyme oeffentliche Voucher-Erstellung.
* Erlaubt max. 10 Erstellungen in 10 Minuten pro Session. Verhindert, dass
* der oeffentliche Modus zum Spammen des UniFi-Controllers missbraucht wird.
*/
function isVoucherRateLimited() {
$window = 600; // 10 Minuten
$maxRequests = 10;
$now = time();
$timestamps = $_SESSION['voucher_create_times'] ?? [];
// Nur Eintraege innerhalb des Zeitfensters behalten
$timestamps = array_values(array_filter($timestamps, function ($t) use ($now, $window) {
return ($now - $t) < $window;
}));
if (count($timestamps) >= $maxRequests) {
$_SESSION['voucher_create_times'] = $timestamps;
return true;
}
$timestamps[] = $now;
$_SESSION['voucher_create_times'] = $timestamps;
return false;
}
// Settings laden
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
@ -67,8 +94,14 @@ if ($auth->isLoggedIn()) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
if (!$publicAccess && !$auth->isLoggedIn()) {
$error = 'Sie müssen angemeldet sein';
} elseif ($auth->isLoggedIn() && !$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
// CSRF wird jetzt fuer ALLE geprueft auch fuer anonyme oeffentliche
// Erstellung (Token wird per Session auch ohne Login vergeben).
$error = 'Ungültiges Sicherheits-Token';
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
// Einfacher Session-basierter Throttle gegen Missbrauch/Spam im
// oeffentlichen Modus (kein Login = kein Benutzerkontext).
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
} else {
try {
$siteId = (int)($_POST['site_id'] ?? 0);
@ -114,7 +147,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
$site['unifi_password'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
@ -558,9 +591,8 @@ $autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
<!-- FIX: Trigger-Feld kommt nicht mehr vom Submit-Button -->
<input type="hidden" name="create_voucher" value="1">
<?php if ($auth->isLoggedIn()): ?>
<input type="hidden" name="csrf_token" value="<?= $auth->getCsrfToken() ?>">
<?php endif; ?>
<!-- CSRF-Token fuer alle (auch anonyme oeffentliche Erstellung) -->
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
<div class="form-group">
<label for="voucher_name">Voucher-Name *</label>

View file

@ -1,9 +1,27 @@
<?php
session_start();
// Prüfen ob bereits installiert
if (file_exists(__DIR__ . '/config.php') && !isset($_GET['reinstall'])) {
die('System bereits installiert. Wenn Sie neu installieren möchten, löschen Sie die config.php oder rufen Sie install.php?reinstall=1 auf.');
// Prüfen ob bereits installiert.
// Wenn eine config.php existiert, darf der Installer NICHT mehr ohne Weiteres
// erreichbar sein sonst koennte jeder die Konfiguration ueberschreiben und
// einen neuen Admin anlegen. Reinstall ist nur fuer angemeldete Admins erlaubt.
if (file_exists(__DIR__ . '/config.php')) {
if (!isset($_GET['reinstall'])) {
die('System bereits installiert. Eine Neuinstallation ist nur fuer angemeldete Administratoren ueber install.php?reinstall=1 moeglich.');
}
// Reinstall angefordert -> Admin-Authentifizierung erzwingen
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
try {
$reinstallAuth = new Auth();
if (!$reinstallAuth->isAdmin()) {
die('Neuinstallation nicht erlaubt: Bitte zuerst als Administrator <a href="login.php">anmelden</a>.');
}
} catch (Exception $e) {
die('Neuinstallation nicht moeglich (Konfigurationsfehler).');
}
}
$step = isset($_POST['step']) ? (int)$_POST['step'] : 1;
@ -130,6 +148,9 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
$configContent .= "define('DB_NAME', '{$db['name']}');\n";
$configContent .= "define('DB_USER', '{$db['user']}');\n";
$configContent .= "define('DB_PASS', '" . addslashes($db['pass']) . "');\n\n";
$configContent .= "// Anwendungs-Schluessel fuer Verschluesselung-at-rest (z.B. UniFi-Passwoerter)\n";
$configContent .= "// NICHT aendern, sonst koennen bestehende verschluesselte Werte nicht mehr gelesen werden.\n";
$configContent .= "define('APP_KEY', '" . base64_encode(random_bytes(32)) . "');\n\n";
$configContent .= "// Sitzungs-Einstellungen\n";
$configContent .= "define('SESSION_LIFETIME', 3600); // 1 Stunde\n\n";
$configContent .= "// Zeitzone\n";
@ -140,7 +161,7 @@ if ($step === 5 && $_SERVER['REQUEST_METHOD'] === 'POST') {
// .htaccess erstellen (ohne Rewrite Rules die Probleme machen)
$htaccess = "# UniFi Voucher System\n\n";
$htaccess .= "# Security\n";
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|\\.md)$\">\n";
$htaccess .= "<FilesMatch \"(config\\.php|database\\.sql|install\\.php|test\\.php|m365_debug\\.php|\\.md)$\">\n";
$htaccess .= " Order Allow,Deny\n";
$htaccess .= " Deny from all\n";
$htaccess .= "</FilesMatch>\n\n";

View file

@ -1,7 +1,8 @@
<?php
// Error Reporting (kann nach erfolgreicher Einrichtung entfernt werden)
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
// Absolute Pfade verwenden
require_once __DIR__ . '/config.php';

View file

@ -1,7 +1,8 @@
<?php
// Umfassendes Error Reporting
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
ini_set('log_errors', 1);
// Versuche Dateien zu laden

View file

@ -1,6 +1,7 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
@ -36,8 +37,18 @@ if (isset($_GET['error'])) {
// Authorization Code erhalten
if (isset($_GET['code'])) {
// OAuth-State validieren (CSRF-Schutz). Der State wurde in login.php erzeugt
// und in der Session hinterlegt; er muss exakt zurueckkommen.
$sessionState = $_SESSION['m365_state'] ?? '';
$returnedState = $_GET['state'] ?? '';
unset($_SESSION['m365_state']); // One-Time-Token, nach Pruefung verbrauchen
if ($sessionState === '' || !hash_equals($sessionState, $returnedState)) {
die("Ungültiger oder fehlender Sicherheits-Token (OAuth state). Bitte erneut anmelden.<br><a href='login.php'>Zurück zum Login</a>");
}
$code = $_GET['code'];
// Token anfordern
$tokenUrl = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token";

View file

@ -1,9 +1,15 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
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';
// Diagnose-Seite nur fuer angemeldete Admins (leakt sonst M365-Konfiguration)
$auth = new Auth();
$auth->requireAdmin();
$db = Database::getInstance();
@ -78,4 +84,22 @@ $redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
<h2>1. Konfiguration Status</h2>
<p>Client ID: <?= !empty($clientId) ? '<span class="ok">✓ Gesetzt</span>' : '<span class="error">✗ Fehlt</span>' ?></p>
<p>Client Secret: <?= !empty($clientSecret) ? '<span class="ok">✓ Gesetzt</span>' : '<span class="error">✗ Fehlt</span>' ?></p>
<p>Tenant ID: <?= !empty($tenant
<p>Tenant ID: <?= !empty($tenantId) ? '<span class="ok">✓ Gesetzt</span>' : '<span class="error">✗ Fehlt</span>' ?></p>
</div>
<div class="section">
<h2>2. Redirect URI</h2>
<p>Diese URI muss exakt in der Azure-App-Registrierung hinterlegt sein:</p>
<div class="url"><?= htmlspecialchars($redirectUri) ?></div>
</div>
<div class="section">
<h2>3. Hinweise</h2>
<ul>
<li>Alle drei Werte (Client ID, Client Secret, Tenant ID) müssen gesetzt sein.</li>
<li>Die Redirect URI in Azure AD muss exakt mit der obigen übereinstimmen.</li>
<li>Benötigte API-Berechtigungen: <code>openid</code>, <code>profile</code>, <code>email</code>, <code>User.Read</code>.</li>
</ul>
</div>
</body>
</html>

View file

@ -1,6 +1,17 @@
<?php
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
// Diagnose-Seite nur fuer angemeldete Admins zugaenglich (verhindert Info-Leak)
if (file_exists(__DIR__ . '/config.php')) {
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/Crypto.php';
$auth = new Auth();
$auth->requireAdmin();
}
echo "<h1>System Test</h1>";
@ -111,7 +122,7 @@ try {
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'username' => $site['unifi_username'],
'password' => $site['unifi_password']
'password' => Crypto::decrypt($site['unifi_password'])
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false,