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:
parent
bf3e55a967
commit
3483da274f
17 changed files with 306 additions and 36 deletions
|
|
@ -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
117
includes/Crypto.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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']),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue