From 4e1e6d85f8bb522583ac8b2a18d02f2a2a00a880 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 30 Oct 2026 21:50:16 +0000 Subject: [PATCH] Upload via GUI (360 Dateien) --- DeskHub-Webseite/tests/integrations_api.php | 6 + DeskHub-Webseite/tests/login_guard.php | 136 ++++++ DeskHub-Webseite/tests/plugin_authentik.php | 458 +++++++++++++++++++ DeskHub-Webseite/tests/plugin_login.php | 261 +++++++++++ DeskHub-Webseite/tests/plugin_virustotal.php | 349 ++++++++++++++ 5 files changed, 1210 insertions(+) create mode 100644 DeskHub-Webseite/tests/login_guard.php create mode 100644 DeskHub-Webseite/tests/plugin_authentik.php create mode 100644 DeskHub-Webseite/tests/plugin_login.php create mode 100644 DeskHub-Webseite/tests/plugin_virustotal.php diff --git a/DeskHub-Webseite/tests/integrations_api.php b/DeskHub-Webseite/tests/integrations_api.php index ade653dd..96d96336 100644 --- a/DeskHub-Webseite/tests/integrations_api.php +++ b/DeskHub-Webseite/tests/integrations_api.php @@ -116,6 +116,12 @@ $check($s === 422 && ($b['error']['code'] ?? '') === 'INVALID_EMAIL', 'ungültig $check($s === 200 && ($b['ticket']['status'] ?? '') === 'open' && count($b['ticket']['messages'] ?? []) === 1, 'Status per API abgefragt'); [$s, $b] = $call('GET', '?number=' . urlencode($apiNumber) . '&email=fremd@example.test', null, $created['key']); $check($s === 404, 'falsche E-Mail-Adresse: Ticket bleibt verborgen (404)'); +// Entwarnung (Minecraft-Plugin): Ticket wird gelöst, der Text steht unverändert (nicht doppelt maskiert) in der internen Notiz +[$s, $b] = $call('POST', '', ['action' => 'resolve', 'number' => $apiNumber, 'email' => 'eva@example.test', 'message' => 'Der Server "Lobby" ist wieder erreichbar & ok.'], $created['key']); +$check($s === 200 && ($b['status'] ?? '') === 'resolved', 'Entwarnung per API löst das Ticket'); +$noteRow = $pdo->prepare("SELECT message FROM ticket_threads WHERE ticket_id = (SELECT id FROM tickets WHERE ticket_number = ?) AND is_internal_note = 1 ORDER BY id DESC LIMIT 1"); +$noteRow->execute([$apiNumber]); +$check($noteRow->fetchColumn() === 'Der Server "Lobby" ist wieder erreichbar & ok.', 'Notiz bei Entwarnung ist Klartext ohne HTML-Entities: ' . json_encode($noteRow->fetchColumn())); $readOnly = api_key_create($pdo, 'Nur lesen', false, true, '', $staffId); [$s] = $call('POST', '', ['name' => 'Eva', 'email' => 'eva@example.test', 'subject' => 'x', 'message' => 'y'], $readOnly['key']); $check($s === 403, 'Schlüssel ohne Recht zum Anlegen: 403'); diff --git a/DeskHub-Webseite/tests/login_guard.php b/DeskHub-Webseite/tests/login_guard.php new file mode 100644 index 00000000..5ec1913e --- /dev/null +++ b/DeskHub-Webseite/tests/login_guard.php @@ -0,0 +1,136 @@ +prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)") + ->execute(['Test Person', 'person@firma.example', password_hash('geheim-123', PASSWORD_DEFAULT), 'agent', db_now()]); +$sid = (int) $pdo->lastInsertId(); +$pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,1,?)") + ->execute(['Chef', 'chef@firma.example', password_hash('x', PASSWORD_DEFAULT), 'admin', db_now()]); +$staff = fn() => $pdo->query('SELECT * FROM staff WHERE id = ' . $sid)->fetch(); +$events = fn(string $ev = '') => (int) $pdo->query('SELECT COUNT(*) FROM login_events' . ($ev !== '' ? " WHERE event = '$ev'" : ''))->fetchColumn(); +$chrome = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; +$phone = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36'; +$as = function (string $ip, string $ua, ?string $cookie): void { + $_SERVER['REMOTE_ADDR'] = $ip; + $_SERVER['HTTP_USER_AGENT'] = $ua; + unset($_COOKIE[LOGIN_GUARD_COOKIE]); + if ($cookie !== null) $_COOKIE[LOGIN_GUARD_COOKIE] = $cookie; +}; +$dev1 = str_repeat('a', 32); +$dev2 = str_repeat('b', 32); + +// Beschreibung und Ort +$check(login_guard_describe_device($chrome) === 'Chrome auf Windows', 'Gerätename: Chrome auf Windows'); +$check(login_guard_describe_device($phone) === 'Chrome auf Android', 'Gerätename: Chrome auf Android'); +$check(login_guard_place('203.0.113.9')[0] === 'N:203.0' && login_guard_place('10.0.0.5')[0] === 'local', 'Ort: Netz der IP bzw. lokales Netz'); +$check(str_starts_with(login_guard_place('2a02:810d:1::5')[0], 'N6:2a02:810d'), 'Ort: IPv6-Netz'); + +// 1) Erste Anmeldung: still gemerkt, keine Warnung +$as('203.0.113.9', $chrome, $dev1); +$GLOBALS['login_guard_last_link'] = null; +$check(login_guard_success($staff(), 'web') === 'first', 'Erste Anmeldung wird still gemerkt'); +$check($GLOBALS['login_guard_last_link'] === null && $events('success') === 1, 'Erste Anmeldung: keine Warnung, ein Protokolleintrag'); + +// 2) Bekanntes Gerät, gleicher Ort +$check(login_guard_success($staff(), 'web') === 'known' && $GLOBALS['login_guard_last_link'] === null, 'Bekanntes Gerät: keine Warnung'); + +// 3) Neues Gerät: Warnung mit Link +$as('203.0.113.9', $phone, $dev2); +$check(login_guard_success($staff(), 'web') === 'new_device', 'Neues Gerät wird erkannt'); +$link = (string) $GLOBALS['login_guard_last_link']; +$check(str_contains($link, '/staff/login_alert.php?t='), 'Warn-Link wurde erzeugt'); +$token1 = substr($link, strpos($link, 't=') + 2); +$check($events('new_device') === 1, 'Protokoll: Neues Gerät'); +$check(login_guard_success($staff(), 'web') === 'known', 'Das neue Gerät ist danach bekannt'); + +// 4) Bekanntes Gerät, neuer Ort +$GLOBALS['login_guard_last_link'] = null; +$as('198.51.100.7', $chrome, $dev1); +$check(login_guard_success($staff(), 'web') === 'new_place' && $GLOBALS['login_guard_last_link'] !== null, 'Neuer Ort vom bekannten Gerät wird gemeldet'); +$token2 = substr((string) $GLOBALS['login_guard_last_link'], -48); +$GLOBALS['login_guard_last_link'] = null; +$as('192.0.2.77', $chrome, $dev1); +set_setting('login_guard_warn_place', '0'); +$check(login_guard_success($staff(), 'web') === 'known' && $GLOBALS['login_guard_last_link'] === null, 'Ort-Warnung lässt sich ausschalten'); +set_setting('login_guard_warn_place', '1'); + +// 5) App: Gerät = Gerätename +$as('203.0.113.9', 'okhttp/4.12', null); +$check(login_guard_success($staff(), 'app', 'Pixel 8') === 'new_device', 'App mit neuem Gerätenamen wird gemeldet'); +$check(login_guard_success($staff(), 'app', 'Pixel 8') === 'known', 'Gleiches App-Gerät ist bekannt'); +$check((int) $pdo->query("SELECT COUNT(*) FROM login_devices WHERE channel = 'app'")->fetchColumn() === 1, 'App-Gerät gemerkt'); + +// 6) Fehlversuche und Sperre +login_guard_failed(null, 'unbekannt@x.example', 'web', 'Unbekannte E-Mail-Adresse'); +login_guard_failed($staff(), 'person@firma.example', 'web', 'Falsches Passwort'); +$check($events('failed') === 2, 'Fehlversuche stehen im Protokoll'); +login_guard_locked('person@firma.example', 'web'); +login_guard_locked('person@firma.example', 'web'); +$check($events('locked') === 1, 'Sperre wird je Sperrzeit nur einmal protokolliert'); +$pdo->exec("UPDATE login_events SET created_at = '2000-01-01 00:00:00' WHERE event = 'locked'"); +login_guard_locked('person@firma.example', 'web'); +$check($events('locked') === 2, 'Nach der Sperrzeit wird eine neue Sperre wieder protokolliert'); +set_setting('login_guard_max_attempts', '999'); +$check(login_guard_max_attempts() === 20, 'Anzahl Fehlversuche ist auf 20 begrenzt'); +set_setting('login_guard_max_attempts', '5'); + +// 7) Link prüfen, einmal verwenden +$check(login_guard_find_alert($pdo, 'zzz') === null && login_guard_find_alert($pdo, str_repeat('0', 48)) === null, 'Ungültiger Link wird abgelehnt'); +$ev = login_guard_find_alert($pdo, $token1); +$check($ev !== null && (int) $ev['staff_id'] === $sid, 'Gültiger Link findet den Eintrag'); +$pdo->prepare('UPDATE login_events SET created_at = ? WHERE id = ?')->execute(['2000-01-01 00:00:00', (int) $ev['id']]); +$check(login_guard_find_alert($pdo, $token1) === null, 'Link ist nach 7 Tagen abgelaufen'); + +// 8) Konto sichern +$pdo->prepare('INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at, last_used_at) VALUES (?,?,?,?,?,?)') + ->execute([$sid, hash('sha256', 't'), 'Pixel 8', date('Y-m-d H:i:s', time() + 3600), db_now(), db_now()]); +$ev2 = login_guard_find_alert($pdo, $token2); +$check($ev2 !== null, 'Zweiter Link ist gültig'); +$_SESSION = ['staff_login_at' => time() - 100]; +$check(login_guard_session_revoked($sid) === false, 'Vor dem Sichern ist die Sitzung gültig'); +$check(login_guard_revoke($pdo, $ev2) === true, 'Konto sichern gelingt'); +$check(!password_verify('geheim-123', (string) $staff()['password_hash']), 'Passwort ist ungültig'); +$check((int) $pdo->query('SELECT COUNT(*) FROM mobile_api_tokens WHERE staff_id = ' . $sid)->fetchColumn() === 0, 'App-Anmeldungen sind beendet'); +$check((int) $pdo->query('SELECT COUNT(*) FROM login_devices WHERE staff_id = ' . $sid)->fetchColumn() === 0, 'Gemerkte Geräte sind vergessen'); +$check($events('revoked') === 1, 'Protokoll: Konto gesichert'); +$check(login_guard_session_revoked($sid) === true, 'Ältere Sitzungen sind ungültig'); +$_SESSION = ['staff_login_at' => time() + 5]; +$check(login_guard_session_revoked($sid) === false, 'Eine spätere Anmeldung bleibt gültig'); +$check(login_guard_find_alert($pdo, $token2) === null, 'Der Link ist nur einmal verwendbar'); + +// 9) Ausschalter +set_setting('login_guard', '0'); +$before = $events(); +$as('203.0.113.9', $chrome, str_repeat('c', 32)); +$check(login_guard_success($staff(), 'web') === 'off' && $events() === $before, 'Ausgeschaltet wird nichts gemerkt oder protokolliert'); +login_guard_failed(null, 'x@y.example', 'web', 'x'); +$check($events() === $before, 'Ausgeschaltet gibt es auch keine Fehlversuch-Einträge'); +set_setting('login_guard', '1'); + +// 10) Aufbewahrung +$pdo->exec("UPDATE login_events SET created_at = '2000-01-01 00:00:00' WHERE id IN (SELECT id FROM login_events ORDER BY id LIMIT 3)"); +$n0 = $events(); +$old = (int) $pdo->query("SELECT COUNT(*) FROM login_events WHERE created_at < '2001-01-01'")->fetchColumn(); +login_guard_purge($pdo); +$check($old >= 3 && $events() === $n0 - $old, 'Alte Einträge werden nach der Aufbewahrungsfrist gelöscht'); + +@unlink($db); +if ($failures) { + fwrite(STDERR, "Anmelde-Wächter-Prüfung fehlgeschlagen:\n- " . implode("\n- ", $failures) . "\n"); + exit(1); +} +echo "Anmelde-Wächter-Prüfungen bestanden.\n"; diff --git a/DeskHub-Webseite/tests/plugin_authentik.php b/DeskHub-Webseite/tests/plugin_authentik.php new file mode 100644 index 00000000..383d89c0 --- /dev/null +++ b/DeskHub-Webseite/tests/plugin_authentik.php @@ -0,0 +1,458 @@ +open($zipPath, ZipArchive::CREATE); +foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) { + $zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1))); +} +$zip->close(); +$r = plugin_install_zip($pdo, $zipPath); +$check($r['ok'] && $r['slug'] === 'authentik', 'Plugin lässt sich installieren: ' . json_encode($r)); +$act = plugin_set_active($pdo, 'authentik', true, ['ui.menu', 'auth.login', 'auth.customer']); +$check($act['ok'], 'Plugin lässt sich mit den Berechtigungen aktivieren: ' . json_encode($act)); +$state = &plugin_state(); +$state['booted'] = false; +plugins_boot(); +$check(isset(plugin_state()['loaded']['authentik']), 'Plugin wird geladen'); +$check(isset(plugin_state()['logins']['authentik']['start'], plugin_state()['logins']['authentik']['callback']), 'Start und Rückkehr sind als Anmelde-Adressen registriert'); +$menus = json_encode(plugin_state()['menus'] ?? []); +$check(str_contains($menus, 'authentik') && str_contains($menus, 'einstellungen'), 'Menüpunkt für Administratoren ist registriert'); + +// Mitarbeiter +$ins = $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,?,?)"); +$ins->execute(['Anna', 'Anna@Firma.Example', 'x', 'agent', 1, db_now()]); +$anna = (int) $pdo->lastInsertId(); +$ins->execute(['Karl', 'karl@firma.example', 'x', 'agent', 0, db_now()]); + +// Ohne Einrichtung: kein Knopf, Anmeldung nicht möglich +$check(!authentik_ready() && plugin_filter('staff.login.buttons', '', []) === '', 'Ohne Einrichtung gibt es keinen Knopf'); +$check(isset(authentik_login_start([])['error']), 'Ohne Einrichtung lässt sich die Anmeldung nicht starten'); + +// Einrichten +$issuer = 'https://auth.firma.example/application/o/deskhub'; +plugin_set_setting('authentik', 'issuer', $issuer . '/'); +plugin_set_setting('authentik', 'client_id', 'deskhub-client'); +plugin_set_setting('authentik', 'client_secret_enc', encrypt_secret('streng-geheim')); +plugin_set_setting('authentik', 'enabled', '1'); +$cfg = authentik_config(); +$check($cfg['issuer'] === $issuer && $cfg['client_secret'] === 'streng-geheim' && authentik_ready(), 'Einstellungen werden gelesen (Geheimnis entschlüsselt, Schrägstrich am Ende entfernt)'); +$check(!str_contains((string) get_setting('plugin.authentik.client_secret_enc', ''), 'streng-geheim'), 'Das Geheimnis liegt nicht im Klartext in der Datenbank'); +$btn = plugin_filter('staff.login.buttons', '', []); +$check(str_contains($btn, 'Mit authentik anmelden') && str_contains($btn, '/staff/plugin_login.php?p=authentik&e=start'), 'Eingerichtet erscheint der Knopf mit dem richtigen Ziel'); +plugin_set_setting('authentik', 'label', 'SSO Login'); +$check(str_contains(plugin_filter('staff.login.buttons', '', []), 'SSO <b>Login</b>'), 'Die Beschriftung wird maskiert'); +plugin_set_setting('authentik', 'label', ''); + +// ── authentik als Funktion nachgebaut ── +$idp = ['claims' => [], 'status' => 200, 'tokenStatus' => 200, 'userinfo' => null, 'challenge' => '', 'nonce' => '', 'calls' => []]; +$jwt = fn(array $c) => 'eyJhbGciOiJub25lIn0.' . authentik_b64url(json_encode($c)) . '.sig'; +$GLOBALS['authentik_http'] = function (string $method, string $url, array $opts) use (&$idp, $issuer, $jwt): array { + $idp['calls'][] = "$method $url"; + if ($url === $issuer . '/.well-known/openid-configuration') { + return [$idp['status'], json_encode(['issuer' => $idp['issuerOverride'] ?? $issuer . '/', 'authorization_endpoint' => 'https://auth.firma.example/application/o/authorize/', 'token_endpoint' => 'https://auth.firma.example/application/o/token/', 'userinfo_endpoint' => 'https://auth.firma.example/application/o/userinfo/']), null]; + } + if ($url === 'https://auth.firma.example/application/o/token/') { + $f = $opts['form'] ?? []; + // PKCE: der Prüfwert muss zur Herausforderung aus dem Start passen + $pkceOk = authentik_b64url(hash('sha256', (string) ($f['code_verifier'] ?? ''), true)) === $idp['challenge']; + if ($f['client_secret'] !== 'streng-geheim' || $f['client_id'] !== 'deskhub-client' || $f['grant_type'] !== 'authorization_code' || $f['code'] !== 'gueltiger-code' || !$pkceOk || !str_contains($f['redirect_uri'], '/staff/plugin_login.php?p=authentik&e=callback')) return [400, json_encode(['error' => 'invalid_grant']), null]; + $claims = $idp['claims'] + ['iss' => $issuer . '/', 'aud' => 'deskhub-client', 'exp' => time() + 300, 'nonce' => $idp['nonce'], 'sub' => 'u1', 'email' => 'anna@firma.example', 'email_verified' => true]; + return [$idp['tokenStatus'], json_encode(['id_token' => $jwt(array_filter($claims, fn($v) => $v !== '__weg__')), 'access_token' => 'at-1']), null]; + } + if ($url === 'https://auth.firma.example/application/o/userinfo/') { + return [200, json_encode($idp['userinfo'] ?? []), null]; + } + return [404, '', null]; +}; +$begin = function () use (&$idp): array { + $_SESSION = []; + $res = authentik_login_start([]); + if (empty($res['redirect'])) return [null, [], $res]; + parse_str((string) parse_url($res['redirect'], PHP_URL_QUERY), $q); + $idp['challenge'] = (string) ($q['code_challenge'] ?? ''); + $idp['nonce'] = (string) ($q['nonce'] ?? ''); + return [$res['redirect'], $q, $res]; +}; +$back = fn(array $query) => authentik_login_callback(['query' => $query]); + +// Start +[$redirect, $q] = $begin(); +$check($redirect !== null && str_starts_with($redirect, 'https://auth.firma.example/application/o/authorize/?'), 'Start leitet zu authentik'); +$check(($q['response_type'] ?? '') === 'code' && ($q['client_id'] ?? '') === 'deskhub-client' && ($q['scope'] ?? '') === 'openid email profile' && ($q['code_challenge_method'] ?? '') === 'S256', 'Anfrage enthält Code-Ablauf, Client-ID, Bereiche und PKCE'); +$check(strlen((string) ($q['state'] ?? '')) === 32 && strlen((string) ($q['nonce'] ?? '')) === 32 && strlen((string) ($q['code_challenge'] ?? '')) >= 43, 'Zustand, Nonce und Prüfwert sind zufällig und lang genug'); +$check(($q['redirect_uri'] ?? '') === authentik_redirect_uri() && str_ends_with(authentik_redirect_uri(), '/staff/plugin_login.php?p=authentik&e=callback'), 'Die Rückkehr-Adresse stimmt'); +[, $q2] = $begin(); +$check($q2['state'] !== $q['state'] && $q2['nonce'] !== $q['nonce'], 'Jeder Start erzeugt neue Werte'); + +// Erfolgreiche Anmeldung +[, $q] = $begin(); +$ok = $back(['code' => 'gueltiger-code', 'state' => $q['state']]); +$check(($ok['staff_id'] ?? 0) === $anna && ($ok['via'] ?? '') === 'authentik', 'Erfolgreiche Rückkehr meldet den Mitarbeiter an (E-Mail ohne Beachtung der Groß-/Kleinschreibung): ' . json_encode($ok)); +$check(!isset($_SESSION['authentik']), 'Die Sitzungswerte sind danach gelöscht'); +$again = $back(['code' => 'gueltiger-code', 'state' => $q['state']]); +$check(isset($again['error']) && !isset($again['staff_id']), 'Dieselbe Rückkehr funktioniert nicht ein zweites Mal'); + +// Fehlerfälle +$fail = function (string $what, array $claims, array $queryOverride = [], ?callable $tweak = null) use (&$idp, $begin, $back, $check) { + $idp['claims'] = $claims; $idp['userinfo'] = null; + if ($tweak) $tweak(); + [, $q] = $begin(); + $res = $back($queryOverride + ['code' => 'gueltiger-code', 'state' => $q['state']]); + $check(isset($res['error']) && !isset($res['staff_id']), $what . ': ' . json_encode($res, JSON_UNESCAPED_UNICODE)); + $idp['claims'] = []; $idp['issuerOverride'] = null; $idp['tokenStatus'] = 200; + return $res; +}; +$fail('falscher Zustand (state) wird abgelehnt', [], ['state' => 'falsch']); +$fail('fehlender Code wird abgelehnt', [], ['code' => '']); +$fail('ungültiger Code wird abgelehnt', [], ['code' => 'geklaut']); +$fail('falsche Nonce wird abgelehnt', ['nonce' => 'fremde-nonce']); +$fail('falscher Aussteller wird abgelehnt', ['iss' => 'https://boese.example/o/x/']); +$fail('falsche Zielgruppe (aud) wird abgelehnt', ['aud' => 'andere-app']); +$fail('Zielgruppe als Liste ohne uns wird abgelehnt', ['aud' => ['a', 'b']]); +$fail('falscher Berechtigter (azp) wird abgelehnt', ['aud' => ['deskhub-client', 'x'], 'azp' => 'x']); +$fail('abgelaufenes Token wird abgelehnt', ['exp' => time() - 600]); +$fail('E-Mail nicht bestätigt wird bei fremder Kennung abgelehnt', ['email_verified' => false, 'sub' => 'u-unv']); +$fail('fehlende E-Mail wird abgelehnt', ['email' => '__weg__']); +$fail('ungültige E-Mail wird abgelehnt', ['email' => 'kein-mail']); +$fail('Fehler von authentik (error=access_denied) wird abgelehnt', [], ['error' => 'access_denied