Upload via GUI (452 Dateien)

This commit is contained in:
2026-11-05 21:21:48 +00:00
parent e90c1a4563
commit 10578475f0
14 changed files with 543 additions and 18 deletions
+8 -1
View File
@@ -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 ───────────────────────────────────────────────────────────────
@@ -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<array<string,mixed>>
*/
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];
}
+1 -1
View File
@@ -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(
'<h2 style="margin:0 0 12px;font-size:20px;color:#1a202c">Sie vertreten ' . e($name) . '</h2>'
'<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;">Sie vertreten ' . e($name) . '</h2>'
. '<p style="margin:0 0 12px;color:#334155">' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen. Neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen, solange die Abwesenheit dauert.</p>'
. '<ul style="color:#334155">' . $list . '</ul>'));
}
+14 -7
View File
@@ -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],
+345
View File
@@ -0,0 +1,345 @@
<?php
/**
* Passkeys (WebAuthn) für die Anmeldung der Mitarbeiter, ohne Fremdbibliothek: CBOR lesen, Registrierung und Anmeldung prüfen.
*
* Unterstützt: ES256 (ECDSA P-256) und RS256, Attestation „none“ (bei Passkeys üblich, die Herkunft des Geräts wird nicht geprüft).
* Verlangt wird immer Nutzerprüfung (Fingerabdruck, Gesicht oder PIN): Ein Passkey ersetzt deshalb Passwort und 2FA-Code.
* Relying-Party-ID und Origin kommen aus der eingestellten Website-URL (Einstellungen → Anzeigename & URL); Passkeys gelten nur dafür.
*/
/** Passkeys eingeschaltet? (Adminportal → Anmelde-Wächter) */
function webauthn_enabled(): bool
{
return get_setting('passkeys_enabled', '1') === '1';
}
function webauthn_b64u_encode(string $raw): string
{
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
function webauthn_b64u_decode(string $s): string
{
$s = strtr($s, '-_', '+/');
$pad = strlen($s) % 4;
if ($pad) $s .= str_repeat('=', 4 - $pad);
$r = base64_decode($s, true);
return $r === false ? '' : $r;
}
/** Minimaler CBOR-Leser (genug für Attestation-Objekt und COSE-Schlüssel). @return mixed */
function webauthn_cbor_decode(string $d, int &$o = 0, int $depth = 0)
{
if ($depth > 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<array<string,mixed>> */
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<string,mixed>,passkey?:array<string,mixed>}
*/
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;
}