Files
deskhub/DeskHub-Webseite/includes/login_guard.php
T
2026-11-05 21:21:44 +00:00

356 lines
21 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
/**
* 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',
];
}