Files
deskhub/DeskHub-Webseite/includes/login_guard.php
T
2026-10-30 21:49:58 +00:00

283 lines
16 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')));
}
/** 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 12px;font-size:20px;color:#1a202c">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();
$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;
login_guard_record($pdo, 'locked', $staff ? (int) $staff['id'] : null, $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt');
$ip = client_ip();
$msg = '<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">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>';
if ($staff) send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg));
login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, $staff ? (string) $staff['email'] : null);
} 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]);
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 12px;font-size:20px;color:#1a202c">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 12px;font-size:20px;color:#1a202c">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',
];
}