Unifi-Voucher-Tool/includes/Auth.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

309 lines
No EOL
11 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
class Auth {
private $db;
/** Pro Request gecachter DB-Datensatz des Session-Users (false = noch nicht geladen) */
private $sessionUser = false;
public function __construct() {
try {
$this->db = Database::getInstance();
} catch (Exception $e) {
die("Datenbankverbindung fehlgeschlagen: " . $e->getMessage());
}
// Session-Konfiguration
if (session_status() === PHP_SESSION_NONE) {
ini_set('session.cookie_httponly', 1);
ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_samesite', 'Lax');
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
ini_set('session.cookie_secure', 1);
}
if (!session_start()) {
die("Session konnte nicht gestartet werden");
}
}
}
// Benutzer einloggen
public function login($email, $password) {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
if ($this->isRateLimited($ip, $email)) {
return 'rate_limited';
}
$user = $this->db->fetchOne(
"SELECT * FROM users WHERE email = ? AND is_active = 1",
[$email]
);
if ($user && password_verify($password, $user['password_hash'])) {
$this->clearLoginAttempts($ip, $email);
$this->setUserSession($user);
$this->updateLastLogin($user['id']);
$this->writeAuditLog($user['id'], 'user_login', 'user', $user['id'], 'Login erfolgreich');
return true;
}
$this->recordLoginAttempt($ip, $email);
return false;
}
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'] ?? '']
);
} catch (\Exception $e) {
// audit_log table may not exist on old installs
}
}
private function isRateLimited($ip, $email) {
try {
$count = $this->db->fetchOne(
"SELECT COUNT(*) as cnt FROM login_attempts
WHERE (ip_address = ? OR email = ?) AND attempted_at > DATE_SUB(NOW(), INTERVAL 10 MINUTE)",
[$ip, $email]
);
return $count && (int)$count['cnt'] >= 10;
} catch (\Exception $e) {
return false;
}
}
private function recordLoginAttempt($ip, $email) {
try {
// Alte Eintraege aufraeumen, damit die Tabelle nicht unbegrenzt waechst
$this->db->query("DELETE FROM login_attempts WHERE attempted_at < DATE_SUB(NOW(), INTERVAL 1 DAY)");
$this->db->query(
"INSERT INTO login_attempts (ip_address, email) VALUES (?, ?)",
[$ip, $email]
);
} catch (\Exception $e) {
// Tabelle existiert noch nicht ignorieren
}
}
private function clearLoginAttempts($ip, $email) {
try {
$this->db->query(
"DELETE FROM login_attempts WHERE ip_address = ? OR email = ?",
[$ip, $email]
);
} catch (\Exception $e) {
// ignore
}
}
// Microsoft 365 Login
public function loginWithMicrosoft($microsoftUser) {
// Zuerst nach Microsoft ID suchen
$user = $this->db->fetchOne(
"SELECT * FROM users WHERE microsoft_id = ? AND is_active = 1",
[$microsoftUser['id']]
);
if (!$user) {
// Prüfen ob E-Mail bereits existiert (ohne Microsoft ID)
$existingUser = $this->db->fetchOne(
"SELECT * FROM users WHERE email = ? AND is_active = 1",
[$microsoftUser['email']]
);
if ($existingUser) {
// Benutzer existiert bereits ohne Microsoft ID - verknüpfen
$this->db->query(
"UPDATE users SET microsoft_id = ?, name = ? WHERE id = ?",
[$microsoftUser['id'], $microsoftUser['name'], $existingUser['id']]
);
$user = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$existingUser['id']]);
} else {
// Komplett neuer Benutzer - anlegen
$userId = $this->db->execute(
"INSERT INTO users (email, name, microsoft_id, is_active) VALUES (?, ?, ?, 1)",
[$microsoftUser['email'], $microsoftUser['name'], $microsoftUser['id']]
);
$user = $this->db->fetchOne("SELECT * FROM users WHERE id = ?", [$userId]);
}
} else {
// Microsoft-Benutzer existiert bereits - Name aktualisieren falls geändert
$this->db->query(
"UPDATE users SET name = ? WHERE id = ?",
[$microsoftUser['name'], $user['id']]
);
}
$this->setUserSession($user);
$this->updateLastLogin($user['id']);
return true;
}
// Session setzen
private function setUserSession($user) {
// Session-ID nach erfolgreichem Login rotieren (verhindert Session-Fixation)
if (session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
}
$this->sessionUser = false; // User-Cache invalidieren
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_email'] = $user['email'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['is_admin'] = (bool)$user['is_admin'];
$_SESSION['login_time'] = time();
// CSRF-Token generieren
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
}
// Letzten Login aktualisieren
private function updateLastLogin($userId) {
$this->db->query(
"UPDATE users SET last_login = NOW() WHERE id = ?",
[$userId]
);
}
// Ausloggen
public function logout() {
$this->sessionUser = null;
$_SESSION = [];
if (isset($_COOKIE[session_name()])) {
setcookie(session_name(), '', time() - 3600, '/');
}
session_destroy();
}
/**
* Laedt den Session-User einmal pro Request aus der DB. Dadurch wirken
* Rechteaenderungen (Admin entzogen, Konto deaktiviert/geloescht) sofort
* und nicht erst nach Ablauf der Session.
*/
private function loadSessionUser() {
if ($this->sessionUser === false) {
$this->sessionUser = null;
if (isset($_SESSION['user_id'])) {
$user = $this->db->fetchOne(
"SELECT * FROM users WHERE id = ? AND is_active = 1",
[$_SESSION['user_id']]
);
$this->sessionUser = $user ?: null;
}
}
return $this->sessionUser;
}
// Prüfen ob eingeloggt
public function isLoggedIn() {
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;
}
// Deaktivierte/geloeschte Konten sofort aussperren
if ($this->loadSessionUser() === null) {
$this->logout();
return false;
}
return true;
}
// Prüfen ob Admin (live aus der DB, nicht aus dem Session-Cache)
public function isAdmin() {
if (!$this->isLoggedIn()) {
return false;
}
$user = $this->loadSessionUser();
$isAdmin = $user !== null && (bool)$user['is_admin'];
$_SESSION['is_admin'] = $isAdmin;
return $isAdmin;
}
// Aktuellen Benutzer abrufen
public function getCurrentUser() {
if (!$this->isLoggedIn()) {
return null;
}
return $this->loadSessionUser();
}
// Prüfen ob Benutzer Zugriff auf Site hat
public function hasAccessToSite($siteId) {
if ($this->isAdmin()) {
return true;
}
if (!$this->isLoggedIn()) {
return false;
}
$access = $this->db->fetchOne(
"SELECT id FROM user_site_access WHERE user_id = ? AND site_id = ?",
[$_SESSION['user_id'], $siteId]
);
return $access !== false;
}
// CSRF-Token validieren
public function validateCsrfToken($token) {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
// CSRF-Token abrufen
public function getCsrfToken() {
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// Benutzer registrieren (nur für Admins)
public function registerUser($email, $name, $password, $isAdmin = false) {
// Prüfen ob E-Mail bereits existiert
$existing = $this->db->fetchOne("SELECT id FROM users WHERE email = ?", [$email]);
if ($existing) {
return false;
}
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
return $this->db->execute(
"INSERT INTO users (email, name, password_hash, is_admin, is_active) VALUES (?, ?, ?, ?, 1)",
[$email, $name, $passwordHash, $isAdmin ? 1 : 0]
);
}
// Admin-Zugriff erforderlich
public function requireAdmin() {
if (!$this->isAdmin()) {
header('Location: /index.php?error=access_denied');
exit;
}
}
// Login erforderlich
public function requireLogin() {
if (!$this->isLoggedIn()) {
header('Location: /login.php');
exit;
}
}
}