Unifi-Voucher-Tool/m365_callback.php
Claude 6e19958a37
Security-, Bugfix- und UX-Überarbeitung auf Basis des Code-Reviews
Sicherheit:
- Bulk-Erstellung serverseitig auf eingeloggte Nutzer beschränkt;
  expire_minutes wird validiert (anonym: nur Default/Template-Werte,
  eingeloggt: max. 1 Jahr)
- IP-basiertes Rate-Limit über neue Tabelle request_throttle
  (Voucher-Erstellung + Passwort-Reset-Anfragen), Session-Fallback
  für Alt-Installationen; Migration 0002
- session_regenerate_id() nach Login, Secure-Cookie-Flag bei HTTPS
- Admin-/Aktiv-Status wird pro Request live aus der DB geprüft
  (Rechteentzug & Deaktivierung wirken sofort); Schutz vor
  Selbst-Degradierung im Benutzer-Edit
- Alle state-ändernden Admin-Aktionen von GET auf POST umgestellt
  (kein CSRF-Token mehr in URLs)
- login_simple.php (Legacy, Debug-Leak) entfernt; cron_test.php nur
  noch für Admins; .htaccess auf Apache-2.4-Syntax inkl. cron_test.php
- M365 Client Secret wird nicht mehr ins Formular zurückgegeben
- Updater: Zip-Slip-/Pfad-Traversal-Schutz, Backup vor dem Anwenden
  mit automatischem Rollback bei Fehlern, AuditLogger-Bug behoben
- cron_sync: Token-Vergleich mit hash_equals; login_attempts-Pruning
- CSV-Export gegen Excel-Formula-Injection abgesichert

Bugfixes:
- M365-Login: Fallback auf userPrincipalName, wenn Graph kein 'mail'
  liefert (Nutzer ohne Exchange-Postfach konnten sich nie anmelden)
- PRG-Pattern überall: F5 erzeugt keine Duplikat-Voucher und
  wiederholt keine Admin-Aktionen (Session-Flash-Messages)
- QR-Code nicht mehr invertiert (schwarz auf weiß, scanbar)
- Bulk-Erstellung nutzt den UniFi 'n'-Parameter: 1 API-Call statt
  n× Login + Voucherlisten-Abruf; exaktes Code-Matching per
  create_time statt "global neuester Voucher"
- Mailer: doppelte Zeilenumbrüche behoben, AUTH nur mit Credentials,
  SMTP-Dot-Stuffing, CLI-sicherer EHLO-Host
- forgot_password: System-URL-Auto-Detect (Reset-Link war sonst
  relativ/kaputt) + Rate-Limit
- Audit-Log-Labels an tatsächliche Action-Keys angepasst;
  Voucher-Erstellung (einzeln & bulk) wird jetzt auditiert
- Site-Edit testet die Verbindung auch ohne Passwortänderung

UX/UI:
- Alert-/Badge-Styles zentral in global.css mit Dark-Mode-Variablen
  (vorher 7× dupliziert mit hart codierten Hellfarben)
- Sticky-Formulare + Tab-Erhalt nach Validierungsfehlern (Bulk),
  Settings kehren nach dem Speichern zum aktiven Tab zurück
- Gültigkeit menschenlesbar (z.B. "8 Stunden" statt "480 Minuten")
- Voucher-Name-Default "Gast/Guest" im öffentlichen Modus
- Favicon auch auf Login-/öffentlichen Seiten
- Verbindungstest-Button pro Site-Karte (Health-Check)
- i18n-Pass: Confirm-Dialoge, Toasts, Fehl-/Erfolgsmeldungen in de/en
- Sprachumschalter ohne fetch+reload (kein Re-Submit-Dialog)
- A11y: Esc schließt Modals, aria-live für Toasts, aria-labels auf
  Icon-Buttons; APP_KEY-Warnbanner im Dashboard
- Dashboard-Sync: set_time_limit passend zur Site-Anzahl;
  Voucher-Sync mit Map statt SELECT pro Voucher

Tooling:
- GitHub-Actions-Workflow: PHP-Lint aller Dateien + de/en-Key-Parität

https://claude.ai/code/session_01KKVpVPJjrTKGoRgpJcySD4
2026-06-09 19:43:13 +00:00

133 lines
No EOL
5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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';
session_start();
$db = Database::getInstance();
$auth = new Auth();
// M365 Einstellungen abrufen
$clientId = $db->getSetting('m365_client_id', '');
$clientSecret = $db->getSetting('m365_client_secret', '');
$tenantId = $db->getSetting('m365_tenant_id', '');
if (empty($clientId) || empty($clientSecret) || empty($tenantId)) {
die('Microsoft 365 ist nicht konfiguriert. Bitte kontaktieren Sie Ihren Administrator. <a href="login.php">Zurück zum Login</a>');
}
// Dynamische Redirect URI
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$redirectUri = $protocol . '://' . $host . $scriptPath . '/m365_callback.php';
// Fehlerbehandlung
if (isset($_GET['error'])) {
$error = htmlspecialchars($_GET['error']);
$errorDesc = htmlspecialchars($_GET['error_description'] ?? 'Unbekannter Fehler');
die("Microsoft 365 Login-Fehler: $error<br>$errorDesc<br><a href='login.php'>Zurück zum Login</a>");
}
// 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";
$postData = [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'code' => $code,
'redirect_uri' => $redirectUri,
'grant_type' => 'authorization_code',
'scope' => 'openid profile email User.Read'
];
$ch = curl_init($tokenUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($postData));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
die("Fehler beim Token-Abruf (HTTP $httpCode): " . htmlspecialchars($response) . "<br><a href='login.php'>Zurück zum Login</a>");
}
$tokenData = json_decode($response, true);
if (!isset($tokenData['access_token'])) {
die("Kein Access Token erhalten: " . htmlspecialchars($response) . "<br><a href='login.php'>Zurück zum Login</a>");
}
$accessToken = $tokenData['access_token'];
// Benutzer-Informationen abrufen
$userUrl = 'https://graph.microsoft.com/v1.0/me';
$ch = curl_init($userUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json'
]);
$userResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
die("Fehler beim Abrufen der Benutzer-Daten (HTTP $httpCode): " . htmlspecialchars($userResponse) . "<br><a href='login.php'>Zurück zum Login</a>");
}
$userData = json_decode($userResponse, true);
// Graph liefert 'mail' nur bei Nutzern mit Exchange-Postfach fuer alle
// anderen auf den userPrincipalName zurueckfallen.
$userEmail = $userData['mail'] ?? $userData['userPrincipalName'] ?? null;
if (!isset($userData['id']) || empty($userEmail)) {
die("Ungültige Benutzer-Daten erhalten: " . htmlspecialchars($userResponse) . "<br><a href='login.php'>Zurück zum Login</a>");
}
// Benutzer einloggen oder anlegen
$microsoftUser = [
'id' => $userData['id'],
'email' => $userEmail,
'name' => $userData['displayName'] ?? trim(($userData['givenName'] ?? '') . ' ' . ($userData['surname'] ?? ''))
];
try {
$auth->loginWithMicrosoft($microsoftUser);
header('Location: index.php');
exit;
} catch (Exception $e) {
die("Login-Fehler: " . $e->getMessage() . "<br><a href='login.php'>Zurück zum Login</a>");
}
} else {
// Keine Authorization Code - Redirect zu Microsoft Login
die("Kein Authorization Code erhalten.<br><a href='login.php'>Zurück zum Login</a>");
}
?>