Unifi-Voucher-Tool/login.php
Friederich Loheide 7fa423a74c
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
Entwicklerhinweis "Entwickelt von Loheide.eu" ergänzen
Zentral in Ui::credit() gepflegt und dezent im Seitenfuß eingebunden:

- Admin-Bereich: unter dem Benutzerbereich in der Sidebar
- öffentliche Voucher-Seite: unter der Karte
- Anmeldung, Installer und Updater: am Ende der jeweiligen Karte
- Wartungsseite: direkt im Markup, da sie bewusst eigenständig bleibt
- README: im Kopf und in der Fußzeile

Der Hinweis ist übersetzt ("Entwickelt von" / "Developed by") und stört
das Branding der Betreiber nicht – die konfigurierbare Fußzeile der
Login-Seite bleibt unverändert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:21:53 +00:00

297 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" aria-hidden="true"></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" aria-hidden="true"></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" role="group" aria-label="<?= __('a11y_language') ?>">
<?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()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
<i class="fas fa-moon" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></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" aria-hidden="true"></i> <?= __('login_back') ?></a></div>
<?php endif; ?>
<?= Ui::credit() ?>
</div>
</section>
<script src="assets/global.js"></script>
</body>
</html>