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 = '

Neue Anmeldung bei Ihrem Konto

' . '

Hallo ' . $name . ', Ihr Mitarbeiter-Konto wurde gerade von einem bisher unbekannten ' . ($reason === 'Neuer Ort' ? 'Ort' : 'Gerät') . ' aus genutzt.

' . '' . '' . '' . '
Zeit' . e(date('d.m.Y H:i')) . '
Gerät' . e($device) . ($channel === 'app' ? ' (App)' : '') . '
Ort' . e($place) . '
IP-Adresse' . e($ip) . '
' . '

Das waren Sie? Dann müssen Sie nichts tun.

' . '

Das waren Sie nicht? Dann sichern Sie Ihr Konto sofort: Das Passwort wird ungültig, alle App-Anmeldungen werden beendet und die Administratoren erfahren davon.

' . '

Das war ich nicht – Konto sichern

' . '

Der Link gilt 7 Tage und kann einmal verwendet werden.

'; 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 = '

Anmeldung gesperrt

' . '

Für das Konto ' . e($email) . ' gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.

' . '

Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.

'; 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 = '

Konto gesichert

' . '

' . 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.

' . '

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“.

'; login_guard_notify_admins('Konto gesichert: ' . $staff['email'], $msg); send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('

Ihr Konto wurde gesichert

Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.

')); 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', ]; }