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
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>
297 lines
13 KiB
PHP
297 lines
13 KiB
PHP
<?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>
|