diff --git a/DeskHub-Webseite/includes/ticket_extras.php b/DeskHub-Webseite/includes/ticket_extras.php index d475c048..f01935f6 100644 --- a/DeskHub-Webseite/includes/ticket_extras.php +++ b/DeskHub-Webseite/includes/ticket_extras.php @@ -76,7 +76,14 @@ function ticket_snooze_wake_due(PDO $pdo): int } catch (Throwable $e) { return 0; // Spalte fehlt (Datenbank noch nicht aktualisiert) } + $woken = 0; foreach ($due as $t) { + // Erledigte Tickets (gelöst oder geschlossen) brauchen keine Wiedervorlage mehr: still aufräumen, keine Benachrichtigung + if (in_array((string) $t['status'], ['resolved', 'closed'], true)) { + $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL WHERE id = ?')->execute([$t['id']]); + continue; + } + $woken++; $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = ? WHERE id = ?')->execute([db_now(), $t['id']]); log_activity($pdo, (int) $t['id'], null, 'Wiedervorlage fällig'); $to = (int) ($t['snoozed_by'] ?: ($t['assigned_staff_id'] ?? 0)); @@ -88,7 +95,7 @@ function ticket_snooze_wake_due(PDO $pdo): int } } } - return count($due); + return $woken; } // ── Beobachter ─────────────────────────────────────────────────────────────── diff --git a/DeskHub-Webseite/includes/update_security.php b/DeskHub-Webseite/includes/update_security.php index bceff5a9..dfd361df 100644 --- a/DeskHub-Webseite/includes/update_security.php +++ b/DeskHub-Webseite/includes/update_security.php @@ -81,3 +81,88 @@ function update_http_headers(): array [$token] = update_token_info(); return $token !== '' ? ['Authorization: token ' . $token] : []; } + +/** + * Gehört diese Datei aus dem Update-Paket nicht auf die Installation? Plugins, Uploads, Backups und App-Dateien gehören dem System, die Plugin-Webseite + * und Entwickler-Ordner gehören nicht auf Kunden-Installationen. Ausnahme: Das Handbuch (docs/DeskHub-Handbuch.pdf) kommt mit jedem Update (Adminportal → Hilfe). + */ +function update_path_is_skipped(string $relNorm): bool +{ + // Die Konfiguration gehört der Installation: nichts aus config/ wird je überschrieben oder neu angelegt (auch nicht bei anderer Groß-/Kleinschreibung) + if (preg_match('#^(\./)*config(/|$)#i', $relNorm) || preg_match('#(^|/)(\.env|config\.php)$#i', $relNorm) && !str_contains($relNorm, '/')) return true; + if ($relNorm === 'docs/DeskHub-Handbuch.pdf' || $relNorm === 'tools/fix-permissions.sh') return false; + return (bool) (preg_match('#^(plugins|uploads|backups|app_downloads)/#', $relNorm) + || preg_match('#^(plugin-webseite|tools|tests|docs|desktop|mobile|theme-packages|vorschaubilder|\.gitea|\.github)/#', $relNorm)); +} + + +// ─── Updates von der Plugin-Webseite ───────────────────────────────────────── + +/** + * Adresse der Update-Schnittstelle auf der offiziellen Plugin-Webseite (app-release.php neben dem Katalog) oder '' ohne offiziellen Katalog. + * Der Zugangsschlüssel wird nur an diesen Server geschickt (catalog_access_headers). + */ +function update_publisher_endpoint(array $query): string +{ + $base = function_exists('catalog_defaults') ? (string) (catalog_defaults()['plugins_url'] ?? '') : ''; + $parts = parse_url($base); + if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return ''; + $path = rtrim(str_replace('\\', '/', dirname((string) ($parts['path'] ?? '/'))), '/'); + return 'https://' . $parts['host'] . (isset($parts['port']) ? ':' . (int) $parts['port'] : '') . $path . '/app-release.php?' . http_build_query(['platform' => 'server'] + $query); +} + +/** + * Die auf der Plugin-Webseite veröffentlichten System-Updates, neueste zuerst. Leer, wenn die Seite nicht antwortet oder nichts + * veröffentlicht ist (dann greift die Ausweichquelle). Jeder Eintrag hat dieselben Felder wie ein Gitea-Release (tag_name, published_at, body) + * plus sha256, size, signature und source = 'publisher'. + * + * @return list> + */ +function update_publisher_releases(): array +{ + static $cache = null; + if ($cache !== null && !isset($GLOBALS['deskhub_http_get'])) return $cache; + $url = update_publisher_endpoint(['list' => 1]); + $out = []; + if ($url !== '') { + [$status, $body] = plugin_http_get($url, 1048576, 8); + $data = $status === 200 && is_string($body) ? json_decode($body, true) : null; + foreach ((array) ($data['releases'] ?? []) as $r) { + $v = (string) ($r['version'] ?? ''); + if (!is_array($r) || !preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $v) || !preg_match('/^[0-9a-f]{64}$/i', (string) ($r['sha256'] ?? ''))) continue; + $out[] = ['tag_name' => $v, 'published_at' => (string) ($r['created_at'] ?? ''), 'body' => (string) ($r['notes'] ?? ''), 'sha256' => strtolower((string) $r['sha256']), + 'size' => (int) ($r['size'] ?? 0), 'signature' => (string) ($r['signature'] ?? ''), 'source' => 'publisher']; + } + usort($out, fn($a, $b) => version_compare($b['tag_name'], $a['tag_name'])); + } + return $cache = $out; +} + +/** Ein Eintrag aus update_publisher_releases() zu einem Versions-Tag (mit oder ohne „v“) oder null. */ +function update_publisher_find(string $tag): ?array +{ + $v = ltrim(trim($tag), 'vV'); + foreach (update_publisher_releases() as $r) if ($r['tag_name'] === $v) return $r; + return null; +} + +/** + * Lädt das Paket einer Version von der Plugin-Webseite und prüft Prüfsumme und (bei eingebautem Herausgeber-Schlüssel) Signatur. + * + * @return array{0:?string,1:?string} [Pfad zur ZIP-Datei, Fehlertext]; bei Fehler ist die Datei schon gelöscht + */ +function update_publisher_fetch(array $release): array +{ + $url = update_publisher_endpoint(['version' => $release['tag_name'], 'download' => 1]); + if ($url === '') return [null, 'Die Plugin-Webseite ist nicht eingetragen.']; + [$status, $body, $err] = plugin_http_get($url, 300 * 1048576, 180); + if ($status !== 200 || !is_string($body)) return [null, 'Download von der Plugin-Webseite fehlgeschlagen' . ($err ? ': ' . $err : " (HTTP $status).")]; + if (!hash_equals((string) $release['sha256'], hash('sha256', $body))) return [null, 'Die Prüfsumme des Pakets stimmt nicht. Es wird nicht installiert.']; + $tmp = tempnam(sys_get_temp_dir(), 'dh_') . '.zip'; + file_put_contents($tmp, $body); + if (update_signature_required()) { + $problem = update_verify_package((string) $release['tag_name'], $tmp, (string) $release['signature']); + if ($problem !== null) { @unlink($tmp); return [null, $problem]; } + } + return [$tmp, null]; +} diff --git a/DeskHub-Webseite/includes/vacation.php b/DeskHub-Webseite/includes/vacation.php index 9542c6a0..b2bbfe0d 100644 --- a/DeskHub-Webseite/includes/vacation.php +++ b/DeskHub-Webseite/includes/vacation.php @@ -71,7 +71,7 @@ function vacation_hand_over(PDO $pdo, int $staffId): int $mail->execute([$to]); $addr = (string) $mail->fetchColumn(); if ($addr !== '') send_mail($addr, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', email_wrap( - '

Sie vertreten ' . e($name) . '

' + '

Sie vertreten ' . e($name) . '

' . '

' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen. Neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen, solange die Abwesenheit dauert.

' . '')); } diff --git a/DeskHub-Webseite/includes/web_handoff.php b/DeskHub-Webseite/includes/web_handoff.php index ebe80a9a..d38afd23 100644 --- a/DeskHub-Webseite/includes/web_handoff.php +++ b/DeskHub-Webseite/includes/web_handoff.php @@ -27,13 +27,20 @@ function web_handoff_pages(array $staff): array ['imap_accounts', 'Postfächer (E-Mail-Abruf)', '/staff/imap_accounts.php', 'Verwaltung', $perm('manage_email_addresses')], // Nur Administratoren ['settings', 'Einstellungen', '/staff/settings.php', 'Verwaltung', $isAdmin], - ['recurring_tickets', 'Wiederkehrende Tickets', '/staff/recurring_tickets.php', 'Verwaltung', $isAdmin], - ['canned_responses', 'Textbausteine', '/staff/canned_responses.php', 'Verwaltung', $isAdmin], - ['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $isAdmin], - ['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $isAdmin], - ['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $isAdmin], - ['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $isAdmin], - ['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $isAdmin], + ['recurring_tickets', 'Wiederkehrende Tickets', '/staff/recurring_tickets.php', 'Verwaltung', $perm('manage_automation')], + ['canned_responses', 'Textbausteine', '/staff/canned_responses.php', 'Verwaltung', $perm('manage_canned')], + ['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $perm('manage_custom_fields')], + ['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $perm('manage_custom_fields')], + ['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $perm('manage_site_pages')], + ['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $perm('manage_ticket_templates')], + ['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $perm('manage_status_page')], + ['languages', 'Sprachen', '/staff/languages.php', 'Verwaltung', $perm('manage_languages')], + ['mail_queue', 'Mail-Warteschlange', '/staff/mail_queue.php', 'Verwaltung', $perm('view_mail_queue')], + ['login_log', 'Anmelde-Wächter', '/staff/login_log.php', 'System', $perm('view_login_guard')], + ['problems', 'Problemmanagement', '/staff/problems.php', 'Verwaltung', $perm('manage_problems')], + ['customers', 'Kundenverzeichnis', '/staff/customers.php', 'Verwaltung', $perm('manage_customers')], + ['banlist', 'Sperrliste', '/staff/banlist.php', 'Allgemein', $perm('manage_banlist')], + ['help', 'Hilfe und Handbuch', '/staff/help.php', 'System', $perm('view_help')], ['webhooks', 'Webhooks', '/staff/webhooks.php', 'Verwaltung', $isAdmin], ['api_keys', 'API-Schlüssel', '/staff/api_keys.php', 'Verwaltung', $isAdmin], ['plugins', 'Plugins und Themes', '/staff/plugins.php', 'System', $isAdmin], diff --git a/DeskHub-Webseite/includes/webauthn.php b/DeskHub-Webseite/includes/webauthn.php new file mode 100644 index 00000000..c4c3d830 --- /dev/null +++ b/DeskHub-Webseite/includes/webauthn.php @@ -0,0 +1,345 @@ + 8 || $o >= strlen($d)) throw new RuntimeException('CBOR ungültig'); + $b = ord($d[$o++]); + $major = $b >> 5; + $info = $b & 31; + if ($major === 7) { + if ($info === 20) return false; + if ($info === 21) return true; + if ($info === 22 || $info === 23) return null; + throw new RuntimeException('CBOR: nicht unterstützter Wert'); + } + if ($info < 24) { + $n = $info; + } elseif ($info === 24) { + if ($o + 1 > strlen($d)) throw new RuntimeException('CBOR zu kurz'); + $n = ord($d[$o++]); + } elseif ($info === 25) { + if ($o + 2 > strlen($d)) throw new RuntimeException('CBOR zu kurz'); + $n = unpack('n', substr($d, $o, 2))[1]; $o += 2; + } elseif ($info === 26) { + if ($o + 4 > strlen($d)) throw new RuntimeException('CBOR zu kurz'); + $n = unpack('N', substr($d, $o, 4))[1]; $o += 4; + } elseif ($info === 27) { + if ($o + 8 > strlen($d)) throw new RuntimeException('CBOR zu kurz'); + $n = unpack('J', substr($d, $o, 8))[1]; $o += 8; + if ($n < 0) throw new RuntimeException('CBOR: Zahl zu groß'); + } else { + throw new RuntimeException('CBOR: unbestimmte Länge nicht unterstützt'); + } + switch ($major) { + case 0: return $n; + case 1: return -1 - $n; + case 2: + case 3: + if ($n > strlen($d) - $o) throw new RuntimeException('CBOR zu kurz'); + $s = substr($d, $o, $n); $o += $n; + return $s; + case 4: + if ($n > 1000) throw new RuntimeException('CBOR: Liste zu lang'); + $a = []; + for ($i = 0; $i < $n; $i++) $a[] = webauthn_cbor_decode($d, $o, $depth + 1); + return $a; + case 5: + if ($n > 1000) throw new RuntimeException('CBOR: Tabelle zu lang'); + $m = []; + for ($i = 0; $i < $n; $i++) { + $k = webauthn_cbor_decode($d, $o, $depth + 1); + $m[is_int($k) ? $k : (string) $k] = webauthn_cbor_decode($d, $o, $depth + 1); + } + return $m; + case 6: + return webauthn_cbor_decode($d, $o, $depth + 1); + } + throw new RuntimeException('CBOR ungültig'); +} + +function webauthn_der_len(int $n): string +{ + if ($n < 128) return chr($n); + $b = ltrim(pack('N', $n), "\0"); + return chr(0x80 | strlen($b)) . $b; +} + +function webauthn_der(int $tag, string $content): string +{ + return chr($tag) . webauthn_der_len(strlen($content)) . $content; +} + +function webauthn_der_int(string $raw): string +{ + $raw = ltrim($raw, "\0"); + if ($raw === '' || (ord($raw[0]) & 0x80)) $raw = "\0" . $raw; + return webauthn_der(0x02, $raw); +} + +function webauthn_pem(string $der): string +{ + return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n"; +} + +/** COSE-Schlüssel (ES256 oder RS256) in eine PEM-Datei für OpenSSL umwandeln. @return array{0:string,1:int} PEM und Algorithmus */ +function webauthn_cose_to_pem(array $cose): array +{ + $kty = $cose[1] ?? null; + $alg = $cose[3] ?? null; + if ($kty === 2 && $alg === -7 && ($cose[-1] ?? null) === 1 && strlen((string) ($cose[-2] ?? '')) === 32 && strlen((string) ($cose[-3] ?? '')) === 32) { + $spki = hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200') . "\x04" . $cose[-2] . $cose[-3]; + return [webauthn_pem($spki), -7]; + } + if ($kty === 3 && $alg === -257 && is_string($cose[-1] ?? null) && is_string($cose[-2] ?? null) && strlen($cose[-1]) >= 128) { + $rsaKey = webauthn_der(0x30, webauthn_der_int($cose[-1]) . webauthn_der_int($cose[-2])); + $alg = hex2bin('300d06092a864886f70d0101010500'); + $spki = webauthn_der(0x30, $alg . webauthn_der(0x03, "\0" . $rsaKey)); + return [webauthn_pem($spki), -257]; + } + throw new RuntimeException('Dieser Passkey-Typ wird nicht unterstützt (erlaubt: ES256, RS256).'); +} + +/** + * Relying Party: ['id' => Domain, 'origin' => Schema://Domain[:Port], 'secure' => bool, 'name' => Anzeigename]. + * Grundlage ist die Website-URL (Einstellungen → Anzeigename & URL). Wird DeskHub unter einer Subdomain dieser Adresse aufgerufen + * (Website-URL https://beispiel.de, aufgerufen unter https://support.beispiel.de), gilt die Adresse, unter der die Seite gerade aufgerufen wird: + * Der Browser verlangt genau diese Domain. Fremde Domains werden ignoriert, dann gilt die Website-URL. + */ +function webauthn_rp(): array +{ + $u = parse_url(base_url()); + $siteHost = strtolower((string) ($u['host'] ?? '')); + $scheme = strtolower((string) ($u['scheme'] ?? 'https')); + $host = $siteHost; + $port = isset($u['port']) ? (int) $u['port'] : null; + // Mögliche Adressen, unter denen die Seite aufgerufen wird. Der Origin-Kopf stammt vom Browser (nicht fälschbar durch die Seite) und ist + // zuverlässig, auch wenn ein Reverse-Proxy den Host-Kopf verändert; X-Forwarded-Host zählt nur von einem vertrauenswürdigen Proxy. + $candidates = []; + $originHdr = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? '')); + if ($originHdr !== '' && ($oh = parse_url($originHdr)) && !empty($oh['host'])) $candidates[] = strtolower((string) $oh['host']) . (isset($oh['port']) ? ':' . (int) $oh['port'] : ''); + if (function_exists('request_from_trusted_proxy') && request_from_trusted_proxy() && !empty($_SERVER['HTTP_X_FORWARDED_HOST'])) { + $candidates[] = strtolower(trim(explode(',', (string) $_SERVER['HTTP_X_FORWARDED_HOST'])[0])); + } + $candidates[] = strtolower(trim((string) ($_SERVER['HTTP_HOST'] ?? ''))); + foreach ($candidates as $req) { + if ($req === '' || !preg_match('/^([a-z0-9]([a-z0-9.-]*[a-z0-9])?)(?::(\d{1,5}))?$/', $req, $m)) continue; + $reqHost = $m[1]; + if ($siteHost !== '' && ($reqHost === $siteHost || str_ends_with($reqHost, '.' . $siteHost))) { + $host = $reqHost; + $port = isset($m[3]) && $m[3] !== '' ? (int) $m[3] : null; + if ($port !== null && (($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) $port = null; + break; + } + } + $origin = $scheme . '://' . $host . ($port !== null ? ':' . $port : ''); + $secure = $scheme === 'https' || $host === 'localhost'; + return ['id' => $host, 'origin' => $origin, 'secure' => $secure, 'name' => (string) get_setting('display_name', config()['site']['name'] ?? 'DeskHub')]; +} + +/** Feste Kennung des Mitarbeiters im Passkey (keine Personendaten). */ +function webauthn_user_handle(int $staffId): string +{ + return hash_hmac('sha256', 'deskhub-passkey-user-' . $staffId, (string) (config()['app_key'] ?? 'deskhub'), true); +} + +function webauthn_challenge_new(string $type, ?int $staffId = null): string +{ + $c = random_bytes(32); + $_SESSION['webauthn_challenge'] = ['v' => webauthn_b64u_encode($c), 'type' => $type, 'exp' => time() + 300, 'staff' => $staffId]; + return webauthn_b64u_encode($c); +} + +/** Merkt die Challenge nur einmal: Ein zweiter Versuch mit derselben Challenge scheitert. */ +function webauthn_challenge_take(string $type, ?int $staffId = null): ?string +{ + $c = $_SESSION['webauthn_challenge'] ?? null; + unset($_SESSION['webauthn_challenge']); + if (!is_array($c) || ($c['type'] ?? '') !== $type || (int) ($c['exp'] ?? 0) < time()) return null; + if ($staffId !== null && (int) ($c['staff'] ?? 0) !== $staffId) return null; + return (string) $c['v']; +} + +/** @return list> */ +function webauthn_staff_passkeys(PDO $pdo, int $staffId): array +{ + try { + $s = $pdo->prepare('SELECT * FROM staff_passkeys WHERE staff_id = ? ORDER BY id'); + $s->execute([$staffId]); + return $s->fetchAll(); + } catch (Throwable $e) { + return []; + } +} + +function webauthn_count_passkeys(PDO $pdo, int $staffId): int +{ + return count(webauthn_staff_passkeys($pdo, $staffId)); +} + +/** Optionen für die Registrierung (navigator.credentials.create). */ +function webauthn_creation_options(PDO $pdo, array $staff): array +{ + $rp = webauthn_rp(); + $exclude = []; + foreach (webauthn_staff_passkeys($pdo, (int) $staff['id']) as $k) $exclude[] = ['type' => 'public-key', 'id' => $k['credential_id']]; + return [ + 'challenge' => webauthn_challenge_new('create', (int) $staff['id']), + 'rp' => ['id' => $rp['id'], 'name' => $rp['name']], + 'user' => ['id' => webauthn_b64u_encode(webauthn_user_handle((int) $staff['id'])), 'name' => (string) $staff['email'], 'displayName' => (string) $staff['name']], + 'pubKeyCredParams' => [['type' => 'public-key', 'alg' => -7], ['type' => 'public-key', 'alg' => -257]], + 'timeout' => 120000, + 'attestation' => 'none', + 'excludeCredentials' => $exclude, + 'authenticatorSelection' => ['residentKey' => 'required', 'requireResidentKey' => true, 'userVerification' => 'required'], + ]; +} + +/** Optionen für die Anmeldung ohne Eingabe der Adresse (navigator.credentials.get). */ +function webauthn_request_options(): array +{ + return [ + 'challenge' => webauthn_challenge_new('get'), + 'rpId' => webauthn_rp()['id'], + 'timeout' => 120000, + 'userVerification' => 'required', + 'allowCredentials' => [], + ]; +} + +/** @return array{0:bool,1:string} gemeinsame Prüfung von clientDataJSON */ +function webauthn_check_client_data(string $json, string $type, string $challenge): array +{ + $cd = json_decode($json, true); + if (!is_array($cd)) return [false, 'Antwort des Geräts ist ungültig.']; + if (($cd['type'] ?? '') !== $type) return [false, 'Falscher Vorgang.']; + if (!hash_equals($challenge, (string) ($cd['challenge'] ?? ''))) return [false, 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.']; + if (($cd['origin'] ?? '') !== webauthn_rp()['origin']) return [false, 'Die Adresse stimmt nicht mit der Website-URL aus den Einstellungen überein (' . webauthn_rp()['origin'] . ').']; + if (!empty($cd['crossOrigin'])) return [false, 'Eingebettete Seiten sind nicht erlaubt.']; + return [true, '']; +} + +/** + * Registrierung abschließen. $cred = ['id' => …, 'response' => ['clientDataJSON' => b64u, 'attestationObject' => b64u]]. + * @return array{ok:bool,error?:string,id?:int} + */ +function webauthn_register_finish(PDO $pdo, array $staff, array $cred, string $name): array +{ + $challenge = webauthn_challenge_take('create', (int) $staff['id']); + if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.']; + try { + $clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? '')); + [$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.create', $challenge); + if (!$ok) return ['ok' => false, 'error' => $err]; + $att = webauthn_cbor_decode(webauthn_b64u_decode((string) ($cred['response']['attestationObject'] ?? ''))); + if (!is_array($att) || !is_string($att['authData'] ?? null)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.']; + $ad = $att['authData']; + if (strlen($ad) < 55) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.']; + if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.']; + $flags = ord($ad[32]); + if (!($flags & 0x01)) return ['ok' => false, 'error' => 'Anwesenheit nicht bestätigt.']; + if (!($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).']; + if (!($flags & 0x40)) return ['ok' => false, 'error' => 'Das Gerät hat keinen Schlüssel geliefert.']; + $signCount = unpack('N', substr($ad, 33, 4))[1]; + $idLen = unpack('n', substr($ad, 53, 2))[1]; + if ($idLen < 16 || $idLen > 1023 || strlen($ad) < 55 + $idLen) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.']; + $credId = substr($ad, 55, $idLen); + $o = 55 + $idLen; + $cose = webauthn_cbor_decode($ad, $o); + if (!is_array($cose)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.']; + [$pem, $alg] = webauthn_cose_to_pem($cose); + if (!openssl_pkey_get_public($pem)) return ['ok' => false, 'error' => 'Der öffentliche Schlüssel ist ungültig.']; + $idText = webauthn_b64u_encode($credId); + if (hash_equals($idText, (string) ($cred['id'] ?? '')) === false) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.']; + $dup = $pdo->prepare('SELECT COUNT(*) FROM staff_passkeys WHERE credential_id = ?'); + $dup->execute([$idText]); + if ((int) $dup->fetchColumn() > 0) return ['ok' => false, 'error' => 'Dieser Passkey ist schon registriert.']; + $name = trim(mb_substr($name, 0, 80)); + if ($name === '') $name = 'Passkey ' . date('d.m.Y'); + $pdo->prepare('INSERT INTO staff_passkeys (staff_id, credential_id, public_key, alg, sign_count, name, created_at) VALUES (?,?,?,?,?,?,?)') + ->execute([(int) $staff['id'], $idText, $pem, $alg, $signCount, $name, db_now()]); + return ['ok' => true, 'id' => (int) $pdo->lastInsertId()]; + } catch (Throwable $e) { + return ['ok' => false, 'error' => $e instanceof RuntimeException ? $e->getMessage() : 'Der Passkey konnte nicht geprüft werden.']; + } +} + +/** + * Anmeldung prüfen. $cred = ['id', 'response' => ['clientDataJSON', 'authenticatorData', 'signature', 'userHandle']] (alles base64url). + * @return array{ok:bool,error?:string,staff?:array,passkey?:array} + */ +function webauthn_login_finish(PDO $pdo, array $cred): array +{ + $challenge = webauthn_challenge_take('get'); + if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.']; + try { + $st = $pdo->prepare('SELECT * FROM staff_passkeys WHERE credential_id = ?'); + $st->execute([(string) ($cred['id'] ?? '')]); + $key = $st->fetch(); + if (!$key) return ['ok' => false, 'error' => 'Dieser Passkey ist hier nicht registriert.']; + $clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? '')); + [$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.get', $challenge); + if (!$ok) return ['ok' => false, 'error' => $err]; + $ad = webauthn_b64u_decode((string) ($cred['response']['authenticatorData'] ?? '')); + $sig = webauthn_b64u_decode((string) ($cred['response']['signature'] ?? '')); + if (strlen($ad) < 37 || $sig === '') return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.']; + if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.']; + $flags = ord($ad[32]); + if (!($flags & 0x01) || !($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).']; + $pub = openssl_pkey_get_public((string) $key['public_key']); + if (!$pub) return ['ok' => false, 'error' => 'Der gespeicherte Schlüssel ist ungültig.']; + $data = $ad . hash('sha256', $clientData, true); + if (openssl_verify($data, $sig, $pub, OPENSSL_ALGO_SHA256) !== 1) return ['ok' => false, 'error' => 'Die Signatur des Passkeys ist ungültig.']; + $handle = (string) ($cred['response']['userHandle'] ?? ''); + if ($handle !== '' && !hash_equals(webauthn_b64u_encode(webauthn_user_handle((int) $key['staff_id'])), $handle)) { + return ['ok' => false, 'error' => 'Der Passkey gehört zu einem anderen Konto.']; + } + // Zähler gegen kopierte Passkeys: Er muss wachsen, wenn das Gerät überhaupt zählt + $count = unpack('N', substr($ad, 33, 4))[1]; + $old = (int) $key['sign_count']; + if (($count !== 0 || $old !== 0) && $count <= $old) return ['ok' => false, 'error' => 'Der Passkey wurde möglicherweise kopiert und ist abgelehnt worden. Bitte einen Administrator informieren.']; + $sd = $pdo->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1'); + $sd->execute([(int) $key['staff_id']]); + $staff = $sd->fetch(); + if (!$staff) return ['ok' => false, 'error' => 'Das Konto ist nicht aktiv.']; + $pdo->prepare('UPDATE staff_passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?')->execute([$count, db_now(), (int) $key['id']]); + return ['ok' => true, 'staff' => $staff, 'passkey' => $key]; + } catch (Throwable $e) { + return ['ok' => false, 'error' => 'Der Passkey konnte nicht geprüft werden.']; + } +} + +/** Alle Passkeys eines Mitarbeiters löschen. @return int Anzahl */ +function webauthn_remove_all(PDO $pdo, int $staffId): int +{ + $n = webauthn_count_passkeys($pdo, $staffId); + if ($n > 0) $pdo->prepare('DELETE FROM staff_passkeys WHERE staff_id = ?')->execute([$staffId]); + return $n; +} diff --git a/DeskHub-Webseite/staff/availability.php b/DeskHub-Webseite/staff/availability.php index 73838ea2..8592dfe6 100644 --- a/DeskHub-Webseite/staff/availability.php +++ b/DeskHub-Webseite/staff/availability.php @@ -38,7 +38,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['status']) && !isset($ } // ── Schichtplan (Admin-Seite) ───────────────────────────────────────────────── -$admin = $staff['role'] === 'admin' ? $staff : null; +$admin = has_permission($staff, 'manage_availability') ? $staff : null; if ($_SERVER['REQUEST_METHOD'] === 'POST') { verify_csrf(); diff --git a/DeskHub-Webseite/staff/banlist.php b/DeskHub-Webseite/staff/banlist.php index eb52f210..685eece7 100644 --- a/DeskHub-Webseite/staff/banlist.php +++ b/DeskHub-Webseite/staff/banlist.php @@ -1,6 +1,6 @@ prepare('UPDATE users SET organization_id = ? WHERE id = ?')->execute([$orgId, $userId]); diff --git a/DeskHub-Webseite/staff/export.php b/DeskHub-Webseite/staff/export.php index 94e11b10..16f3e0de 100644 --- a/DeskHub-Webseite/staff/export.php +++ b/DeskHub-Webseite/staff/export.php @@ -1,6 +1,6 @@ 0) { @ob_end_clean(); } + header('Content-Type: application/pdf'); + header('Content-Disposition: attachment; filename="' . MANUAL_FILE . '"'); + header('Content-Length: ' . $info['size']); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: private, no-store'); + readfile(manual_path()); + exit; +} + +$info = manual_info(); +$pageTitle = 'Hilfe'; +$activePage = 'help'; +require __DIR__ . '/_header.php'; +?> +

Hilfe für Administratoren

+

Diese Seite sehen nur Administratoren: das Handbuch zum Herunterladen und eine Kurzhilfe für den Alltag.

+ +
+
Handbuch
+ +
DeskHub-Handbuch (PDF)
+
Zu DeskHub · · Stand
+ ⬇ Handbuch herunterladen +
SHA-256:
+
Das Handbuch beschreibt jede Funktion mit Bildern (Beispieldaten) und wird mit jedem Update aktualisiert.
+ +
Das Handbuch ist in dieser Installation nicht vorhanden
+

Es gehört zum Update-Paket (Datei docs/). Spielt das nächste vollständige Update ein, dann erscheint es hier.

+ +
+ +
+

Erste Schritte

+
    +
  1. Abteilungen anlegen – jedes Ticket gehört zu genau einer.
  2. +
  3. E-Mail-Versand (SMTP) einrichten und eine Test-Mail senden.
  4. +
  5. Postfach für den E-Mail-Abruf einrichten und testen.
  6. +
  7. Automatisierung (Cron-Jobs) prüfen.
  8. +
  9. Mitarbeiter anlegen und Rollen vergeben; Administratoren sollten 2FA oder einen Passkey nutzen.
  10. +
  11. Sicherheitsprüfung ausführen und Backups einrichten.
  12. +
+
+ +
+

Wenn etwas nicht funktioniert

+ + + + + + + + +
Eine automatische Mail kommt nicht anAdmin-Protokoll (Fehlermeldung des Anbieters), Mail-Warteschlange, Test-Mail unter Einstellungen → E-Mail.
E-Mails werden nicht zu TicketsPostfächer → „Testen“ zeigt die Meldung des Mail-Servers.
Rotes oder gelbes Banner obenStörungsmeldung der Überwachung (Postfach, Abruf, Versand). Details unter E-Mail-Adressen.
„Datenbank veraltet“System-Update → „Datenbank aktualisieren“.
Ein Plugin macht ProblemeUnter Plugins deaktivieren; geht die Seite nicht mehr auf, die Datei plugins/.disabled anlegen.
Ein Mitarbeiter ist gesperrt oder hat sein Gerät verlorenMitarbeiter → Menü ⋯: „Sperre aufheben“, „Überall abmelden“, „Passkeys entfernen“.
Anmeldungen und Sperren ansehenAnmelde-Wächter mit Protokoll und Einstellungen (auch Passkeys ein- und ausschalten).
+
+ +
+

Diese Installation

+ + + + +
DeskHub-Version
PHP
Website-URL
+
+