356 lines
21 KiB
PHP
356 lines
21 KiB
PHP
<?php
|
||
/**
|
||
* Anmelde-Wächter: merkt sich die Geräte und Orte, von denen sich Mitarbeiter anmelden, und warnt per Mail, wenn ein neues Gerät
|
||
* oder ein neuer Ort auftaucht. Fehlversuche und Sperren stehen im Protokoll (staff/login_log.php). Wer eine Warnung bekommt und die
|
||
* Anmeldung nicht selbst war, sichert das Konto über den Link „Das war ich nicht“ in der Mail (staff/login_alert.php).
|
||
*
|
||
* Ein Gerät ist ein Browser (Kennung in einem Cookie) bzw. eine App (Gerätename). Ein Ort ist das Land (nur wenn Cloudflare es
|
||
* mitschickt) oder sonst das Netz der IP-Adresse; echte Standortdaten gibt es ohne Fremddienst nicht.
|
||
* Fehler im Wächter dürfen eine Anmeldung nie verhindern: alles ist in try/catch gefasst.
|
||
*/
|
||
|
||
const LOGIN_GUARD_COOKIE = 'dh_device';
|
||
|
||
function login_guard_enabled(): bool
|
||
{
|
||
return get_setting('login_guard', '1') === '1';
|
||
}
|
||
|
||
function login_guard_max_attempts(): int
|
||
{
|
||
return max(3, min(20, (int) get_setting('login_guard_max_attempts', '5')));
|
||
}
|
||
|
||
function login_guard_lock_minutes(): int
|
||
{
|
||
return max(5, min(240, (int) get_setting('login_guard_lock_minutes', '15')));
|
||
}
|
||
|
||
function login_guard_retention_days(): int
|
||
{
|
||
return max(7, min(1095, (int) get_setting('login_guard_retention_days', '180')));
|
||
}
|
||
|
||
/**
|
||
* Ist dieser Browser ein bekanntes Gerät dieses Mitarbeiters (Cookie des Anmelde-Wächters, bei einer früheren Anmeldung gemerkt)?
|
||
* Liest nur, setzt kein Cookie. Nur für Browser: Die Kennung von Apps stammt aus frei wählbaren Angaben und taugt dafür nicht.
|
||
*/
|
||
function login_guard_known_device(PDO $pdo, int $staffId): bool
|
||
{
|
||
$id = (string) ($_COOKIE[LOGIN_GUARD_COOKIE] ?? '');
|
||
if (!preg_match('/^[a-f0-9]{32}$/', $id)) return false;
|
||
$st = $pdo->prepare('SELECT 1 FROM login_devices WHERE staff_id = ? AND device_hash = ?');
|
||
$st->execute([$staffId, hash('sha256', $staffId . '|' . $id)]);
|
||
return (bool) $st->fetchColumn();
|
||
}
|
||
|
||
/** Wie login_guard_known_device(), aber für eine E-Mail-Adresse (unbekannte Adresse = nein). */
|
||
function login_guard_known_device_for_email(string $email): bool
|
||
{
|
||
if (!login_guard_enabled() || trim($email) === '') return false;
|
||
try {
|
||
$pdo = get_db();
|
||
$st = $pdo->prepare('SELECT id FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||
$st->execute([$email]);
|
||
$id = (int) $st->fetchColumn();
|
||
return $id > 0 && login_guard_known_device($pdo, $id);
|
||
} catch (Throwable $e) {
|
||
error_log('login_guard_known_device: ' . $e->getMessage());
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Ist die Anmeldung dieses Kontos gerade wegen zu vieler Fehlversuche gesperrt (Web oder App)? */
|
||
function login_guard_is_locked(PDO $pdo, string $email): bool
|
||
{
|
||
$email = strtolower(trim($email));
|
||
if ($email === '') return false;
|
||
$since = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
|
||
$st = $pdo->prepare("SELECT COUNT(*) FROM rate_limit_log WHERE bucket IN ('staff_login_email','mobile_staff_login_email') AND identifier = ? AND created_at >= ?");
|
||
$st->execute([$email, $since]);
|
||
return (int) $st->fetchColumn() >= login_guard_max_attempts();
|
||
}
|
||
|
||
/**
|
||
* Hebt die Sperre eines Kontos sofort auf: die Fehlversuch-Zähler für Web, App und 2FA-Code werden gelöscht.
|
||
* Die Sperre je IP-Adresse (viele Versuche von einer Adresse) bleibt bewusst bestehen.
|
||
* @return int Anzahl der gelöschten Zähler-Einträge
|
||
*/
|
||
function login_guard_unlock(PDO $pdo, int $staffId, ?int $byStaffId = null, string $reason = ''): int
|
||
{
|
||
$st = $pdo->prepare('SELECT email FROM staff WHERE id = ?');
|
||
$st->execute([$staffId]);
|
||
$email = strtolower(trim((string) $st->fetchColumn()));
|
||
if ($email === '') return 0;
|
||
$n = 0;
|
||
$del = $pdo->prepare("DELETE FROM rate_limit_log WHERE bucket IN ('staff_login_email','mobile_staff_login_email') AND identifier = ?");
|
||
$del->execute([$email]);
|
||
$n += $del->rowCount();
|
||
$del = $pdo->prepare("DELETE FROM rate_limit_log WHERE bucket = 'staff_totp_attempt' AND identifier = ?");
|
||
$del->execute([(string) $staffId]);
|
||
$n += $del->rowCount();
|
||
$detail = $reason !== '' ? $reason : ($byStaffId !== null && $byStaffId !== $staffId ? 'Sperre aufgehoben durch Administrator #' . $byStaffId : 'Sperre aufgehoben');
|
||
try { login_guard_record($pdo, 'unlocked', $staffId, $email, 'web', $detail); } catch (Throwable $e) { error_log('login_guard_unlock log: ' . $e->getMessage()); }
|
||
audit_log($byStaffId ?? $staffId, "Anmeldesperre aufgehoben für Mitarbeiter #{$staffId}" . ($reason !== '' ? ' (' . $reason . ')' : ''));
|
||
return $n;
|
||
}
|
||
|
||
/** Anzeigename des Geräts aus dem User-Agent, z. B. „Chrome auf Windows“. */
|
||
function login_guard_describe_device(string $ua): string
|
||
{
|
||
$ua = trim($ua);
|
||
if ($ua === '') return 'Unbekanntes Gerät';
|
||
if (stripos($ua, 'okhttp') !== false || stripos($ua, 'DeskHub') !== false) return 'DeskHub-App';
|
||
$browser = 'Browser';
|
||
foreach (['Edg' => 'Edge', 'OPR' => 'Opera', 'Firefox' => 'Firefox', 'Chrome' => 'Chrome', 'Safari' => 'Safari'] as $needle => $name) {
|
||
if (stripos($ua, $needle) !== false) { $browser = $name; break; }
|
||
}
|
||
$os = 'unbekanntem System';
|
||
foreach (['Windows' => 'Windows', 'Android' => 'Android', 'iPhone' => 'iOS', 'iPad' => 'iOS', 'Macintosh' => 'macOS', 'Mac OS' => 'macOS', 'Linux' => 'Linux'] as $needle => $name) {
|
||
if (stripos($ua, $needle) !== false) { $os = $name; break; }
|
||
}
|
||
return $browser . ' auf ' . $os;
|
||
}
|
||
|
||
/** Ort der Anmeldung: [Schlüssel, Anzeigetext]. Land nur, wenn Cloudflare es über einen vertrauten Proxy mitschickt. */
|
||
function login_guard_place(string $ip): array
|
||
{
|
||
if (request_from_trusted_proxy()) {
|
||
$country = strtoupper(trim((string) ($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '')));
|
||
if (preg_match('/^[A-Z]{2}$/', $country) && !in_array($country, ['XX', 'T1'], true)) return ['C:' . $country, 'Land ' . $country];
|
||
}
|
||
if (!filter_var($ip, FILTER_VALIDATE_IP)) return ['unknown', 'Unbekannt'];
|
||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return ['local', 'Lokales Netz'];
|
||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||
$p = explode('.', $ip);
|
||
return ['N:' . $p[0] . '.' . $p[1], 'Netz ' . $p[0] . '.' . $p[1] . '.x.x'];
|
||
}
|
||
$bin = inet_pton($ip);
|
||
$h = $bin === false ? ['0', '0'] : array_map(fn($w) => dechex($w), array_values(unpack('n2', substr($bin, 0, 4))));
|
||
return ['N6:' . $h[0] . ':' . $h[1], 'Netz ' . $h[0] . ':' . $h[1] . '::/32'];
|
||
}
|
||
|
||
/** Schreibt einen Protokolleintrag; gibt die Eintrags-Nummer zurück. */
|
||
function login_guard_record(PDO $pdo, string $event, ?int $staffId, string $email, string $channel, string $detail = '', ?string $device = null): int
|
||
{
|
||
$ip = client_ip();
|
||
[, $placeLabel] = login_guard_place($ip);
|
||
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||
$pdo->prepare('INSERT INTO login_events (staff_id, email, event, channel, ip, place, device, detail, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
|
||
->execute([$staffId, mb_substr($email, 0, 255), $event, $channel, mb_substr($ip, 0, 64), mb_substr($placeLabel, 0, 100),
|
||
mb_substr($device ?? login_guard_describe_device($ua), 0, 160), mb_substr($detail, 0, 255), db_now()]);
|
||
if (random_int(1, 40) === 1) login_guard_purge($pdo);
|
||
return (int) $pdo->lastInsertId();
|
||
}
|
||
|
||
function login_guard_purge(PDO $pdo): void
|
||
{
|
||
$cut = date('Y-m-d H:i:s', time() - login_guard_retention_days() * 86400);
|
||
$pdo->prepare('DELETE FROM login_events WHERE created_at < ?')->execute([$cut]);
|
||
}
|
||
|
||
/** Kennung dieses Browsers (Cookie); legt sie an, wenn sie fehlt. */
|
||
function login_guard_device_id(string $channel, ?string $deviceName): string
|
||
{
|
||
if ($channel === 'app') return 'app:' . mb_strtolower(trim((string) $deviceName)) . '|' . login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||
$id = (string) ($_COOKIE[LOGIN_GUARD_COOKIE] ?? '');
|
||
if (!preg_match('/^[a-f0-9]{32}$/', $id)) $id = bin2hex(random_bytes(16));
|
||
if (!headers_sent() && PHP_SAPI !== 'cli') {
|
||
setcookie(LOGIN_GUARD_COOKIE, $id, ['expires' => time() + 400 * 86400, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']), 'httponly' => true, 'samesite' => 'Lax']);
|
||
}
|
||
$_COOKIE[LOGIN_GUARD_COOKIE] = $id;
|
||
return $id;
|
||
}
|
||
|
||
/**
|
||
* Nach erfolgreicher Anmeldung aufrufen (Web: staff_login(); App: api/staff/v1/login.php).
|
||
* Merkt Gerät und Ort, schreibt das Protokoll und warnt bei Neuem. Gibt das Ergebnis zurück: first | known | new_device | new_place | off.
|
||
*/
|
||
function login_guard_success(array $staff, string $channel = 'web', ?string $deviceName = null): string
|
||
{
|
||
if (!login_guard_enabled()) return 'off';
|
||
try {
|
||
$pdo = get_db();
|
||
$sid = (int) $staff['id'];
|
||
$now = db_now();
|
||
$ip = client_ip();
|
||
[$placeKey, $placeLabel] = login_guard_place($ip);
|
||
$label = $channel === 'app' && trim((string) $deviceName) !== '' ? mb_substr(trim((string) $deviceName), 0, 120) : login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||
$hash = hash('sha256', $sid . '|' . login_guard_device_id($channel, $deviceName));
|
||
|
||
$st = $pdo->prepare('SELECT id FROM login_devices WHERE staff_id = ? AND device_hash = ?');
|
||
$st->execute([$sid, $hash]);
|
||
$devId = $st->fetchColumn();
|
||
$st = $pdo->prepare('SELECT id FROM login_places WHERE staff_id = ? AND place_key = ?');
|
||
$st->execute([$sid, $placeKey]);
|
||
$plId = $st->fetchColumn();
|
||
$st = $pdo->prepare('SELECT COUNT(*) FROM login_devices WHERE staff_id = ?');
|
||
$st->execute([$sid]);
|
||
$hadAny = (int) $st->fetchColumn() > 0;
|
||
|
||
if ($devId) {
|
||
$pdo->prepare('UPDATE login_devices SET last_seen = ?, last_ip = ?, place = ?, label = ? WHERE id = ?')->execute([$now, $ip, $placeLabel, $label, $devId]);
|
||
} else {
|
||
$pdo->prepare('INSERT INTO login_devices (staff_id, device_hash, label, channel, first_ip, last_ip, place, first_seen, last_seen) VALUES (?,?,?,?,?,?,?,?,?)')
|
||
->execute([$sid, $hash, $label, $channel, $ip, $ip, $placeLabel, $now, $now]);
|
||
}
|
||
if ($plId) {
|
||
$pdo->prepare('UPDATE login_places SET last_seen = ? WHERE id = ?')->execute([$now, $plId]);
|
||
} else {
|
||
$pdo->prepare('INSERT INTO login_places (staff_id, place_key, label, first_seen, last_seen) VALUES (?,?,?,?,?)')->execute([$sid, $placeKey, $placeLabel, $now, $now]);
|
||
}
|
||
|
||
$email = (string) $staff['email'];
|
||
if (!$hadAny) {
|
||
login_guard_record($pdo, 'success', $sid, $email, $channel, 'Erste Anmeldung, Gerät und Ort gemerkt', $label);
|
||
return 'first';
|
||
}
|
||
if (!$devId) {
|
||
$eid = login_guard_record($pdo, 'new_device', $sid, $email, $channel, 'Neues Gerät' . (!$plId ? ' und neuer Ort' : ''), $label);
|
||
login_guard_alert($pdo, $staff, $eid, 'Neues Gerät', $label, $placeLabel, $ip, $channel);
|
||
return 'new_device';
|
||
}
|
||
if (!$plId && get_setting('login_guard_warn_place', '1') === '1') {
|
||
$eid = login_guard_record($pdo, 'new_place', $sid, $email, $channel, 'Neuer Ort', $label);
|
||
login_guard_alert($pdo, $staff, $eid, 'Neuer Ort', $label, $placeLabel, $ip, $channel);
|
||
return 'new_place';
|
||
}
|
||
login_guard_record($pdo, 'success', $sid, $email, $channel, '', $label);
|
||
return 'known';
|
||
} catch (Throwable $e) {
|
||
error_log('login_guard_success: ' . $e->getMessage());
|
||
return 'off';
|
||
}
|
||
}
|
||
|
||
/** Warn-Mail an den Mitarbeiter mit einmaligem Link „Das war ich nicht“. */
|
||
function login_guard_alert(PDO $pdo, array $staff, int $eventId, string $reason, string $device, string $place, string $ip, string $channel): void
|
||
{
|
||
$token = bin2hex(random_bytes(24));
|
||
$pdo->prepare('UPDATE login_events SET token_hash = ? WHERE id = ?')->execute([hash('sha256', $token), $eventId]);
|
||
$link = base_url() . '/staff/login_alert.php?t=' . $token;
|
||
if (getenv('DESKHUB_TESTING') === '1') $GLOBALS['login_guard_last_link'] = $link; // nur für Tests
|
||
$name = e((string) ($staff['name'] ?? ''));
|
||
$inner = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Neue Anmeldung bei Ihrem Konto</h2>'
|
||
. '<p style="margin:0 0 12px;color:#334155">Hallo ' . $name . ', Ihr Mitarbeiter-Konto wurde gerade von einem bisher unbekannten ' . ($reason === 'Neuer Ort' ? 'Ort' : 'Gerät') . ' aus genutzt.</p>'
|
||
. '<table style="margin:0 0 16px;font-size:14px;color:#334155"><tr><td style="padding:2px 14px 2px 0;color:#64748b">Zeit</td><td>' . e(date('d.m.Y H:i')) . '</td></tr>'
|
||
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Gerät</td><td>' . e($device) . ($channel === 'app' ? ' (App)' : '') . '</td></tr>'
|
||
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Ort</td><td>' . e($place) . '</td></tr>'
|
||
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">IP-Adresse</td><td>' . e($ip) . '</td></tr></table>'
|
||
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie?</strong> Dann müssen Sie nichts tun.</p>'
|
||
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie nicht?</strong> Dann sichern Sie Ihr Konto sofort: Das Passwort wird ungültig, alle App-Anmeldungen werden beendet und die Administratoren erfahren davon.</p>'
|
||
. '<p style="margin:0 0 8px"><a href="' . e($link) . '" style="display:inline-block;background:#dc2626;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600">Das war ich nicht – Konto sichern</a></p>'
|
||
. '<p style="margin:12px 0 0;font-size:12px;color:#64748b">Der Link gilt 7 Tage und kann einmal verwendet werden.</p>';
|
||
send_mail((string) $staff['email'], 'Neue Anmeldung bei Ihrem Konto (' . $reason . ')', email_wrap($inner));
|
||
}
|
||
|
||
/** Fehlversuch ins Protokoll. $staff ist null, wenn es die Adresse nicht gibt. */
|
||
function login_guard_failed(?array $staff, string $email, string $channel, string $detail): void
|
||
{
|
||
if (!login_guard_enabled()) return;
|
||
try {
|
||
login_guard_record(get_db(), 'failed', $staff ? (int) $staff['id'] : null, $email, $channel, $detail);
|
||
} catch (Throwable $e) {
|
||
error_log('login_guard_failed: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/** Die Konto-Sperre hat gegriffen: einmal je Sperrzeit ins Protokoll, Mail an den Kontoinhaber und die Administratoren. */
|
||
function login_guard_locked(string $email, string $channel): void
|
||
{
|
||
if (!login_guard_enabled()) return;
|
||
try {
|
||
$pdo = get_db();
|
||
// Steuerzeichen und Zeilenumbrüche raus (die Adresse steht im Betreff), Länge begrenzen
|
||
$email = mb_substr(trim((string) preg_replace('/[\x00-\x1F\x7F]+/', ' ', $email)), 0, 120);
|
||
$cut = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
|
||
$st = $pdo->prepare("SELECT COUNT(*) FROM login_events WHERE event = 'locked' AND LOWER(email) = LOWER(?) AND created_at >= ?");
|
||
$st->execute([$email, $cut]);
|
||
if ((int) $st->fetchColumn() > 0) return;
|
||
$st = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||
$st->execute([$email]);
|
||
$staff = $st->fetch() ?: null;
|
||
if (!$staff) return; // unbekannte Adresse: kein Eintrag und keine Mail (sonst lässt sich der Posteingang der Administratoren füllen); die Fehlversuche stehen schon im Protokoll
|
||
login_guard_record($pdo, 'locked', (int) $staff['id'], $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt');
|
||
$ip = client_ip();
|
||
$msg = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Anmeldung gesperrt</h2>'
|
||
. '<p style="margin:0 0 12px;color:#334155">Für das Konto <strong>' . e($email) . '</strong> gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.</p>'
|
||
. '<p style="margin:0 0 12px;color:#334155">Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.</p>';
|
||
send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg));
|
||
login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, (string) $staff['email']);
|
||
} catch (Throwable $e) {
|
||
error_log('login_guard_locked: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/** Mail an alle aktiven Administratoren (abschaltbar). $skipEmail: dieser Empfänger hat die Mail schon bekommen. */
|
||
function login_guard_notify_admins(string $subject, string $innerHtml, ?string $skipEmail = null): void
|
||
{
|
||
if (get_setting('login_guard_notify_admins', '1') !== '1') return;
|
||
foreach (get_db()->query("SELECT email FROM staff WHERE role = 'admin' AND is_active = 1")->fetchAll(PDO::FETCH_COLUMN) as $adminMail) {
|
||
if ($skipEmail !== null && strcasecmp((string) $adminMail, $skipEmail) === 0) continue;
|
||
if (filter_var($adminMail, FILTER_VALIDATE_EMAIL)) send_mail((string) $adminMail, $subject, email_wrap($innerHtml));
|
||
}
|
||
}
|
||
|
||
/** Alarm-Link prüfen: gibt die Protokollzeile zurück, wenn der Link gültig ist (7 Tage, einmal verwendbar). */
|
||
function login_guard_find_alert(PDO $pdo, string $token): ?array
|
||
{
|
||
if (!preg_match('/^[a-f0-9]{48}$/', $token)) return null;
|
||
$st = $pdo->prepare('SELECT * FROM login_events WHERE token_hash = ? AND token_used = 0 AND created_at >= ?');
|
||
$st->execute([hash('sha256', $token), date('Y-m-d H:i:s', time() - 7 * 86400)]);
|
||
return $st->fetch() ?: null;
|
||
}
|
||
|
||
/**
|
||
* „Das war ich nicht“: Passwort ungültig machen, App-Anmeldungen beenden, alle gemerkten Geräte und Orte vergessen, laufende
|
||
* Browser-Sitzungen beenden (current_staff() prüft den Zeitpunkt), Protokoll und Mail an die Administratoren.
|
||
*/
|
||
function login_guard_revoke(PDO $pdo, array $event): bool
|
||
{
|
||
$sid = (int) ($event['staff_id'] ?? 0);
|
||
$st = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
|
||
$st->execute([$sid]);
|
||
$staff = $st->fetch();
|
||
if (!$staff) return false;
|
||
$pdo->prepare('UPDATE login_events SET token_used = 1 WHERE id = ?')->execute([(int) $event['id']]);
|
||
$pdo->prepare('UPDATE staff SET password_hash = ? WHERE id = ?')->execute([password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $sid]);
|
||
$pdo->prepare('DELETE FROM mobile_api_tokens WHERE staff_id = ?')->execute([$sid]);
|
||
$pdo->prepare('DELETE FROM login_devices WHERE staff_id = ?')->execute([$sid]);
|
||
$pdo->prepare('DELETE FROM login_places WHERE staff_id = ?')->execute([$sid]);
|
||
// Auch Passkeys: Wer das Konto übernommen hat, könnte einen eigenen eingetragen haben
|
||
try { $pdo->prepare('DELETE FROM staff_passkeys WHERE staff_id = ?')->execute([$sid]); } catch (Throwable $e) { /* Tabelle fehlt vor dem Datenbank-Update */ }
|
||
set_setting('login_guard_revoke_' . $sid, (string) time());
|
||
login_guard_record($pdo, 'revoked', $sid, (string) $staff['email'], 'web', 'Konto gesichert: „Das war ich nicht“ nach Eintrag ' . (int) $event['id']);
|
||
$msg = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Konto gesichert</h2>'
|
||
. '<p style="margin:0 0 12px;color:#334155">' . e((string) $staff['name']) . ' (' . e((string) $staff['email']) . ') hat eine Anmeldung als „nicht von mir“ gemeldet. Das Passwort wurde ungültig gemacht, alle App-Anmeldungen wurden beendet.</p>'
|
||
. '<p style="margin:0 0 12px;color:#334155">Gemeldet war: ' . e((string) $event['device']) . ', ' . e((string) $event['place']) . ', IP ' . e((string) $event['ip']) . '. Bitte prüfen Sie das Protokoll im Adminportal unter „Anmelde-Wächter“.</p>';
|
||
login_guard_notify_admins('Konto gesichert: ' . $staff['email'], $msg);
|
||
send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Ihr Konto wurde gesichert</h2><p style="margin:0;color:#334155">Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.</p>'));
|
||
return true;
|
||
}
|
||
|
||
/** Nach „Das war ich nicht“ sind ältere Browser-Sitzungen ungültig. */
|
||
function login_guard_session_revoked(int $staffId): bool
|
||
{
|
||
$revoked = (int) get_setting('login_guard_revoke_' . $staffId, '0');
|
||
return $revoked > 0 && (int) ($_SESSION['staff_login_at'] ?? 0) <= $revoked;
|
||
}
|
||
|
||
/** Namen der Ereignisarten für die Anzeige. */
|
||
function login_guard_event_labels(): array
|
||
{
|
||
return [
|
||
'success' => 'Angemeldet',
|
||
'new_device' => 'Neues Gerät',
|
||
'new_place' => 'Neuer Ort',
|
||
'failed' => 'Fehlversuch',
|
||
'locked' => 'Gesperrt',
|
||
'revoked' => 'Konto gesichert',
|
||
'signout' => 'Überall abgemeldet',
|
||
'unlocked' => 'Sperre aufgehoben',
|
||
'passkey_added' => 'Passkey hinzugefügt',
|
||
'passkey_removed' => 'Passkey entfernt',
|
||
];
|
||
}
|