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