200 lines
10 KiB
PHP
200 lines
10 KiB
PHP
<?php
|
||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||
require_once __DIR__ . '/../includes/totp.php';
|
||
require_once __DIR__ . '/../includes/webauthn.php';
|
||
|
||
if (current_staff()) {
|
||
redirect('/staff/reports.php');
|
||
}
|
||
|
||
$errors = [];
|
||
$need2fa = false;
|
||
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !csrf_valid()) {
|
||
// Die Anmeldeseite war so lange offen, dass die Sitzung auf dem Server inzwischen aufgeräumt wurde: neu anmelden statt Fehlerseite
|
||
$errors[] = 'Die Anmeldeseite war zu lange geöffnet und die Sitzung ist abgelaufen. Bitte melden Sie sich jetzt noch einmal an.';
|
||
} elseif ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$action = $_POST['action'] ?? 'login';
|
||
|
||
if ($action === 'verify_totp') {
|
||
$pendingId = $_SESSION['totp_pending_staff_id'] ?? null;
|
||
$code = trim($_POST['totp_code'] ?? '');
|
||
if (!$pendingId) {
|
||
$errors[] = 'Sitzung abgelaufen. Bitte neu anmelden.';
|
||
} else {
|
||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
|
||
$stmt->execute([$pendingId]);
|
||
$staff = $stmt->fetch();
|
||
if ($staff && !rate_limit_check('staff_totp_attempt', (string) $pendingId, login_guard_max_attempts(), login_guard_lock_minutes())) {
|
||
// Code-Raten bremsen: nach zu vielen Fehlversuchen muss das Passwort neu eingegeben werden
|
||
unset($_SESSION['totp_pending_staff_id']);
|
||
login_guard_locked((string) $staff['email'], 'web');
|
||
$errors[] = 'Zu viele falsche Codes. Bitte warten Sie einige Minuten und melden Sie sich neu an.';
|
||
} elseif ($staff && totp_verify($staff['totp_secret'], $code)) {
|
||
unset($_SESSION['totp_pending_staff_id']);
|
||
rate_limit_reset('staff_totp_attempt', (string) $pendingId);
|
||
staff_login($staff);
|
||
redirect('/staff/reports.php');
|
||
} else {
|
||
if ($staff) login_guard_failed($staff, (string) $staff['email'], 'web', 'Falscher 2FA-Code');
|
||
$errors[] = 'Ungültiger Code. Bitte erneut versuchen.';
|
||
$need2fa = true;
|
||
}
|
||
}
|
||
} else {
|
||
$email = trim($_POST['email'] ?? '');
|
||
$pass = $_POST['password'] ?? '';
|
||
if (!rate_limit_check('staff_login_attempt', client_ip(), 10, 15)) {
|
||
$errors[] = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.';
|
||
} elseif ($email && !rate_limit_check('staff_login_email', strtolower($email), login_guard_max_attempts(), login_guard_lock_minutes()) && !login_guard_known_device_for_email($email)) {
|
||
// Konto-basierte Sperre – verhindert Brute-Force über wechselnde IPs. Ein bekanntes Gerät des Kontos (Cookie von einer früheren Anmeldung)
|
||
// wird davon nicht ausgesperrt: Sonst könnte jeder, der die Adresse kennt, den echten Mitarbeiter mit Fehlversuchen dauerhaft aussperren.
|
||
// Die Sperre je IP-Adresse gilt auch für bekannte Geräte.
|
||
login_guard_locked($email, 'web');
|
||
$errors[] = 'Zu viele Anmeldeversuche für dieses Konto. Bitte warten Sie einige Minuten.';
|
||
} else {
|
||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||
$stmt->execute([$email]);
|
||
$staff = $stmt->fetch();
|
||
if ($staff && password_verify($pass, $staff['password_hash'])) {
|
||
// Erfolgreicher Login – Zähler zurücksetzen
|
||
rate_limit_reset('staff_login_email', strtolower($email));
|
||
if ($staff['totp_enabled']) {
|
||
$_SESSION['totp_pending_staff_id'] = $staff['id'];
|
||
$need2fa = true;
|
||
} else {
|
||
staff_login($staff);
|
||
redirect('/staff/reports.php');
|
||
}
|
||
} else {
|
||
login_guard_failed($staff ?: null, $email, 'web', $staff ? 'Falsches Passwort' : 'Unbekannte E-Mail-Adresse');
|
||
$errors[] = 'E-Mail-Adresse oder Passwort ist falsch.';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$pageTitle = 'Mitarbeiter-Login';
|
||
$activePage = '';
|
||
// Staff-Header hat Sidebar – für Login-Seite eigenes schlankes HTML
|
||
$banner = get_setting('banner_path');
|
||
$logo = get_setting('logo_path');
|
||
$displayName = get_setting('display_name', config()['site']['name']);
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Mitarbeiter-Login – <?= e($displayName) ?></title>
|
||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
|
||
<?php $customThemeStylesheet = active_custom_theme_stylesheet(); ?>
|
||
<?php if ($customThemeStylesheet): ?>
|
||
<link rel="stylesheet" href="<?= e($customThemeStylesheet) ?>">
|
||
<?php endif; ?>
|
||
<link rel="manifest" href="/manifest-staff.php">
|
||
<meta name="theme-color" content="#0f6f6a">
|
||
<?= site_icon_links() ?>
|
||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/sw.js').catch(function(){});}</script>
|
||
</head>
|
||
<body class="login-page staff-login-page"<?php if ($banner): ?> style="background-image:url('<?= e($banner) ?>'); background-size:cover; background-position:center"<?php endif; ?>>
|
||
<?= i18n_switcher_html('floating') ?>
|
||
|
||
<!-- Schlanker Header -->
|
||
<header class="staff-login-header">
|
||
<div class="staff-login-brand">
|
||
<?php if ($logo): ?>
|
||
<img src="<?= e($logo) ?>" alt="">
|
||
<?php endif; ?>
|
||
<?php if (!$logo || get_setting('logo_hide_name', '0') !== '1'): ?>
|
||
<span class="staff-login-brand-name"><?= e($displayName) ?></span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<a href="/login.php" class="staff-login-back">← Zum Kundenportal</a>
|
||
</header>
|
||
|
||
<!-- Auth-Bereich -->
|
||
<div class="login-screen staff-login-screen">
|
||
<div class="auth-card login-card staff-login-card">
|
||
<h1 class="staff-login-title">Mitarbeiterbereich</h1>
|
||
<div class="sub">Anmeldung für Support-Mitarbeiter und Administratoren.</div>
|
||
|
||
<?php foreach ($errors as $err): ?>
|
||
<div class="flash error"><?= e($err) ?></div>
|
||
<?php endforeach; ?>
|
||
<?php foreach (get_flashes() as $f): ?>
|
||
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
|
||
<?php endforeach; ?>
|
||
|
||
<?php if ($need2fa): ?>
|
||
<p style="font-size:.88rem; color:#64748b; margin-bottom:14px">Bitte geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.</p>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="verify_totp">
|
||
<label for="totp_code">Authenticator-Code</label>
|
||
<input type="text" id="totp_code" name="totp_code" inputmode="numeric" pattern="\d{6}" maxlength="6" required autofocus placeholder="000000" style="letter-spacing:.2em; font-size:1.3rem; text-align:center">
|
||
<div class="btn-row">
|
||
<button type="submit" class="btn login-submit">Verifizieren</button>
|
||
</div>
|
||
</form>
|
||
<p style="margin-top:14px; font-size:.8rem; text-align:center"><a href="/staff/login.php">← Zurück zum Login</a></p>
|
||
<?php else: ?>
|
||
<form method="post">
|
||
<?= csrf_field() ?>
|
||
<input type="hidden" name="action" value="login">
|
||
<label for="email">E-Mail-Adresse</label>
|
||
<input type="email" id="email" name="email" required autofocus autocomplete="email" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||
<label for="password">Passwort</label>
|
||
<input type="password" id="password" name="password" required autocomplete="current-password" autocapitalize="none" autocorrect="off" spellcheck="false">
|
||
<div style="text-align:right; margin-top:6px">
|
||
<a href="/staff/forgot_password.php" style="font-size:.8rem">Passwort vergessen?</a>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button type="submit" class="btn login-submit">Anmelden</button>
|
||
</div>
|
||
</form>
|
||
<?php endif; ?>
|
||
<?php if (!$need2fa): ?>
|
||
<?php
|
||
$loginButtons = (string) plugin_filter('staff.login.buttons', '', []);
|
||
$passkeyOn = webauthn_enabled();
|
||
// Gleiche Gestaltung wie die Knöpfe der Anmelde-Plugins (zum Beispiel authentik): ein Block, gleiche Größe, Symbol vor der Beschriftung
|
||
$passkeyBtn = $passkeyOn ? '<button type="button" id="passkey-login" class="btn secondary" style="display:none;text-align:center;width:100%"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-5px;margin-right:8px"><circle cx="8" cy="15" r="4"/><path d="M10.85 12.15 19 4"/><path d="m18 5 3 3"/><path d="m15 8 2 2"/></svg>Mit Passkey anmelden</button><div id="passkey-msg" class="flash error" style="display:none;margin:0"></div>' : '';
|
||
?>
|
||
<?php if ($passkeyBtn . $loginButtons !== ''): ?>
|
||
<div class="login-alt" id="login-alt" style="margin-top:18px;padding-top:16px;border-top:1px solid #e2e8f0;display:grid;gap:10px"><?= $passkeyBtn . $loginButtons ?></div>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Schlanker Footer -->
|
||
<footer class="staff-login-footer">
|
||
<span>Copyright © <?= date('Y') ?> <?= e($displayName) ?> – Alle Rechte vorbehalten.</span>
|
||
</footer>
|
||
|
||
<script src="/assets/js/passkey.js?v=<?= @filemtime(__DIR__ . '/../assets/js/passkey.js') ?: time() ?>"></script>
|
||
<script>
|
||
(function () {
|
||
var btn = document.getElementById('passkey-login'), msg = document.getElementById('passkey-msg'), alt = document.getElementById('login-alt');
|
||
if (!btn) return;
|
||
if (!window.DeskHubPasskey || !DeskHubPasskey.supported()) {
|
||
// Browser ohne Passkeys: Knopf bleibt weg, der Block auch, wenn sonst nichts darin steht
|
||
if (alt && alt.querySelectorAll('a, button').length <= 1) alt.style.display = 'none';
|
||
return;
|
||
}
|
||
btn.style.display = 'block';
|
||
btn.addEventListener('click', function () {
|
||
msg.style.display = 'none';
|
||
btn.disabled = true;
|
||
DeskHubPasskey.login(<?= json_encode(csrf_token()) ?>).then(function (r) { location.href = r.redirect || '/staff/reports.php'; })
|
||
.catch(function (e) { msg.textContent = e.message; msg.style.display = 'block'; btn.disabled = false; });
|
||
});
|
||
})();
|
||
// Hält die Sitzung der offenen Anmeldeseite am Leben: Der Server räumt ungenutzte Sitzungen nach etwa 24 Minuten auf, und dann wäre das
|
||
// Anmeldeformular ungültig. Ein kurzer Abruf alle 8 Minuten genügt.
|
||
setInterval(function () { fetch('/staff/session_ping.php?check=1', { credentials: 'same-origin', cache: 'no-store' }).catch(function () {}); }, 8 * 60 * 1000);
|
||
</script>
|
||
</body>
|
||
</html>
|