From a7577a234d7f74eda788913682fbefd6cc3fc455 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Thu, 5 Nov 2026 21:21:59 +0000 Subject: [PATCH] Upload via GUI (452 Dateien) --- DeskHub-Webseite/tests/passkey.php | 208 +++++++++++++ DeskHub-Webseite/tests/passkey_web.php | 282 +++++++++++++++++ DeskHub-Webseite/tests/permissions.php | 128 ++++++++ DeskHub-Webseite/tests/permissions_web.php | 285 ++++++++++++++++++ .../tests/plugin_website_apps.php | 25 ++ DeskHub-Webseite/tests/plugins.php | 32 ++ DeskHub-Webseite/tests/signout.php | 86 ++++++ DeskHub-Webseite/tests/signout_web.php | 172 +++++++++++ .../tests/sla_mail_recipients.php | 1 + DeskHub-Webseite/tests/sla_reopen_notify.php | 97 ++++++ 10 files changed, 1316 insertions(+) create mode 100644 DeskHub-Webseite/tests/passkey.php create mode 100644 DeskHub-Webseite/tests/passkey_web.php create mode 100644 DeskHub-Webseite/tests/permissions.php create mode 100644 DeskHub-Webseite/tests/permissions_web.php create mode 100644 DeskHub-Webseite/tests/signout.php create mode 100644 DeskHub-Webseite/tests/signout_web.php create mode 100644 DeskHub-Webseite/tests/sla_reopen_notify.php diff --git a/DeskHub-Webseite/tests/passkey.php b/DeskHub-Webseite/tests/passkey.php new file mode 100644 index 00000000..c59ac48a --- /dev/null +++ b/DeskHub-Webseite/tests/passkey.php @@ -0,0 +1,208 @@ +query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); +$pdo->exec('DELETE FROM staff'); +$ins = $pdo->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'); +$ins->execute(['Eva', 'eva@test.de', 'x', 'agent', $dept, $now]); $eva = (int) $pdo->lastInsertId(); +$ins->execute(['Max', 'max@test.de', 'x', 'agent', $dept, $now]); $max = (int) $pdo->lastInsertId(); +$staffRow = fn(int $id) => $pdo->query('SELECT * FROM staff WHERE id = ' . $id)->fetch(); + +require_once __DIR__ . '/fixtures/fake_authenticator.php'; +$GLOBALS['fa_rp'] = 'support.test'; +$GLOBALS['fa_origin'] = 'https://support.test'; +$bin = fn(string $b) => "\0BIN" . $b; + +$check(webauthn_cbor_decode(hex2bin('a201020326')) === [1 => 2, 3 => -7], 'CBOR-Leser liest eine COSE-Tabelle'); +$m = webauthn_cbor_decode(t_cbor(['fmt' => 'none', 'n' => 300, 'neg' => -5, 'b' => $bin("\x01\x02")])); +$check($m['fmt'] === 'none' && $m['n'] === 300 && $m['neg'] === -5 && $m['b'] === "\x01\x02", 'CBOR: Text, Zahlen, negative Zahlen und Bytes werden gelesen'); +$bad = false; +try { webauthn_cbor_decode("\x5f\x41"); } catch (Throwable $e) { $bad = true; } +$check($bad, 'CBOR: unbestimmte Länge und abgeschnittene Daten werden abgelehnt'); +$check(webauthn_b64u_decode(webauthn_b64u_encode("\xfb\xff\x00abc")) === "\xfb\xff\x00abc", 'base64url hin und zurück'); + +$rp = webauthn_rp(); +$check($rp['id'] === 'support.test' && $rp['origin'] === 'https://support.test' && $rp['secure'] === true, 'Relying Party stammt aus der Website-URL'); + +$register = function (FakeKey $k, int $sid, array $o = [], string $name = 'Laptop') use ($pdo, $staffRow): array { + $opt = webauthn_creation_options($pdo, $staffRow($sid)); + return webauthn_register_finish($pdo, $staffRow($sid), t_register($k, $opt, $o), $name); +}; +$login = function (FakeKey $k, int $sid, array $o = []) use ($pdo): array { + $opt = webauthn_request_options(); + return webauthn_login_finish($pdo, t_login($k, $opt, $sid, $o)); +}; + +// Subdomain: Wird DeskHub unter einer Subdomain der Website-URL aufgerufen, gilt diese Adresse (der Browser verlangt genau sie) +$oldHost = $_SERVER['HTTP_HOST'] ?? null; +set_setting('site_url', 'https://windelweb.org'); +foreach ([ + ['support.windelweb.org', 'support.windelweb.org', 'https://support.windelweb.org'], + ['SUPPORT.WindelWeb.org:8443', 'support.windelweb.org', 'https://support.windelweb.org:8443'], + ['support.windelweb.org:443', 'support.windelweb.org', 'https://support.windelweb.org'], + ['windelweb.org', 'windelweb.org', 'https://windelweb.org'], + ['evil.example', 'windelweb.org', 'https://windelweb.org'], + ['windelweb.org.evil.example', 'windelweb.org', 'https://windelweb.org'], + ['evilwindelweb.org', 'windelweb.org', 'https://windelweb.org'], + ['support.windelweb.org/../x', 'windelweb.org', 'https://windelweb.org'], + ['', 'windelweb.org', 'https://windelweb.org'], +] as [$h, $id, $origin]) { + $_SERVER['HTTP_HOST'] = $h; + $r = webauthn_rp(); + $check($r['id'] === $id && $r['origin'] === $origin, "Domain bei Host „{$h}“: {$r['id']} / {$r['origin']}"); +} +// Reverse-Proxy verändert den Host-Kopf: der Origin-Kopf des Browsers entscheidet +$_SERVER['HTTP_HOST'] = 'deskhub-intern:8080'; +$_SERVER['HTTP_ORIGIN'] = 'https://support.windelweb.org'; +$r = webauthn_rp(); +$check($r['id'] === 'support.windelweb.org' && $r['origin'] === 'https://support.windelweb.org', 'Origin-Kopf des Browsers gilt, wenn der Host-Kopf intern umgeschrieben wurde'); +$_SERVER['HTTP_ORIGIN'] = 'https://evil.example'; +$r = webauthn_rp(); +$check($r['id'] === 'windelweb.org', 'Fremder Origin-Kopf wird ignoriert'); +unset($_SERVER['HTTP_ORIGIN']); +if ($oldHost === null) unset($_SERVER['HTTP_HOST']); else $_SERVER['HTTP_HOST'] = $oldHost; +set_setting('site_url', 'https://support.test'); + +// ── Optionen ─────────────────────────────────────────────────────────────────── +$opt = webauthn_creation_options($pdo, $staffRow($eva)); +$check($opt['rp']['id'] === 'support.test' && $opt['authenticatorSelection']['userVerification'] === 'required' && $opt['authenticatorSelection']['residentKey'] === 'required', 'Registrierung verlangt Nutzerprüfung und einen auffindbaren Schlüssel'); +$check(strlen(webauthn_b64u_decode($opt['challenge'])) === 32 && $opt['user']['id'] !== '' && $opt['user']['name'] === 'eva@test.de' && $opt['user']['displayName'] === 'Eva', 'Challenge hat 32 Bytes, die Benutzerkennung ist gesetzt'); +$check(webauthn_user_handle($eva) !== webauthn_user_handle($max) && strlen(webauthn_user_handle($eva)) === 32, 'Benutzerkennungen sind je Mitarbeiter verschieden'); + +// ── Registrierung ────────────────────────────────────────────────────────────── +$ec = new FakeKey('ec'); +$r = $register($ec, $eva); +$check(!empty($r['ok']), 'Registrierung mit ES256 klappt: ' . json_encode($r)); +$check(webauthn_count_passkeys($pdo, $eva) === 1 && webauthn_count_passkeys($pdo, $max) === 0, 'Der Passkey gehört nur Eva'); +$row = webauthn_staff_passkeys($pdo, $eva)[0]; +$check($row['name'] === 'Laptop' && (int) $row['alg'] === -7 && str_contains($row['public_key'], 'BEGIN PUBLIC KEY'), 'Name, Algorithmus und Schlüssel sind gespeichert'); +$r = $register($ec, $max); +$check(empty($r['ok']) && str_contains((string) $r['error'], 'schon registriert'), 'Derselbe Passkey lässt sich nicht für ein zweites Konto eintragen'); +foreach ([ + 'falsche Challenge' => ['challenge' => webauthn_b64u_encode(random_bytes(32))], + 'falscher Origin' => ['origin' => 'https://evil.test'], + 'falsche Domain im Passkey' => ['rpId' => 'evil.test'], + 'ohne Nutzerprüfung' => ['flags' => 0x41], + 'ohne Anwesenheit' => ['flags' => 0x44], + 'falscher Vorgang' => ['type' => 'webauthn.get'], +] as $label => $o) { + $k = new FakeKey('ec'); + $r = $register($k, $max, $o); + $check(empty($r['ok']), 'Registrierung abgelehnt: ' . $label); +} +$check(webauthn_count_passkeys($pdo, $max) === 0, 'Abgelehnte Registrierungen hinterlassen nichts'); +// Challenge nur einmal verwendbar +$opt = webauthn_creation_options($pdo, $staffRow($max)); +$k = new FakeKey('ec'); +$cred = t_register($k, $opt); +$first = webauthn_register_finish($pdo, $staffRow($max), $cred, 'Handy'); +$k2 = new FakeKey('ec'); +$second = webauthn_register_finish($pdo, $staffRow($max), t_register($k2, $opt), 'Zweit'); +$check(!empty($first['ok']) && empty($second['ok']), 'Eine Challenge gilt nur für einen Versuch'); +$opt = webauthn_creation_options($pdo, $staffRow($eva)); +$check(count($opt['excludeCredentials']) === 1, 'Schon eingetragene Passkeys werden beim Registrieren ausgeschlossen'); +// Challenge eines anderen Mitarbeiters +$opt = webauthn_creation_options($pdo, $staffRow($eva)); +$r = webauthn_register_finish($pdo, $staffRow($max), t_register(new FakeKey('ec'), $opt), 'Fremd'); +$check(empty($r['ok']), 'Challenge eines anderen Mitarbeiters wird nicht angenommen'); +$_SESSION['webauthn_challenge'] = ['v' => 'x', 'type' => 'create', 'exp' => time() - 1, 'staff' => $eva]; +$check(webauthn_challenge_take('create', $eva) === null, 'Abgelaufene Challenge wird abgelehnt'); + +// ── Anmeldung ────────────────────────────────────────────────────────────────── +$r = $login($ec, $eva); +$check(!empty($r['ok']) && (int) $r['staff']['id'] === $eva, 'Anmeldung mit ES256 klappt und liefert das richtige Konto: ' . json_encode($r['error'] ?? '')); +$check(webauthn_staff_passkeys($pdo, $eva)[0]['last_used_at'] !== null && (int) webauthn_staff_passkeys($pdo, $eva)[0]['sign_count'] === 1, 'Zähler und „zuletzt benutzt“ werden gespeichert'); +$r = $login($ec, $eva, ['count' => 1]); +$check(empty($r['ok']) && str_contains((string) $r['error'], 'kopiert'), 'Zähler, der nicht wächst, wird als Kopie abgelehnt'); +$r = $login($ec, $eva); +$check(!empty($r['ok']), 'Danach klappt es mit wachsendem Zähler wieder'); +$login($ec, $eva, ['count' => 50]); +foreach ([ + 'falsche Challenge' => ['challenge' => webauthn_b64u_encode(random_bytes(32))], + 'falscher Origin' => ['origin' => 'https://evil.test'], + 'falsche Domain' => ['rpId' => 'evil.test'], + 'ohne Nutzerprüfung' => ['flags' => 0x01], + 'fremde Signatur' => ['signWith' => (new FakeKey('ec'))->res], + 'fremde Benutzerkennung' => ['userHandle' => webauthn_b64u_encode(webauthn_user_handle($max))], + 'falscher Vorgang' => ['type' => 'webauthn.create'], +] as $label => $o) { + $r = $login($ec, $eva, $o + ['count' => (int) $ec->count + 5]); + $check(empty($r['ok']), 'Anmeldung abgelehnt: ' . $label); +} +$r = $login(new FakeKey('ec'), $eva); +$check(empty($r['ok']), 'Unbekannter Passkey wird abgelehnt'); +$opt = webauthn_request_options(); +$cred = t_login($ec, $opt, $eva, ['count' => 200]); +$r1 = webauthn_login_finish($pdo, $cred); +$r2 = webauthn_login_finish($pdo, $cred); +$check(!empty($r1['ok']) && empty($r2['ok']), 'Dieselbe Antwort lässt sich nicht noch einmal verwenden'); + +// RS256 +$rsa = new FakeKey('rsa'); +$r = $register($rsa, $max, [], 'Sicherheitsschlüssel'); +$check(!empty($r['ok']), 'Registrierung mit RS256 klappt: ' . json_encode($r)); +$r = $login($rsa, $max); +$check(!empty($r['ok']) && (int) $r['staff']['id'] === $max, 'Anmeldung mit RS256 klappt'); +// Zähler 0 bei Geräten ohne Zähler (Sicherheitsschlüssel/iCloud): bleibt erlaubt +$zero = new FakeKey('ec'); +$register($zero, $max, [], 'Ohne Zähler'); +$r = $login($zero, $max, ['count' => 0]); +$r2 = $login($zero, $max, ['count' => 0]); +$check(!empty($r['ok']) && !empty($r2['ok']), 'Geräte, die immer 0 melden, bleiben anmeldbar'); + +// Inaktives Konto +$pdo->prepare('UPDATE staff SET is_active = 0 WHERE id = ?')->execute([$max]); +$r = $login($rsa, $max); +$check(empty($r['ok']), 'Ein deaktiviertes Konto kann sich nicht mit Passkey anmelden'); +$pdo->prepare('UPDATE staff SET is_active = 1 WHERE id = ?')->execute([$max]); + +// Nicht unterstützter Schlüssel +$bad = false; +try { webauthn_cose_to_pem([1 => 1, 3 => -8]); } catch (Throwable $e) { $bad = true; } +$check($bad, 'Nicht unterstützte Schlüsselarten werden abgelehnt'); + +// ── Entfernen ────────────────────────────────────────────────────────────────── +$n = webauthn_remove_all($pdo, $max); +$check($n === 3 && webauthn_count_passkeys($pdo, $max) === 0 && webauthn_count_passkeys($pdo, $eva) === 1, 'Administrator-Entfernen löscht alle Passkeys des Mitarbeiters und nur seine'); +$r = $login($rsa, $max); +$check(empty($r['ok']), 'Nach dem Entfernen ist die Anmeldung mit diesem Passkey nicht mehr möglich'); +$check(webauthn_remove_all($pdo, $max) === 0, 'Nochmaliges Entfernen ohne Passkeys ist harmlos'); + +// „Das war ich nicht“ entfernt Passkeys ebenfalls +$pdo->prepare('INSERT INTO login_events (event, staff_id, email, channel, ip, device, place, created_at, token_hash, token_used) VALUES (?,?,?,?,?,?,?,?,?,0)') + ->execute(['new_device', $eva, 'eva@test.de', 'web', '203.0.113.5', 'Test', 'Testort', $now, hash('sha256', 'x')]); +$evt = $pdo->query('SELECT * FROM login_events ORDER BY id DESC LIMIT 1')->fetch(); +login_guard_revoke($pdo, $evt); +$check(webauthn_count_passkeys($pdo, $eva) === 0, '„Das war ich nicht“ entfernt auch die Passkeys'); + +// Protokoll-Beschriftungen +$labels = login_guard_event_labels(); +$check(isset($labels['passkey_added'], $labels['passkey_removed']), 'Protokoll kennt „Passkey hinzugefügt/entfernt“'); + +echo $failures ? "FEHLGESCHLAGEN:\n - " . implode("\n - ", $failures) . "\n" : ''; +echo "Passkey-Test: " . ($total - count($failures)) . " von {$total} Prüfungen in Ordnung.\n"; +exit($failures ? 1 : 0); diff --git a/DeskHub-Webseite/tests/passkey_web.php b/DeskHub-Webseite/tests/passkey_web.php new file mode 100644 index 00000000..7c40ab0a --- /dev/null +++ b/DeskHub-Webseite/tests/passkey_web.php @@ -0,0 +1,282 @@ +getPathname(), strlen($root) + 1)); + if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue; + if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; } + @mkdir(dirname($tmp . '/' . $rel), 0755, true); + copy($f->getPathname(), $tmp . '/' . $rel); +} +@mkdir($tmp . '/uploads'); +@mkdir($tmp . '/config'); +file_put_contents($tmp . '/config/config.php', ' bin2hex(random_bytes(32)), + 'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'], + 'site' => ['name' => 'Test', 'url' => 'https://127.0.0.1:' . $webPort, 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'], + 'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'], +], true) . ';'); + +putenv('DESKHUB_CONFIG_JSON'); +putenv('DESKHUB_SQLITE_PATH'); +putenv('DESKHUB_TESTING'); +putenv('DESKHUB_API_ALLOW_INSECURE=1'); +require_once $root . '/database/sqlite_schema.php'; +require_once $root . '/database/migrate.php'; +$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$db->exec('PRAGMA foreign_keys = ON'); +$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql'))); +run_migrations($db, true); +$now = date('Y-m-d H:i:s'); +$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); +$db->exec('DELETE FROM staff'); +$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'); +foreach ([['Chef', 'chef@test.de', 'admin'], ['Eva', 'eva@test.de', 'agent'], ['Max', 'max@test.de', 'agent']] as [$n, $e, $r]) { + $ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]); +} +$evaId = (int) $db->query("SELECT id FROM staff WHERE email = 'eva@test.de'")->fetchColumn(); +$db = null; + +$failures = []; +$total = 0; +$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; }; +$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp); + +try { + for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); } + $pdo = function () use ($tmp): PDO { + $p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); + $p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + return $p; + }; + $call = function (string $method, string $path, array $form, string $jar, ?string $bearer = null, ?array $json = null) use ($webPort): array { + $ch = curl_init("http://127.0.0.1:$webPort$path"); + $h = ['Sec-Fetch-Mode: navigate']; + if ($json !== null) $h[] = 'Content-Type: application/json'; + if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer; + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_HEADER => true, CURLOPT_HTTPHEADER => $h]); + if ($jar !== '') { curl_setopt($ch, CURLOPT_COOKIEJAR, $jar); curl_setopt($ch, CURLOPT_COOKIEFILE, $jar); } + if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $json !== null ? json_encode($json) : http_build_query($form)); } + $raw = (string) curl_exec($ch); + $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); + curl_close($ch); + return [$s, substr($raw, $hs), substr($raw, 0, $hs)]; + }; + $login = function (string $email) use ($call): string { + $jar = tempnam(sys_get_temp_dir(), 'sow'); + [, $lp] = $call('GET', '/staff/login.php', [], $jar); + preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m); + $call('POST', '/staff/login.php', ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234'], $jar); + return $jar; + }; + $csrf = function (string $path, string $jar) use ($call): string { + [, $p] = $call('GET', $path, [], $jar); + preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $mm); + return $mm[1] ?? ''; + }; + $loggedIn = function (string $jar) use ($call): bool { + [$s, $b] = $call('GET', '/staff/profile.php?tab=security', [], $jar); + return $s === 200 && str_contains($b, 'Von allen Geräten abmelden'); + }; + $apiToken = function (string $email) use ($call): string { + [, $b] = $call('POST', '/api/staff/v1/login.php', [], '', null, ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test-Gerät']); + return (string) (json_decode($b, true)['token'] ?? ''); + }; + $apiOk = fn(string $tok) => $call('GET', '/api/staff/v1/me.php', [], '', $tok)[0]; + + require_once $root . '/includes/webauthn.php'; + require_once __DIR__ . '/fixtures/fake_authenticator.php'; + $GLOBALS['fa_rp'] = '127.0.0.1'; + $GLOBALS['fa_origin'] = 'https://127.0.0.1:' . $webPort; + $chefId = (int) $pdo()->query("SELECT id FROM staff WHERE email = 'chef@test.de'")->fetchColumn(); + $maxId = (int) $pdo()->query("SELECT id FROM staff WHERE email = 'max@test.de'")->fetchColumn(); + $count = fn(int $sid) => (int) $pdo()->query("SELECT COUNT(*) FROM staff_passkeys WHERE staff_id = {$sid}")->fetchColumn(); + $pk = function (string $jar, string $csrf, array $f) use ($call): array { + [$s, $b] = $call('POST', '/staff/passkey.php', ['csrf_token' => $csrf] + $f, $jar); + return [$s, json_decode($b, true) ?: []]; + }; + $addPasskey = function (string $jar, FakeKey $k, string $name = 'Laptop', string $pw = 'Sehr-Sicher-1234') use ($csrf, $pk): array { + $t = $csrf('/staff/profile.php?tab=security', $jar); + [$s, $o] = $pk($jar, $t, ['action' => 'register_options', 'password' => $pw]); + if ($s !== 200) return [$s, $o]; + return $pk($jar, $t, ['action' => 'register_verify', 'name' => $name, 'credential' => json_encode(t_register($k, $o['options']))]); + }; + $passkeyLogin = function (FakeKey $k, array $o = []) use ($call, $pk): array { + $jar = tempnam(sys_get_temp_dir(), 'pkw'); + [, $lp] = $call('GET', '/staff/login.php', [], $jar); + preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m); + [$s, $opt] = $pk($jar, $m[1] ?? '', ['action' => 'login_options']); + if ($s !== 200) return [$jar, $s, $opt]; + [$s, $r] = $pk($jar, $m[1], ['action' => 'login_verify', 'credential' => json_encode(t_login($k, $opt['options'], 0, $o + ['userHandle' => '']))]); + return [$jar, $s, $r]; + }; + + $jarEva = $login('eva@test.de'); + $jarChef = $login('chef@test.de'); + $jarMax = $login('max@test.de'); + [, $page] = $call('GET', '/staff/profile.php?tab=security', [], $jarEva); + $check(str_contains($page, 'Passkeys') && str_contains($page, 'Noch kein Passkey eingerichtet') && str_contains($page, '/assets/js/passkey.js'), 'Profil zeigt den Bereich „Passkeys“ ohne Eintrag'); + [, $lp] = $call('GET', '/staff/login.php', [], ''); + $check(str_contains($lp, 'Mit Passkey anmelden') && str_contains($lp, 'passkey.js'), 'Anmeldeseite hat den Knopf „Mit Passkey anmelden“'); + + $check(preg_match('/id="pk_name"[^>]*autocomplete="off"[^>]*data-bwignore/', $page) === 1, 'Namensfeld des Passkeys wird von Passwortmanagern nicht ausgefüllt'); + // Registrieren + $evaKey = new FakeKey('ec'); + [$s, $r] = $addPasskey($jarEva, $evaKey, 'Laptop', 'falsches-Passwort'); + $check($s === 403 && empty($r['ok']) && $count($evaId) === 0, 'Registrierung ohne richtiges Passwort wird abgelehnt (HTTP ' . $s . ')'); + [$s, $r] = $pk($jarEva, '', ['action' => 'register_options', 'password' => 'Sehr-Sicher-1234']); + $check($s === 403, 'ohne CSRF-Token wird nichts angenommen'); + [$s, $b] = $call('POST', '/staff/passkey.php', ['action' => 'register_options', 'password' => 'x'], ''); + $check($s === 403, 'ohne Sitzung wird nichts angenommen'); + $t = $csrf('/staff/profile.php?tab=security', $jarEva); + [$s, $r] = $pk('', $t, ['action' => 'register_options', 'password' => 'Sehr-Sicher-1234']); + $check($s === 403 || $s === 401, 'ohne Anmeldung gibt es keine Registrierungsoptionen (HTTP ' . $s . ')'); + [$s, $r] = $addPasskey($jarEva, $evaKey); + $check($s === 200 && !empty($r['ok']) && $count($evaId) === 1, 'Registrierung mit richtigem Passwort klappt: ' . json_encode($r)); + [, $page] = $call('GET', '/staff/profile.php?tab=security', [], $jarEva); + $check(str_contains($page, 'Laptop') && str_contains($page, 'Passkey hinzugefügt'), 'Profil zeigt den Passkey und die Erfolgsmeldung'); + [$s, $r] = $addPasskey($jarEva, $evaKey, 'Nochmal'); + $check(empty($r['ok']) && $count($evaId) === 1, 'Derselbe Passkey lässt sich nicht doppelt eintragen'); + + // Anmelden ohne Passwort + [$jar, $s, $r] = $passkeyLogin($evaKey); + $check($s === 200 && !empty($r['ok']) && $loggedIn($jar), 'Anmeldung mit Passkey klappt und die Sitzung ist gültig: ' . json_encode($r)); + $ev = $pdo()->query("SELECT COUNT(*) FROM login_events WHERE staff_id = {$evaId} AND event IN ('success','new_device','new_place')")->fetchColumn(); + $check((int) $ev >= 1, 'Protokoll: die Anmeldung steht im Anmelde-Wächter'); + @unlink($jar); + [$jar, $s, $r] = $passkeyLogin($evaKey, ['count' => 1]); + $check($s === 401 && !$loggedIn($jar), 'Zähler, der nicht wächst, wird abgelehnt (HTTP ' . $s . ')'); + @unlink($jar); + [$jar, $s, $r] = $passkeyLogin(new FakeKey('ec')); + $check($s === 401 && !$loggedIn($jar), 'unbekannter Passkey wird abgelehnt'); + @unlink($jar); + [$jar, $s, $r] = $passkeyLogin($evaKey, ['signWith' => (new FakeKey('ec'))->res, 'count' => 20]); + $check($s === 401 && !$loggedIn($jar), 'falsche Signatur wird abgelehnt'); + @unlink($jar); + [$jar, $s, $r] = $passkeyLogin($evaKey, ['origin' => 'https://evil.example', 'count' => 30]); + $check($s === 401 && !$loggedIn($jar), 'falsche Herkunft wird abgelehnt'); + @unlink($jar); + // Antwort ohne vorherige Optionen + $jarN = tempnam(sys_get_temp_dir(), 'pkw'); + [, $lp] = $call('GET', '/staff/login.php', [], $jarN); + preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m); + $fake = t_login($evaKey, ['challenge' => webauthn_b64u_encode(random_bytes(32))], 0, ['count' => 40, 'userHandle' => '']); + [$s, $r] = $pk($jarN, $m[1], ['action' => 'login_verify', 'credential' => json_encode($fake)]); + $check($s === 401 && !$loggedIn($jarN), 'ohne vorherige Anfrage (keine Challenge in der Sitzung) wird abgelehnt'); + @unlink($jarN); + // Deaktiviertes Konto + $p = $pdo(); $p->exec("UPDATE staff SET is_active = 0 WHERE id = {$evaId}"); + [$jar, $s] = $passkeyLogin($evaKey, ['count' => 50]); + $check($s === 401 && !$loggedIn($jar), 'deaktiviertes Konto kann sich nicht mit Passkey anmelden'); + @unlink($jar); + $p->exec("UPDATE staff SET is_active = 1 WHERE id = {$evaId}"); + + // Administrator entfernt die Passkeys eines Mitarbeiters + [$s, $r] = $addPasskey($jarMax, new FakeKey('ec'), 'Max Handy'); + $check($count($maxId) === 1, 'Max hat einen Passkey'); + [, $page] = $call('GET', '/staff/staff_manage.php', [], $jarChef); + $check(str_contains($page, 'Passkeys entfernen (1)') && str_contains($page, '🔑 1 Passkey'), 'Mitarbeiterverwaltung zeigt „Passkeys entfernen (1)“ und die Anzahl'); + // Ein normaler Mitarbeiter darf das nicht + $t = $csrf('/staff/profile.php', $jarMax); + $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkeys', 'id' => $evaId], $jarMax); + $check($count($evaId) === 1, 'Ein normaler Mitarbeiter kann keine fremden Passkeys entfernen'); + $t = $csrf('/staff/staff_manage.php', $jarChef); + [$s] = $call('POST', '/staff/staff_manage.php', ['csrf_token' => $t, 'action' => 'remove_passkeys', 'id' => $maxId], $jarChef); + $check($s === 302 && $count($maxId) === 0 && $count($evaId) === 1, 'Administrator entfernt Max’ Passkeys, Evas bleiben (HTTP ' . $s . ')'); + $ev = $pdo()->query("SELECT detail FROM login_events WHERE event = 'passkey_removed' AND staff_id = {$maxId}")->fetchColumn(); + $check(str_contains((string) $ev, 'Administrator'), 'Protokoll nennt den Administrator'); + $aud = $pdo()->query("SELECT COUNT(*) FROM admin_audit_log WHERE action LIKE 'Passkeys von Mitarbeiter #{$maxId} entfernt%'")->fetchColumn(); + $check((int) $aud === 1, 'Admin-Protokoll hat den Eintrag'); + [, $page] = $call('GET', '/staff/staff_manage.php', [], $jarChef); + $check(!str_contains($page, 'Passkeys entfernen (0)') && str_contains($page, 'Passkeys entfernen (1)'), 'Danach steht der Knopf nur noch bei Eva'); + $maxKey = new FakeKey('ec'); + $addPasskey($jarMax, $maxKey, 'Neu'); + [$jar, $s] = $passkeyLogin($maxKey); + $check($s === 200 && $loggedIn($jar), 'Nach dem Entfernen kann der Mitarbeiter einen neuen Passkey einrichten und nutzen'); + @unlink($jar); + + // Mitarbeiter entfernt den eigenen Passkey (Passwort nötig) + $pid = (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$evaId}")->fetchColumn(); + $t = $csrf('/staff/profile.php?tab=security', $jarEva); + $call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'delete_passkey', 'passkey_id' => $pid, 'confirm_password' => 'falsch'], $jarEva); + $check($count($evaId) === 1, 'Eigener Passkey: mit falschem Passwort bleibt er bestehen'); + $call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'delete_passkey', 'passkey_id' => $pid, 'confirm_password' => 'Sehr-Sicher-1234'], $jarEva); + $check($count($evaId) === 0, 'Eigener Passkey: mit richtigem Passwort wird er entfernt'); + [$jar, $s] = $passkeyLogin($evaKey, ['count' => 60]); + $check($s === 401 && !$loggedIn($jar), 'Der entfernte Passkey meldet nicht mehr an'); + @unlink($jar); + // Fremden Passkey kann man nicht über die eigene ID löschen + $maxPid = (int) $pdo()->query("SELECT id FROM staff_passkeys WHERE staff_id = {$maxId}")->fetchColumn(); + $t = $csrf('/staff/profile.php?tab=security', $jarEva); + $call('POST', '/staff/profile.php?tab=security', ['csrf_token' => $t, 'action' => 'delete_passkey', 'passkey_id' => $maxPid, 'confirm_password' => 'Sehr-Sicher-1234'], $jarEva); + $check($count($maxId) === 1, 'Ein Passkey eines anderen Mitarbeiters lässt sich nicht löschen'); + + // Gestaltung: der Knopf steht im selben Block wie die Knöpfe der Anmelde-Plugins + [, $lp] = $call('GET', '/staff/login.php', [], ''); + $check(preg_match('#id="login-alt"[^>]*>\s*