Files
2026-10-30 21:50:16 +00:00

459 lines
35 KiB
PHP

<?php
declare(strict_types=1);
/**
* Prüft das Plugin "authentik-Anmeldung": Einrichtung, Knopf, Start des OpenID-Connect-Ablaufs (PKCE, Zustand, Nonce), Rückkehr mit allen
* Prüfungen (Zustand, Wiederholung, Nonce, Aussteller, Zielgruppe, Ablauf, E-Mail, Gruppe), Zuordnung zu aktiven Mitarbeitern und Einstellungsseite.
* authentik wird durch eine Funktion im Test ersetzt ($GLOBALS['authentik_http']); die Weiterleitungs- und Anmelde-Logik des Servers prüft tests/plugin_login.php.
*/
$pluginDir = sys_get_temp_dir() . '/deskhub-ak-' . bin2hex(random_bytes(4));
mkdir($pluginDir, 0755, true);
putenv('DESKHUB_PLUGINS_DIR=' . $pluginDir);
putenv('DESKHUB_TESTING=1');
putenv('DESKHUB_CATALOG_DEFAULTS=' . __DIR__ . '/fixtures/no-catalog.php');
putenv('DESKHUB_SQLITE_PATH=:memory:');
require_once dirname(__DIR__) . '/includes/bootstrap.php';
require_once __DIR__ . '/fixtures/paths.php';
$failures = [];
$check = function (bool $ok, string $what) use (&$failures): void { if (!$ok) $failures[] = $what; };
$pdo = get_db();
if (session_status() !== PHP_SESSION_ACTIVE) @session_start();
$_SESSION = [];
$src = plugin_source_dir_or_skip('authentik', 'plugin_authentik');
$zipPath = sys_get_temp_dir() . '/ak-' . bin2hex(random_bytes(3)) . '.zip';
$zip = new ZipArchive();
$zip->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&amp;e=start'), 'Eingerichtet erscheint der Knopf mit dem richtigen Ziel');
plugin_set_setting('authentik', 'label', 'SSO <b>Login</b>');
$check(str_contains(plugin_filter('staff.login.buttons', '', []), 'SSO &lt;b&gt;Login&lt;/b&gt;'), '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<script>']);
$fail('Token-Endpunkt mit Fehler wird abgelehnt', [], [], function () use (&$idp) { $idp['tokenStatus'] = 500; });
$fail('unbekannte Adresse wird abgelehnt', ['email' => 'niemand@firma.example', 'sub' => 'u-niemand']);
$res = $fail('deaktivierter Mitarbeiter wird abgelehnt', ['email' => 'karl@firma.example', 'sub' => 'u-karl']);
$check(str_contains((string) ($res['error'] ?? ''), 'kein aktives Mitarbeiterkonto'), 'Fehlermeldung nennt das fehlende Konto');
$fail('Aussteller in der Konfiguration passt nicht', [], [], function () use (&$idp) { $idp['issuerOverride'] = 'https://anderer.example/o/'; plugin_set_setting('authentik', 'discovery', ''); });
plugin_set_setting('authentik', 'discovery', '');
$log = (string) $pdo->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'authentik:%'")->fetchColumn();
$check(str_contains($log, 'kein Mitarbeiterkonto für niemand@firma.example'), 'Eine Anmeldung ohne Konto steht im Admin-Protokoll: ' . $log);
// Ablauf nach 10 Minuten
[, $q] = $begin();
$_SESSION['authentik']['at'] = time() - 700;
$check(isset($back(['code' => 'gueltiger-code', 'state' => $q['state']])['error']), 'Ein zu langsamer Ablauf (über 10 Minuten) wird abgelehnt');
// Ohne gestarteten Ablauf
$_SESSION = [];
$check(isset($back(['code' => 'gueltiger-code', 'state' => 'x'])['error']), 'Rückkehr ohne gestarteten Ablauf wird abgelehnt');
// Gruppenprüfung
plugin_set_setting('authentik', 'group', 'deskhub-support');
$idp['claims'] = ['groups' => ['andere', 'deskhub-support']];
[, $q] = $begin();
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Mitglied der geforderten Gruppe wird angemeldet');
$idp['claims'] = ['groups' => ['andere']];
[, $q] = $begin();
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($res['error']) && str_contains($res['error'], 'deskhub-support'), 'Wer nicht in der Gruppe ist, wird abgelehnt: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
$idp['claims'] = [];
$idp['userinfo'] = ['sub' => 'u1', 'groups' => ['deskhub-support']];
[, $q] = $begin();
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Gruppen werden bei Bedarf über „userinfo“ geholt');
$idp['userinfo'] = ['sub' => 'ANDERER', 'groups' => ['deskhub-support']];
[, $q] = $begin();
$check(isset($back(['code' => 'gueltiger-code', 'state' => $q['state']])['error']), '„userinfo“ eines anderen Benutzers (sub) zählt nicht');
$idp['claims'] = ['groups' => [['name' => 'deskhub-support']]];
$idp['userinfo'] = null;
[, $q] = $begin();
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Gruppen als Liste von Objekten mit Namen werden verstanden');
plugin_set_setting('authentik', 'group', '');
// E-Mail nur über userinfo
$idp['claims'] = ['email' => '__weg__'];
$idp['userinfo'] = ['sub' => 'u1', 'email' => 'anna@firma.example'];
[, $q] = $begin();
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Fehlt die E-Mail im Token, wird sie über „userinfo“ geholt');
$idp['claims'] = [];
// ── Verknüpfung ──
$ins->execute(['Berta', 'berta@firma.example', 'x', 'agent', 1, db_now()]);
$berta = (int) $pdo->lastInsertId();
$check(authentik_link_staff_for('u1') === $anna && ((authentik_link_of($anna)['email'] ?? '') === 'anna@firma.example'), 'Die erste Anmeldung über die E-Mail-Adresse hat Anna automatisch verknüpft');
$log = (string) $pdo->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'authentik: Konto automatisch verknüpft%'")->fetchColumn();
$check(str_contains($log, 'anna@firma.example'), 'Die automatische Verknüpfung steht im Admin-Protokoll');
// Anmeldung über die Verknüpfung, auch wenn die E-Mail-Adresse in authentik eine andere ist
$idp['claims'] = ['email' => 'ganz.anders@privat.example'];
[, $q] = $begin();
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $anna, 'Anmeldung über die Verknüpfung klappt auch mit geänderter E-Mail-Adresse in authentik');
// Fremdes authentik-Konto mit der E-Mail-Adresse einer verknüpften Person kommt nicht hinein
$idp['claims'] = ['sub' => 'u-fremd', 'email' => 'anna@firma.example'];
[, $q] = $begin();
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($res['error']) && !isset($res['staff_id']) && str_contains($res['error'], 'anderen authentik-Konto verknüpft'), 'Ein fremdes authentik-Konto mit gleicher E-Mail-Adresse wird abgewiesen: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
// „Nur verknüpfte Konten“
plugin_set_setting('authentik', 'only_linked', '1');
$idp['claims'] = ['sub' => 'u-berta', 'email' => 'berta@firma.example'];
[, $q] = $begin();
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($res['error']) && str_contains($res['error'], 'noch keinem DeskHub-Konto'), 'Mit „Nur verknüpfte Konten“ wird ein nicht verknüpftes Konto abgewiesen, auch bei passender E-Mail-Adresse');
$check(authentik_link_of($berta) === null, 'und dabei nicht verknüpft');
// Berta verknüpft selbst (angemeldet)
$bertaRow = ['id' => $berta, 'role' => 'agent', 'email' => 'berta@firma.example', 'name' => 'Berta'];
$_SESSION = [];
$st = authentik_link_start(['staff' => $bertaRow]);
$check(!empty($st['redirect']) && ($_SESSION['authentik']['mode'] ?? '') === 'link' && ($_SESSION['authentik']['staff_id'] ?? 0) === $berta, 'Verknüpfen startet für den angemeldeten Mitarbeiter');
parse_str((string) parse_url($st['redirect'], PHP_URL_QUERY), $q);
$idp['challenge'] = $q['code_challenge'];
$idp['nonce'] = $q['nonce'];
$check(isset(authentik_link_start([])['error']), 'Ohne angemeldeten Mitarbeiter lässt sich nicht verknüpfen');
$res = authentik_login_callback(['staff' => $bertaRow, 'query' => ['code' => 'gueltiger-code', 'state' => $q['state']]]);
$check(($res['local'] ?? '') === '/staff/plugin.php?p=authentik&page=konto' && str_contains((string) ($res['notice'] ?? ''), 'berta@firma.example'), 'Die Verknüpfung klappt und führt zurück zur Konto-Seite: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
$check(authentik_link_staff_for('u-berta') === $berta && ((authentik_link_of($berta)['email'] ?? '') === 'berta@firma.example'), 'Die Verknüpfung ist gespeichert');
$idp['claims'] = ['sub' => 'u-berta', 'email' => 'berta@firma.example'];
[, $q] = $begin();
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $berta, 'Danach klappt die Anmeldung mit „Nur verknüpfte Konten“');
// Verknüpfen: Fehlerfälle
$linkTry = function (array $staffRow, array $claims, ?callable $tamper = null) use (&$idp) {
$_SESSION = [];
$st = authentik_link_start(['staff' => $staffRow]);
parse_str((string) parse_url($st['redirect'], PHP_URL_QUERY), $q);
$idp['challenge'] = $q['code_challenge'];
$idp['nonce'] = $q['nonce'];
$idp['claims'] = $claims;
if ($tamper) $tamper();
return authentik_login_callback(['staff' => $staffRow, 'query' => ['code' => 'gueltiger-code', 'state' => $q['state']]]);
};
$annaRow = ['id' => $anna, 'role' => 'agent', 'email' => 'anna@firma.example', 'name' => 'Anna'];
$res = $linkTry($annaRow, ['sub' => 'u-berta', 'email' => 'berta@firma.example']);
$check(isset($res['error']) && str_contains($res['error'], 'bereits mit einem anderen') && ($res['back'] ?? '') === '/staff/plugin.php?p=authentik&page=konto', 'Ein authentik-Konto, das schon einem anderen Mitarbeiter gehört, lässt sich nicht noch einmal verknüpfen: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
$check(authentik_link_staff_for('u-berta') === $berta, 'und die alte Verknüpfung bleibt');
$res = $linkTry($annaRow, [], function () { $_SESSION['authentik']['staff_id'] = 999; });
$check(isset($res['error']) && !isset($res['local']), 'Eine für einen anderen Mitarbeiter gestartete Verknüpfung wird abgelehnt');
$_SESSION = [];
authentik_begin('login');
$q0 = $_SESSION['authentik'];
$res = authentik_login_callback(['staff' => $annaRow, 'query' => ['code' => 'gueltiger-code', 'state' => $q0['state']]]);
$check(isset($res['error']) && !isset($res['local']), 'Ein als Anmeldung gestarteter Ablauf lässt sich nicht als Verknüpfung beenden');
$_SESSION = [];
authentik_begin('link', $anna);
$q0 = $_SESSION['authentik'];
$res = authentik_login_callback(['query' => ['code' => 'gueltiger-code', 'state' => $q0['state']]]);
$check(isset($res['error']) && !isset($res['staff_id']), 'Ein als Verknüpfung gestarteter Ablauf lässt sich nicht als Anmeldung beenden');
// Neu verknüpfen ersetzt die alte Verknüpfung
$res = $linkTry($annaRow, ['sub' => 'u-anna-neu', 'email' => 'anna.neu@privat.example']);
$check(isset($res['local']) && authentik_link_staff_for('u-anna-neu') === $anna && authentik_link_staff_for('u1') === null, 'Neu verknüpfen ersetzt die alte Verknüpfung (die alte Kennung gilt nicht mehr)');
// Lösen und Übersicht
$all = authentik_links_all();
$check(count($all) === 2, 'Die Übersicht listet beide Verknüpfungen: ' . json_encode($all, JSON_UNESCAPED_UNICODE));
authentik_link_remove($berta);
$check(authentik_link_of($berta) === null && authentik_link_staff_for('u-berta') === null, 'Lösen entfernt beide Einträge');
$all = authentik_links_all();
$check(count($all) === 1 && $all[0]['staff_id'] === $anna && $all[0]['name'] === 'Anna', 'Danach bleibt nur Anna in der Übersicht: ' . json_encode($all, JSON_UNESCAPED_UNICODE));
plugin_set_setting('authentik', 'only_linked', '0');
// Adressprüfung
$check(authentik_url_ok('https://a.example/o/x') && authentik_url_ok('http://localhost:9000/o/x') && !authentik_url_ok('http://a.example/o') && !authentik_url_ok('ftp://a.example') && !authentik_url_ok('https://u:p@a.example/') && !authentik_url_ok('https://a.example/o?x=1') && !authentik_url_ok('https://a.example/o#x') && !authentik_url_ok('https://a.example/ o'), 'Adressprüfung: nur https (http nur localhost), ohne Zugangsdaten, Parameter und Anker');
// Konfiguration abrufen und merken
$idp['calls'] = [];
plugin_set_setting('authentik', 'discovery', '');
authentik_discovery($issuer);
authentik_discovery($issuer);
$check(count(array_filter($idp['calls'], fn($c) => str_contains($c, 'openid-configuration'))) === 1, 'Die Konfiguration von authentik wird eine Stunde lang gemerkt');
$idp['status'] = 404;
$d = authentik_discovery($issuer, true);
$check(!$d['ok'] && str_contains($d['error'], 'HTTP 404'), 'Eine falsche Issuer-Adresse gibt eine verständliche Meldung');
$idp['status'] = 200;
// Einstellungsseite
$staff = ['id' => 1, 'role' => 'admin', 'name' => 'Admin'];
$_SERVER['REQUEST_METHOD'] = 'GET';
ob_start();
include $pluginDir . '/authentik/pages/einstellungen.php';
$page = (string) ob_get_clean();
$check(str_contains($page, 'authentik-Anmeldung') && str_contains($page, e(authentik_redirect_uri())) && str_contains($page, 'placeholder="(unverändert)"') && !str_contains($page, 'streng-geheim'), 'Einstellungsseite zeigt die Rückkehr-Adresse, nennt das Geheimnis nicht im Klartext');
$staff['role'] = 'agent';
ob_start();
include $pluginDir . '/authentik/pages/einstellungen.php';
$denied = (string) ob_get_clean();
$check(str_contains($denied, 'Keine Berechtigung') && !str_contains($denied, 'Client-ID'), 'Die Einstellungsseite ist nur für Administratoren');
// ── Kunden (Ticketersteller) ──
$check(isset(plugin_state()['customer_logins']['authentik']['start']), 'Die Kunden-Anmelde-Adresse ist registriert');
$check(!authentik_customer_ready() && plugin_filter('customer.login.buttons', '', []) === '', 'Ausgeschaltet gibt es keinen Kunden-Knopf');
$r0 = authentik_customer_start([]);
$check(isset($r0['error']) && !isset($r0['redirect']), 'Ausgeschaltet lässt sich die Kunden-Anmeldung nicht starten');
plugin_set_setting('authentik', 'customers', '1');
$check(authentik_customer_ready() && str_contains(plugin_filter('customer.login.buttons', '', []), 'plugin_login.php?p=authentik&amp;e=start'), 'Eingeschaltet erscheint der Kunden-Knopf mit dem Kunden-Einstieg');
$check(!str_contains(plugin_filter('staff.login.buttons', '', []), 'e=start&amp;') && str_contains(plugin_filter('staff.login.buttons', '', []), '/staff/plugin_login.php'), 'Der Mitarbeiter-Knopf bleibt beim Mitarbeiter-Einstieg');
$custBegin = function () use (&$idp) {
$_SESSION = [];
$res = authentik_customer_start([]);
parse_str((string) parse_url($res['redirect'] ?? '', PHP_URL_QUERY), $q);
$idp['challenge'] = (string) ($q['code_challenge'] ?? '');
$idp['nonce'] = (string) ($q['nonce'] ?? '');
return [$res, $q];
};
[$res, $q] = $custBegin();
$check(!empty($res['redirect']) && ($_SESSION['authentik']['mode'] ?? '') === 'customer' && empty($_SESSION['authentik']['staff_id']), 'Kunden-Start leitet zu authentik und merkt die Betriebsart „customer“');
$idp['claims'] = ['sub' => 'k1', 'email' => 'Kunde.Neu@Firma.Example', 'name' => 'Karla Kunde'];
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(($res['customer']['email'] ?? '') === 'kunde.neu@firma.example' && ($res['customer']['name'] ?? '') === 'Karla Kunde' && ($res['customer']['create'] ?? null) === true && ($res['customer']['via'] ?? '') === 'authentik' && !isset($res['staff_id']), 'Kunde: bestätigte E-Mail-Adresse und Name gehen an DeskHub (Konto darf angelegt werden): ' . json_encode($res, JSON_UNESCAPED_UNICODE));
$check(authentik_link_staff_for('k1') === null, 'Kunden werden nicht mit Mitarbeitern verknüpft');
// Name-Rückfälle
foreach ([[['name' => '', 'given_name' => 'Gerd', 'family_name' => 'Gast'], 'Gerd Gast'], [['name' => '', 'preferred_username' => 'gerdg'], 'gerdg'], [['name' => ''], '']] as [$cl, $expect]) {
[$res, $q] = $custBegin();
$idp['claims'] = ['sub' => 'k2', 'email' => 'gerd@firma.example'] + $cl;
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(($r['customer']['name'] ?? 'x') === $expect, 'Kunden-Name aus den Aussagen: „' . $expect . '“ (war: ' . json_encode($r['customer']['name'] ?? null) . ')');
}
// Ohne automatisches Anlegen
plugin_set_setting('authentik', 'customer_create', '0');
[$res, $q] = $custBegin();
$idp['claims'] = ['sub' => 'k3', 'email' => 'x@firma.example'];
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(($r['customer']['create'] ?? null) === false, 'Ohne „automatisch anlegen“ steht create=false');
plugin_set_setting('authentik', 'customer_create', '1');
// Fehler gehen auf die Kunden-Seite
foreach ([['Nonce falsch', ['nonce' => 'fremd']], ['E-Mail nicht bestätigt', ['email_verified' => false]], ['abgelaufen', ['exp' => time() - 900]]] as [$what, $cl]) {
[$res, $q] = $custBegin();
$idp['claims'] = ['sub' => 'k4', 'email' => 'k@firma.example'] + $cl;
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($r['error']) && ($r['audience'] ?? '') === 'customer' && !isset($r['customer']), 'Kunde: ' . $what . ' wird abgelehnt und als Kunden-Fehler gemeldet');
}
[$res, $q] = $custBegin();
$r = $back(['error' => 'access_denied', 'state' => $q['state']]);
$check(isset($r['error']) && ($r['audience'] ?? '') === 'customer', 'Kunde: Ablehnung durch authentik wird als Kunden-Fehler gemeldet');
// Eigene Gruppe für Kunden
plugin_set_setting('authentik', 'customer_group', 'deskhub-kunden');
plugin_set_setting('authentik', 'group', 'nur-mitarbeiter');
[$res, $q] = $custBegin();
$idp['claims'] = ['sub' => 'k5', 'email' => 'k5@firma.example', 'groups' => ['deskhub-kunden']];
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($r['customer']), 'Kunde in der Kunden-Gruppe kommt hinein (die Mitarbeiter-Gruppe gilt nicht für Kunden)');
[$res, $q] = $custBegin();
$idp['claims'] = ['sub' => 'k5', 'email' => 'k5@firma.example', 'groups' => ['nur-mitarbeiter']];
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($r['error']) && !isset($r['customer']) && str_contains($r['error'], 'deskhub-kunden'), 'Kunde nur in der Mitarbeiter-Gruppe wird abgewiesen');
plugin_set_setting('authentik', 'customer_group', '');
plugin_set_setting('authentik', 'group', '');
// Betriebsart kommt aus der Sitzung: ein Mitarbeiter-Ablauf wird nie zur Kunden-Anmeldung und umgekehrt
[, $q] = $begin();
$idp['claims'] = ['sub' => 'u-anna-neu', 'email' => 'anna@firma.example'];
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($r['staff_id']) && !isset($r['customer']), 'Ein Mitarbeiter-Ablauf liefert nur eine Mitarbeiter-Anmeldung');
// Kunden ausgeschaltet, während der Ablauf läuft
[$res, $q] = $custBegin();
plugin_set_setting('authentik', 'customers', '0');
$idp['claims'] = ['sub' => 'k6', 'email' => 'k6@firma.example'];
$r = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($r['error']) && !isset($r['customer']) && ($r['audience'] ?? '') === 'customer', 'Wird die Kunden-Anmeldung während des Ablaufs ausgeschaltet, kommt niemand mehr hinein');
plugin_set_setting('authentik', 'customers', '0');
$idp['claims'] = [];
// ── E-Mail nicht bestätigt (authentik meldet email_verified = false) ──
$check(authentik_config()['require_verified'] === true, 'Die Prüfung der bestätigten E-Mail-Adresse ist standardmäßig an');
$ins->execute(['Carla', 'carla@firma.example', 'x', 'agent', 1, db_now()]);
$carla = (int) $pdo->lastInsertId();
$carlaRow = ['id' => $carla, 'role' => 'agent', 'email' => 'carla@firma.example', 'name' => 'Carla'];
// 1) Unverknüpft, nur über die E-Mail-Adresse: abgelehnt, mit Hinweis auf „Mein authentik-Konto“
$idp['claims'] = ['sub' => 'u-carla', 'email' => 'carla@firma.example', 'email_verified' => false];
[, $q] = $begin();
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($res['error']) && !isset($res['staff_id']) && str_contains($res['error'], 'Mein authentik-Konto') && authentik_link_of($carla) === null, 'Unbestätigte Adresse: Anmeldung über die E-Mail-Adresse wird abgelehnt (mit Hinweis auf „Mein authentik-Konto“) und nichts verknüpft: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
// 2) Verknüpfen funktioniert trotzdem (die Adresse entscheidet dort nicht)
$res = $linkTry($carlaRow, ['sub' => 'u-carla', 'email' => 'carla@firma.example', 'email_verified' => false]);
$check(isset($res['local']) && authentik_link_staff_for('u-carla') === $carla, 'Verknüpfen klappt auch, wenn authentik die Adresse als nicht bestätigt meldet: ' . json_encode($res, JSON_UNESCAPED_UNICODE));
// 3) Danach klappt die Anmeldung über die Verknüpfung
$idp['claims'] = ['sub' => 'u-carla', 'email' => 'carla@firma.example', 'email_verified' => false];
[, $q] = $begin();
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $carla, 'Wer verknüpft ist, meldet sich auch mit unbestätigter Adresse an');
// 4) Prüfung ausgeschaltet: Zuordnung über die Adresse klappt (und verknüpft)
$ins->execute(['Dora', 'dora@firma.example', 'x', 'agent', 1, db_now()]);
$dora = (int) $pdo->lastInsertId();
plugin_set_setting('authentik', 'require_verified', '0');
$idp['claims'] = ['sub' => 'u-dora', 'email' => 'dora@firma.example', 'email_verified' => false];
[, $q] = $begin();
$check(($back(['code' => 'gueltiger-code', 'state' => $q['state']])['staff_id'] ?? 0) === $dora && authentik_link_staff_for('u-dora') === $dora, 'Mit ausgeschalteter Prüfung klappt die Zuordnung über die Adresse');
// 5) Kunden: unbestätigt wird abgelehnt (an), sonst angenommen (aus)
plugin_set_setting('authentik', 'customers', '1');
plugin_set_setting('authentik', 'require_verified', '1');
$idp['claims'] = ['sub' => 'k-unv', 'email' => 'unv@firma.example', 'email_verified' => false];
[$r0, $q] = $custBegin();
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($res['error']) && !isset($res['customer']) && ($res['audience'] ?? '') === 'customer' && str_contains($res['error'], 'nicht bestätigt'), 'Kunde mit unbestätigter Adresse wird abgelehnt (Kunden-Fehler)');
plugin_set_setting('authentik', 'require_verified', '0');
[$r0, $q] = $custBegin();
$idp['claims'] = ['sub' => 'k-unv', 'email' => 'unv@firma.example', 'email_verified' => false];
$res = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(($res['customer']['email'] ?? '') === 'unv@firma.example', 'Mit ausgeschalteter Prüfung wird der Kunde angenommen');
plugin_set_setting('authentik', 'require_verified', '1');
plugin_set_setting('authentik', 'customers', '0');
$idp['claims'] = [];
// Konto-Seite für Mitarbeiter
$render = function (array $staffRow, string $page, array $post = []) use ($pluginDir) {
global $staff;
$staff = $staffRow;
$_SERVER['REQUEST_METHOD'] = $post ? 'POST' : 'GET';
$_POST = $post ? $post + ['csrf_token' => csrf_token()] : [];
ob_start();
include $pluginDir . '/authentik/pages/' . $page . '.php';
return (string) ob_get_clean();
};
$annaStaff = ['id' => $anna, 'role' => 'agent', 'email' => 'anna@firma.example', 'name' => 'Anna'];
$page = $render($annaStaff, 'konto');
$check(str_contains($page, 'Mein authentik-Konto') && str_contains($page, 'Verknüpft.') && str_contains($page, 'anna.neu@privat.example') && str_contains($page, 'p=authentik&amp;e=link') && str_contains($page, 'Verknüpfung lösen'), 'Konto-Seite zeigt die Verknüpfung, „neu verknüpfen“ und „lösen“');
$bertaStaff = ['id' => $berta, 'role' => 'agent', 'email' => 'berta@firma.example', 'name' => 'Berta'];
$page = $render($bertaStaff, 'konto');
$check(str_contains($page, 'Noch nicht verknüpft') && str_contains($page, 'Mit authentik verknüpfen') && str_contains($page, 'automatisch verknüpft'), 'Ohne Verknüpfung bietet die Konto-Seite „Mit authentik verknüpfen“ an und erklärt die E-Mail-Anmeldung');
plugin_set_setting('authentik', 'only_linked', '1');
$page = $render($bertaStaff, 'konto');
$check(str_contains($page, 'Ohne Verknüpfung ist die Anmeldung mit authentik hier nicht möglich'), 'Mit „Nur verknüpfte Konten“ steht dort, dass die Verknüpfung nötig ist');
plugin_set_setting('authentik', 'only_linked', '0');
$page = $render($annaStaff, 'konto', ['action' => 'unlink']);
$check(authentik_link_of($anna) === null && str_contains($page, 'Die Verknüpfung ist gelöst') && str_contains($page, 'Noch nicht verknüpft'), 'Selbst lösen funktioniert');
$check(str_contains((string) $pdo->query("SELECT GROUP_CONCAT(action, ' | ') FROM admin_audit_log WHERE action LIKE 'authentik: Verknüpfung gelöst%'")->fetchColumn(), 'Verknüpfung gelöst'), 'Selbst lösen steht im Admin-Protokoll');
// Verwaltung: Übersicht und Lösen für andere
authentik_link_set($berta, 'u-berta2', 'berta@firma.example');
$adminStaff = ['id' => 1, 'role' => 'admin', 'email' => 'admin@firma.example', 'name' => 'Admin'];
$page = $render($adminStaff, 'einstellungen');
$nLinks = count(authentik_links_all());
$check(str_contains($page, 'Verknüpfte Konten (' . $nLinks . ')') && str_contains($page, 'Berta') && str_contains($page, 'name="only_linked"') && str_contains($page, 'value="unlink_staff"'), 'Einstellungsseite listet Verknüpfungen und bietet „Nur verknüpfte Konten“ an');
$page = $render($adminStaff, 'einstellungen', ['action' => 'unlink_staff', 'staff_id' => (string) $berta]);
$check(authentik_link_of($berta) === null && str_contains($page, 'Die Verknüpfung ist gelöst') && str_contains($page, 'Verknüpfte Konten (' . ($nLinks - 1) . ')'), 'Ein Administrator kann die Verknüpfung eines anderen lösen');
$page = $render($adminStaff, 'einstellungen', ['action' => 'save', 'issuer' => $issuer, 'client_id' => 'deskhub-client', 'client_secret' => '', 'scopes' => 'openid email profile', 'group' => '', 'label' => '', 'enabled' => '1', 'only_linked' => '1', 'customers' => '1', 'customer_create' => '1', 'customer_group' => 'kunden']);
$check(plugin_setting('authentik', 'customers', '0') === '1' && plugin_setting('authentik', 'customer_create', '0') === '1' && plugin_setting('authentik', 'customer_group', '') === 'kunden' && str_contains($page, 'name="customers"') && str_contains($page, 'Kunden (Ticketersteller)'), 'Einstellungsseite speichert die Kunden-Einstellungen');
plugin_set_setting('authentik', 'customers', '0');
plugin_set_setting('authentik', 'customer_group', '');
$check(plugin_setting('authentik', 'only_linked', '0') === '1' && authentik_ready() && authentik_config()['client_secret'] === 'streng-geheim', 'Speichern setzt „Nur verknüpfte Konten“ und lässt das Geheimnis bei leerem Feld unverändert');
plugin_set_setting('authentik', 'only_linked', '0');
// Diagnose „Kunden-Anmeldung“ auf der Einstellungsseite
plugin_set_setting('authentik', 'customers', '0');
$page = $render($adminStaff, 'einstellungen');
$check(str_contains($page, 'Der Knopf erscheint dort noch nicht') && str_contains($page, 'Kunden-Anmeldung ist ausgeschaltet') && str_contains($page, 'Die Berechtigung „auth.customer“ ist freigegeben'), 'Diagnose: ausgeschaltet nennt den Grund, die freigegebene Berechtigung ist erkannt');
plugin_set_setting('authentik', 'customers', '1');
$page = $render($adminStaff, 'einstellungen');
$check(str_contains($page, 'Der Knopf erscheint auf der Anmeldeseite des Kundenportals') && !str_contains($page, 'Der Knopf erscheint dort noch nicht'), 'Diagnose: eingeschaltet meldet, dass der Knopf erscheint');
plugin_set_setting('authentik', 'customers', '0');
foreach (scandir($pluginDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') continue;
is_dir($pluginDir . '/' . $entry) ? plugin_rmdir($pluginDir . '/' . $entry) : @unlink($pluginDir . '/' . $entry);
}
@rmdir($pluginDir);
@unlink($zipPath);
if ($failures) {
fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
exit(1);
}
echo "authentik plugin checks passed.\n";