Unifi-Voucher-Tool/login.php
Friederich Loheide 30d0ce3a23 Branding: Farben systemweit einstellbar + Bild-Upload statt nur URLs
Design-Tab (Administration → Einstellungen → Design):
- Akzentfarbe für Hell- und Dark-Mode, Markenverlauf und Eckenradius
- abgeleitete Töne (Hover, weiche Flächen, Fokusring) werden per
  color-mix aus der Grundfarbe berechnet – eine Farbe genügt
- Live-Vorschau mit Button, Badge, Chip, Logo-Kachel und Link
- Ausgabe als schlanker :root-Override über Ui::brandingStyle(), greift
  auf allen Seiten inklusive Login und Installer

Uploads (includes/Upload.php):
- Logo, Favicon, Login-Logo und Login-Hintergrund lassen sich jetzt
  hochladen; das URL-Feld bleibt als Alternative bestehen
- Whitelist nach Endung, 3-MB-Grenze, getimagesize-Prüfung für Raster,
  SVGs werden von Skripten, Event-Handlern und externen Verweisen befreit
- Zufällige Dateinamen in uploads/, dort sperrt eine .htaccess die
  Ausführung von PHP; beim Ersetzen wird die alte Datei gelöscht

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:27:30 +00:00

296 lines
13 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';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/I18n.php';
try {
$auth = new Auth();
// Angemeldete Admins koennen die Login-Seite mit ?preview=1 ansehen
// (Vorschau aus den Einstellungen), ohne abgemeldet zu werden.
$isPreview = isset($_GET['preview']) && $auth->isLoggedIn() && $auth->isAdmin();
if ($auth->isLoggedIn() && !$isPreview) { header('Location: index.php'); exit; }
} catch (Exception $e) {
die('Fehler beim Initialisieren: ' . $e->getMessage());
}
I18n::init();
$error = '';
$success = '';
$show2fa = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['totp_code'])) {
// Zweiter Login-Schritt: 2FA-Code
try {
if ($auth->verifyTotpLogin(trim($_POST['totp_code']))) {
header('Location: index.php');
exit;
}
$error = 'Code ungültig oder abgelaufen. Bitte erneut versuchen.';
$show2fa = $auth->isTotpPending();
} catch (Exception $e) {
$error = 'Login-Fehler: ' . $e->getMessage();
}
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
if (empty($email) || empty($password)) {
$error = __('login_error_empty');
} else {
$result = $auth->login($email, $password);
if ($result === true) {
header('Location: index.php');
exit;
} elseif ($result === 'totp_required') {
$show2fa = true;
} elseif ($result === 'rate_limited') {
$error = __('login_error_rate');
} else {
$error = __('login_error_creds');
}
}
} catch (Exception $e) {
$error = 'Login-Fehler: ' . $e->getMessage();
}
}
// Direkter Aufruf mit ?2fa=1 (z.B. nach Redirect) und noch ausstehendem Login
if (!$show2fa && isset($_GET['2fa']) && $auth->isTotpPending()) {
$show2fa = true;
}
try {
$db = Database::getInstance();
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$logoUrl = $db->getSetting('logo_url', '');
$m365ClientId = $db->getSetting('m365_client_id', '');
$m365ClientSecret = $db->getSetting('m365_client_secret', '');
$m365TenantId = $db->getSetting('m365_tenant_id', '');
$m365Enabled = !empty($m365ClientId) && !empty($m365ClientSecret) && !empty($m365TenantId);
$publicAccess = $db->getSetting('public_access', 0);
$smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1';
$m365LoginUrl = '';
if ($m365Enabled) {
$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';
$params = [
'client_id' => $m365ClientId,
'response_type' => 'code',
'redirect_uri' => $redirectUri,
'response_mode' => 'query',
'scope' => 'openid profile email User.Read',
'state' => bin2hex(random_bytes(16))
];
$_SESSION['m365_state'] = $params['state'];
$m365LoginUrl = "https://login.microsoftonline.com/$m365TenantId/oauth2/v2.0/authorize?" . http_build_query($params);
}
// Generisches OIDC (optional)
$oidcEnabled = $db->getSetting('oidc_enabled', '0') === '1'
&& $db->getSetting('oidc_client_id', '') !== ''
&& $db->getSetting('oidc_auth_url', '') !== '';
$oidcName = $db->getSetting('oidc_name', 'SSO');
$oidcLoginUrl = '';
if ($oidcEnabled) {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$scriptPath = dirname($_SERVER['SCRIPT_NAME']);
$scriptPath = $scriptPath === '/' ? '' : $scriptPath;
$oidcState = bin2hex(random_bytes(16));
$_SESSION['oidc_state'] = $oidcState;
$oidcLoginUrl = rtrim($db->getSetting('oidc_auth_url', ''), '?') . '?' . http_build_query([
'client_id' => $db->getSetting('oidc_client_id', ''),
'response_type' => 'code',
'redirect_uri' => $protocol . '://' . $_SERVER['HTTP_HOST'] . $scriptPath . '/oidc_callback.php',
'scope' => $db->getSetting('oidc_scopes', 'openid profile email'),
'state' => $oidcState,
]);
}
$showLocalLogin = isset($_GET['local']) && $_GET['local'] === '1';
// --- Individualisierung der Login-Seite -------------------------------
// Alle Werte sind optional; leer bedeutet "Standard verwenden".
$loginBrand = $db->getSetting('login_brand_name', '') ?: $appTitle;
$loginLogo = $db->getSetting('login_logo_url', '') ?: $logoUrl;
$showPanel = $db->getSetting('login_panel_enabled', '1') === '1';
$claimTitle = $db->getSetting('login_claim_title', '') ?: __('auth_claim_title');
$claimText = $db->getSetting('login_claim_text', '') ?: __('auth_claim_text');
$featureRaw = trim((string)$db->getSetting('login_features', ''));
if ($featureRaw !== '') {
$loginFeatures = array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $featureRaw))));
} else {
$loginFeatures = [__('auth_feature_1'), __('auth_feature_2'), __('auth_feature_3')];
}
$loginFooter = $db->getSetting('login_footer', '') ?: ('© ' . date('Y') . ' ' . $loginBrand);
$loginBgImage = trim((string)$db->getSetting('login_bg_image', ''));
$loginBgFrom = $db->getSetting('login_bg_from', '') ?: '#3b2f8f';
$loginBgTo = $db->getSetting('login_bg_to', '') ?: '#6d5ce7';
$loginOverlay = max(0, min(90, (int)$db->getSetting('login_bg_overlay', '40')));
$visualStyle = '--login-from:' . htmlspecialchars($loginBgFrom, ENT_QUOTES)
. ';--login-to:' . htmlspecialchars($loginBgTo, ENT_QUOTES)
. ';--login-overlay:' . ($loginOverlay / 100);
if ($loginBgImage !== '') {
$visualStyle .= ";--login-image:url('" . htmlspecialchars(Ui::mediaUrl($loginBgImage), ENT_QUOTES) . "')";
}
} catch (Exception $e) {
die('Datenbankfehler: ' . $e->getMessage());
}
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= __('login_title') ?> <?= htmlspecialchars($appTitle) ?></title>
<?= Ui::head($db) ?>
</head>
<body class="auth-body<?= $showPanel ? '' : ' auth-body-single' ?>">
<?php if ($showPanel): ?>
<section class="auth-visual<?= $loginBgImage !== '' ? ' has-image' : '' ?>" style="<?= $visualStyle ?>">
<div class="auth-brand">
<?php if ($loginLogo): ?>
<img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="auth-brand-logo">
<?php else: ?>
<span class="brand-mark"><i class="fas fa-wifi"></i></span>
<span><?= htmlspecialchars($loginBrand) ?></span>
<?php endif; ?>
</div>
<div class="auth-claim">
<?php if ($claimTitle !== ''): ?><h2><?= htmlspecialchars($claimTitle) ?></h2><?php endif; ?>
<?php if ($claimText !== ''): ?><p><?= htmlspecialchars($claimText) ?></p><?php endif; ?>
<?php if (!empty($loginFeatures)): ?>
<ul class="auth-features">
<?php foreach ($loginFeatures as $feature): ?>
<li><span class="tick"><i class="fas fa-check"></i></span> <?= htmlspecialchars($feature) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<div class="auth-foot"><?= htmlspecialchars($loginFooter) ?></div>
</section>
<?php endif; ?>
<section class="auth-panel">
<div class="auth-tools">
<div class="lang-switcher">
<?php foreach (I18n::getAvailable() as $code => $label): ?>
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
<?php endforeach; ?>
</div>
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" title="Dark Mode">
<i class="fas fa-moon"></i>
</button>
</div>
<div class="login-container">
<?php if (!$showPanel): ?>
<?php if ($loginLogo): ?>
<img src="<?= htmlspecialchars(Ui::mediaUrl($loginLogo)) ?>" alt="<?= htmlspecialchars($loginBrand) ?>" class="logo">
<?php else: ?>
<div class="auth-mark">
<span class="brand-mark"><i class="fas fa-wifi"></i></span>
<span><?= htmlspecialchars($loginBrand) ?></span>
</div>
<?php endif; ?>
<?php endif; ?>
<h1><?= __('login_title') ?></h1>
<p class="subtitle"><?= __('login_subtitle') ?></p>
<?php if ($error): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success"><?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<?php if ($show2fa): ?>
<form method="post">
<p class="subtitle">
Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
oder einen Ihrer Recovery-Codes.
</p>
<div class="form-group">
<label for="totp_code">Code</label>
<input type="text" id="totp_code" name="totp_code" maxlength="9" class="code-input"
autocomplete="one-time-code" required autofocus
placeholder="123456">
</div>
<button type="submit" class="btn btn-primary btn-lg">Bestätigen</button>
</form>
<div class="auth-links"><a href="login.php" class="local-login-link">Abbrechen</a></div>
<?php elseif ($m365Enabled && !$showLocalLogin): ?>
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
<path fill="#f35325" d="M1 1h10v10H1z"/>
<path fill="#81bc06" d="M12 1h10v10H12z"/>
<path fill="#05a6f0" d="M1 12h10v10H1z"/>
<path fill="#ffba08" d="M12 12h10v10H12z"/>
</svg>
<?= __('login_ms') ?>
</a>
<div class="auth-links"><a href="?local=1" class="local-login-link"><?= __('login_local') ?></a></div>
<?php else: ?>
<form method="post">
<div class="form-group">
<label for="email"><?= __('login_email') ?></label>
<input type="email" id="email" name="email" required autofocus>
</div>
<div class="form-group">
<label for="password"><?= __('login_password') ?></label>
<input type="password" id="password" name="password" required>
</div>
<?php if ($smtpEnabled): ?>
<a href="forgot_password.php" class="forgot-link"><?= __('login_forgot') ?></a>
<?php endif; ?>
<button type="submit" class="btn btn-primary btn-lg"><?= __('login_btn') ?></button>
</form>
<?php if ($m365Enabled): ?>
<div class="divider"><span><?= __('or') ?></span></div>
<a href="<?= htmlspecialchars($m365LoginUrl) ?>" class="btn-microsoft">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23">
<path fill="#f35325" d="M1 1h10v10H1z"/>
<path fill="#81bc06" d="M12 1h10v10H12z"/>
<path fill="#05a6f0" d="M1 12h10v10H1z"/>
<path fill="#ffba08" d="M12 12h10v10H12z"/>
</svg>
<?= __('login_ms') ?>
</a>
<?php endif; ?>
<?php endif; ?>
<?php if (!$show2fa && $oidcEnabled): ?>
<div class="divider"><span><?= __('or') ?></span></div>
<a href="<?= htmlspecialchars($oidcLoginUrl) ?>" class="btn btn-secondary btn-lg">
<i class="fas fa-key"></i> <?= htmlspecialchars($oidcName) ?>
</a>
<?php endif; ?>
<?php if ($publicAccess): ?>
<div class="auth-links"><a href="index.php" class="back-link"><i class="fas fa-arrow-left"></i> <?= __('login_back') ?></a></div>
<?php endif; ?>
</div>
</section>
<script src="assets/global.js"></script>
</body>
</html>