plugin_setting(AUTHENTIK, 'enabled', '0') === '1', 'issuer' => rtrim(trim((string) plugin_setting(AUTHENTIK, 'issuer', '')), '/'), 'client_id' => trim((string) plugin_setting(AUTHENTIK, 'client_id', '')), 'client_secret' => $secret !== '' ? decrypt_secret($secret) : '', 'scopes' => trim((string) plugin_setting(AUTHENTIK, 'scopes', 'openid email profile')) ?: 'openid email profile', 'group' => trim((string) plugin_setting(AUTHENTIK, 'group', '')), 'customers' => plugin_setting(AUTHENTIK, 'customers', '0') === '1', 'customer_create' => plugin_setting(AUTHENTIK, 'customer_create', '1') === '1', 'customer_group' => trim((string) plugin_setting(AUTHENTIK, 'customer_group', '')), 'require_verified' => plugin_setting(AUTHENTIK, 'require_verified', '1') === '1', 'label' => $label !== '' ? mb_substr($label, 0, 60) : 'Mit authentik anmelden', ]; } /** Rückkehr-Adresse, die in authentik als „Redirect URI“ eingetragen wird. */ function authentik_redirect_uri(): string { return rtrim(base_url(), '/') . plugin_login_url(AUTHENTIK, 'callback'); } /** Prüft eine Adresse des Anmeldedienstes: https (http nur für localhost zum Testen), ohne Zugangsdaten, Parameter und Anker. */ function authentik_url_ok(string $url, bool $allowQuery = false): bool { if ($url === '' || strlen($url) > 300 || preg_match('/[\x00-\x20]/', $url)) return false; $p = parse_url($url); if (!$p || empty($p['host']) || isset($p['user']) || isset($p['pass']) || isset($p['fragment'])) return false; if (!$allowQuery && isset($p['query'])) return false; $scheme = $p['scheme'] ?? ''; return $scheme === 'https' || ($scheme === 'http' && in_array($p['host'], ['localhost', '127.0.0.1', '[::1]'], true)); } function authentik_ready(): bool { $c = authentik_config(); return $c['enabled'] && authentik_url_ok($c['issuer']) && $c['client_id'] !== '' && $c['client_secret'] !== ''; } /** Ist die Anmeldung für Kunden (Ticketersteller) eingeschaltet und eingerichtet? */ function authentik_customer_ready(): bool { return authentik_ready() && authentik_config()['customers']; } /** * HTTP-Aufruf zum Anmeldedienst mit cURL: Zertifikat wird geprüft, keine Weiterleitungen, begrenzte Antwortgröße und Zeit. * @return array{0:int,1:?string,2:?string} [Status, Antworttext, Fehler] */ function authentik_http(string $method, string $url, array $opts = []): array { if (!function_exists('curl_init')) return [0, null, 'Auf diesem Server fehlt die PHP-Erweiterung cURL.']; if (!authentik_url_ok($url, true)) return [0, null, 'Ungültige Adresse.']; if (isset($GLOBALS['authentik_http']) && is_callable($GLOBALS['authentik_http'])) return ($GLOBALS['authentik_http'])($method, $url, $opts); // für Tests $body = ''; $headers = array_merge(['Accept: application/json', 'User-Agent: DeskHub-authentik'], (array) ($opts['headers'] ?? [])); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 12, CURLOPT_CONNECTTIMEOUT => 6, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HTTPHEADER => $headers, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP, CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int { $body .= $data; return strlen($body) > 262144 ? 0 : strlen($data); }, ]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query((array) ($opts['form'] ?? []))); } $ok = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); $err = $ok === false ? curl_error($ch) : null; curl_close($ch); return [$status, $err === null ? $body : null, $err]; } /** Konfiguration des Anmeldedienstes (/.well-known/openid-configuration), eine Stunde lang gemerkt. @return array{ok:bool,error?:string,doc?:array} */ function authentik_discovery(string $issuer, bool $refresh = false): array { if (!$refresh) { $cached = json_decode((string) plugin_setting(AUTHENTIK, 'discovery', ''), true); if (is_array($cached) && ($cached['issuer'] ?? '') === $issuer && (int) ($cached['at'] ?? 0) > time() - 3600 && is_array($cached['doc'] ?? null)) return ['ok' => true, 'doc' => $cached['doc']]; } [$status, $body, $err] = authentik_http('GET', $issuer . '/.well-known/openid-configuration'); if ($err !== null || $body === null) return ['ok' => false, 'error' => 'authentik ist nicht erreichbar: ' . ($err ?? 'keine Antwort')]; $doc = json_decode($body, true); if ($status !== 200 || !is_array($doc)) return ['ok' => false, 'error' => 'authentik antwortet nicht mit einer gültigen Konfiguration (HTTP ' . $status . '). Stimmt die Issuer-Adresse?']; if (rtrim((string) ($doc['issuer'] ?? ''), '/') !== $issuer) return ['ok' => false, 'error' => 'Der Issuer in der Antwort („' . mb_substr((string) ($doc['issuer'] ?? ''), 0, 120) . '“) passt nicht zur eingestellten Adresse.']; foreach (['authorization_endpoint', 'token_endpoint'] as $k) { if (!authentik_url_ok((string) ($doc[$k] ?? ''), true)) return ['ok' => false, 'error' => 'Die Konfiguration von authentik enthält keinen gültigen Eintrag „' . $k . '“.']; } $keep = array_intersect_key($doc, array_flip(['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint'])); plugin_set_setting(AUTHENTIK, 'discovery', json_encode(['issuer' => $issuer, 'at' => time(), 'doc' => $keep], JSON_UNESCAPED_SLASHES)); return ['ok' => true, 'doc' => $keep]; } function authentik_b64url(string $bin): string { return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); } /** Ablauf starten: Zustand, Nonce und PKCE-Prüfwert in der Sitzung merken, dann weiter zu authentik. $mode: 'login' oder 'link' (Konto verknüpfen). */ function authentik_begin(string $mode, ?int $staffId = null): array { $back = $mode === 'link' ? ['back' => '/staff/plugin.php?p=authentik&page=konto'] : []; if ($mode === 'customer' && !authentik_customer_ready()) return ['error' => 'Die Anmeldung mit authentik ist für Kunden nicht eingeschaltet.']; if (!authentik_ready()) return ['error' => 'Die Anmeldung mit authentik ist nicht eingerichtet.'] + $back; $cfg = authentik_config(); $d = authentik_discovery($cfg['issuer']); if (!$d['ok']) return ['error' => $d['error']] + $back; $verifier = authentik_b64url(random_bytes(48)); $state = bin2hex(random_bytes(16)); $nonce = bin2hex(random_bytes(16)); $_SESSION['authentik'] = ['state' => $state, 'nonce' => $nonce, 'verifier' => $verifier, 'at' => time(), 'mode' => $mode, 'staff_id' => $staffId]; $url = $d['doc']['authorization_endpoint']; $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query([ 'response_type' => 'code', 'client_id' => $cfg['client_id'], 'redirect_uri' => authentik_redirect_uri(), 'scope' => $cfg['scopes'], 'state' => $state, 'nonce' => $nonce, 'code_challenge' => authentik_b64url(hash('sha256', $verifier, true)), 'code_challenge_method' => 'S256', ], '', '&', PHP_QUERY_RFC3986); return ['redirect' => $url]; } /** Start der Anmeldung (nicht angemeldet). */ function authentik_login_start(array $req): array { return authentik_begin('login'); } /** Start der Anmeldung für Kunden (Kundenportal). */ function authentik_customer_start(array $req): array { return authentik_begin('customer'); } /** Start der Verknüpfung (angemeldeter Mitarbeiter). */ function authentik_link_start(array $req): array { $staff = $req['staff'] ?? null; if (!is_array($staff) || empty($staff['id'])) return ['error' => 'Bitte zuerst anmelden.']; return authentik_begin('link', (int) $staff['id']); } /** Liest die Aussagen (Claims) eines ID-Tokens, das direkt vom Token-Endpunkt über TLS kam (dann ist keine Signaturprüfung nötig, OIDC Core 3.1.3.7). */ function authentik_claims(string $jwt): ?array { $parts = explode('.', $jwt); if (count($parts) !== 3) return null; $json = base64_decode(strtr($parts[1], '-_', '+/') . str_repeat('=', (4 - strlen($parts[1]) % 4) % 4), true); $claims = $json === false ? null : json_decode($json, true); return is_array($claims) ? $claims : null; } /** * Rückkehr von authentik: Code gegen Token tauschen und alles prüfen (Zustand, Aussteller, Zielgruppe, Ablauf, Nonce, E-Mail, Gruppe). * @return array{error:string}|array{claims:array,email:string,sub:string} */ function authentik_exchange(array $q, ?array $sess, string $mode): array { $cfg = authentik_config(); if (!$sess || time() - (int) ($sess['at'] ?? 0) > 600 || ($sess['mode'] ?? 'login') !== $mode) return ['error' => 'Die Anmeldung ist abgelaufen oder wurde nicht hier gestartet. Bitte noch einmal auf „' . ($mode === 'link' ? 'Mit authentik verknüpfen' : $cfg['label']) . '“ klicken.']; if (isset($q['error'])) return ['error' => 'authentik hat die Anmeldung nicht bestätigt (' . mb_substr(preg_replace('/[^A-Za-z0-9 _.\-]/', '', (string) $q['error']), 0, 60) . ').']; $state = (string) ($q['state'] ?? ''); $code = (string) ($q['code'] ?? ''); if ($state === '' || !hash_equals((string) $sess['state'], $state) || $code === '' || strlen($code) > 2000) return ['error' => 'Die Antwort von authentik passt nicht zur gestarteten Anmeldung. Bitte noch einmal versuchen.']; $d = authentik_discovery($cfg['issuer']); if (!$d['ok']) return ['error' => $d['error']]; [$status, $body, $err] = authentik_http('POST', $d['doc']['token_endpoint'], ['form' => [ 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => authentik_redirect_uri(), 'client_id' => $cfg['client_id'], 'client_secret' => $cfg['client_secret'], 'code_verifier' => (string) $sess['verifier'], ]]); if ($err !== null || $body === null) return ['error' => 'authentik ist nicht erreichbar: ' . ($err ?? 'keine Antwort')]; $tok = json_decode($body, true); if ($status !== 200 || !is_array($tok) || empty($tok['id_token'])) return ['error' => 'authentik hat den Anmelde-Code nicht angenommen (HTTP ' . $status . '). Stimmen Client-ID und Geheimnis?']; $claims = authentik_claims((string) $tok['id_token']); if (!$claims) return ['error' => 'Die Antwort von authentik ist unlesbar.']; // Prüfung nach OIDC Core: Aussteller, Zielgruppe, Ablauf, Nonce if (rtrim((string) ($claims['iss'] ?? ''), '/') !== $cfg['issuer']) return ['error' => 'Die Antwort stammt nicht vom eingestellten authentik (Aussteller).']; $aud = $claims['aud'] ?? []; $aud = is_array($aud) ? $aud : [$aud]; if (!in_array($cfg['client_id'], array_map('strval', $aud), true) || (isset($claims['azp']) && (string) $claims['azp'] !== $cfg['client_id'])) return ['error' => 'Die Antwort von authentik gilt für eine andere Anwendung.']; if ((int) ($claims['exp'] ?? 0) < time() - 60) return ['error' => 'Die Bestätigung von authentik ist abgelaufen.']; if (!hash_equals((string) $sess['nonce'], (string) ($claims['nonce'] ?? ''))) return ['error' => 'Die Antwort von authentik gehört nicht zu dieser Anmeldung (Nonce).']; $sub = (string) ($claims['sub'] ?? ''); if ($sub === '' || strlen($sub) > 255) return ['error' => 'Die Antwort von authentik enthält keine Benutzerkennung.']; $userinfo = null; $loadUserinfo = function () use (&$userinfo, $tok, $d, $sub) { if ($userinfo !== null) return $userinfo; $userinfo = []; if (!empty($tok['access_token']) && !empty($d['doc']['userinfo_endpoint'])) { [$s2, $b2, $e2] = authentik_http('GET', $d['doc']['userinfo_endpoint'], ['headers' => ['Authorization: Bearer ' . $tok['access_token']]]); $ui = $e2 === null && $b2 !== null && $s2 === 200 ? json_decode($b2, true) : null; if (is_array($ui) && (string) ($ui['sub'] ?? '') === $sub) $userinfo = $ui; // nur der Benutzer dieser Anmeldung zählt } return $userinfo; }; $email = strtolower(trim((string) ($claims['email'] ?? ''))); if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { $claims = $loadUserinfo() + $claims; // manche Einstellungen liefern die Adresse nur über „userinfo“ $email = strtolower(trim((string) ($claims['email'] ?? ''))); } if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) return ['error' => 'authentik hat keine E-Mail-Adresse geliefert. Im Provider den Bereich „email“ aktivieren.']; // Ob authentik die Adresse als bestätigt meldet. Verlangt wird das nur dort, wo die Adresse über das Konto entscheidet (siehe authentik_unverified_error()). $verified = !array_key_exists('email_verified', $claims) || $claims['email_verified'] !== false; // Optional: nur Mitglieder einer Gruppe (für Kunden eine eigene) $needGroup = $mode === 'customer' ? $cfg['customer_group'] : $cfg['group']; if ($needGroup !== '') { $groups = $claims['groups'] ?? null; if (!is_array($groups)) $groups = $loadUserinfo()['groups'] ?? null; $names = array_map(fn($g) => is_array($g) ? (string) ($g['name'] ?? '') : (string) $g, is_array($groups) ? $groups : []); if (!in_array($needGroup, $names, true)) { audit_log(null, 'authentik: ' . mb_substr($email, 0, 120) . ' abgelehnt (nicht in der Gruppe ' . mb_substr($needGroup, 0, 60) . ')'); return ['error' => 'Ihr authentik-Konto gehört nicht zur Gruppe „' . $needGroup . '“, die für DeskHub nötig ist.']; } } return ['claims' => $claims, 'email' => $email, 'sub' => $sub, 'verified' => $verified]; } /** * Fehlermeldung, wenn authentik die E-Mail-Adresse als „nicht bestätigt“ meldet und die Adresse über das Konto entscheiden würde * (Mitarbeiter ohne Verknüpfung, Kunden). Null = in Ordnung (bestätigt oder Prüfung ausgeschaltet). */ function authentik_unverified_error(array $x, string $who): ?string { if ($x['verified'] || !authentik_config()['require_verified']) return null; return $who === 'customer' ? 'Die E-Mail-Adresse ist in authentik nicht bestätigt. Bitte erst in authentik bestätigen.' : 'Die E-Mail-Adresse ist in authentik nicht bestätigt, daher ordnet DeskHub das Konto nicht über die Adresse zu. Verknüpfen Sie Ihr Konto einmal unter „Mein authentik-Konto“ (nach der Anmeldung mit E-Mail und Passwort), danach geht die Anmeldung mit authentik.'; } // ── Verknüpfung DeskHub-Konto ⇄ authentik-Konto (in den Einstellungen gespeichert) ── /** Kurzer Schlüssel für eine authentik-Benutzerkennung (gilt nur für diesen Issuer). */ function authentik_link_hash(string $sub): string { return substr(hash('sha256', authentik_config()['issuer'] . '|' . $sub), 0, 40); } /** Zu dieser authentik-Kennung verknüpfter Mitarbeiter (ID) oder null. */ function authentik_link_staff_for(string $sub): ?int { $id = (int) plugin_setting(AUTHENTIK, 'link_s_' . authentik_link_hash($sub), '0'); return $id > 0 ? $id : null; } /** Verknüpfung eines Mitarbeiters: ['h' => Schlüssel, 'email' => …, 'at' => …] oder null. */ function authentik_link_of(int $staffId): ?array { $d = json_decode((string) plugin_setting(AUTHENTIK, 'link_u_' . $staffId, ''), true); return is_array($d) && !empty($d['h']) ? $d : null; } function authentik_link_set(int $staffId, string $sub, string $email): void { authentik_link_remove($staffId); $h = authentik_link_hash($sub); plugin_set_setting(AUTHENTIK, 'link_s_' . $h, (string) $staffId); plugin_set_setting(AUTHENTIK, 'link_u_' . $staffId, json_encode(['h' => $h, 'email' => mb_substr($email, 0, 190), 'at' => date('Y-m-d H:i:s')], JSON_UNESCAPED_UNICODE)); } function authentik_link_remove(int $staffId): void { $old = authentik_link_of($staffId); $keys = ['link_u_' . $staffId]; if ($old) $keys[] = 'link_s_' . $old['h']; foreach ($keys as $k) { plugin_set_setting(AUTHENTIK, $k, ''); // erst leeren (der Einstellungs-Zwischenspeicher der Anfrage), dann die Zeile löschen get_db()->prepare('DELETE FROM settings WHERE setting_key = ?')->execute(['plugin.' . AUTHENTIK . '.' . $k]); } } /** Alle Verknüpfungen mit Name und E-Mail des Mitarbeiters (für die Verwaltung). @return list */ function authentik_links_all(): array { $out = []; $rows = get_db()->query("SELECT setting_key, setting_value FROM settings WHERE setting_key LIKE 'plugin.authentik.link_u_%'")->fetchAll(); $st = get_db()->prepare('SELECT name, email FROM staff WHERE id = ?'); foreach ($rows as $r) { if (!str_starts_with((string) $r['setting_key'], 'plugin.authentik.link_u_')) continue; $id = (int) substr((string) $r['setting_key'], strlen('plugin.authentik.link_u_')); $d = json_decode((string) $r['setting_value'], true); if ($id <= 0 || !is_array($d)) continue; $st->execute([$id]); $s = $st->fetch(); $out[] = ['staff_id' => $id, 'name' => (string) ($s['name'] ?? '(gelöscht)'), 'staff_email' => (string) ($s['email'] ?? ''), 'email' => (string) ($d['email'] ?? ''), 'at' => (string) ($d['at'] ?? '')]; } usort($out, fn($a, $b) => strcasecmp($a['name'], $b['name'])); return $out; } /** Rückkehr von authentik: angemeldet = Konto verknüpfen, sonst anmelden. */ function authentik_login_callback(array $req): array { $loggedIn = isset($req['staff']) && is_array($req['staff']); $sess = is_array($_SESSION['authentik'] ?? null) ? $_SESSION['authentik'] : null; unset($_SESSION['authentik']); // jeder Ablauf gilt nur einmal // Betriebsart: angemeldet = Verknüpfen; sonst steht in der Sitzung, ob ein Kunde oder ein Mitarbeiter anmelden wollte $mode = $loggedIn ? 'link' : (($sess['mode'] ?? 'login') === 'customer' ? 'customer' : 'login'); $back = $loggedIn ? ['back' => '/staff/plugin.php?p=authentik&page=konto'] : ($mode === 'customer' ? ['audience' => 'customer'] : []); if (!authentik_ready()) return ['error' => 'Die Anmeldung mit authentik ist nicht eingerichtet.'] + $back; if ($mode === 'customer' && !authentik_customer_ready()) return ['error' => 'Die Anmeldung mit authentik ist für Kunden nicht eingeschaltet.'] + $back; $x = authentik_exchange((array) ($req['query'] ?? []), $sess, $mode); if (isset($x['error'])) return $x + $back; $email = $x['email']; $sub = $x['sub']; // Kunde (Ticketersteller): nur die bestätigte E-Mail-Adresse zählt; das Konto wird bei Bedarf angelegt if ($mode === 'customer') { if ($e = authentik_unverified_error($x, 'customer')) return ['error' => $e] + $back; $c = $x['claims']; $name = trim((string) ($c['name'] ?? '')); if ($name === '') $name = trim(trim((string) ($c['given_name'] ?? '')) . ' ' . trim((string) ($c['family_name'] ?? ''))); if ($name === '') $name = trim((string) ($c['preferred_username'] ?? '')); return ['customer' => ['email' => $email, 'name' => mb_substr($name, 0, 120), 'create' => authentik_config()['customer_create'], 'via' => 'authentik']]; } // Verknüpfen (angemeldeter Mitarbeiter) if ($loggedIn) { $staffId = (int) $req['staff']['id']; if ((int) ($sess['staff_id'] ?? 0) !== $staffId) return ['error' => 'Die Verknüpfung wurde von einem anderen Konto gestartet. Bitte noch einmal starten.'] + $back; $other = authentik_link_staff_for($sub); if ($other !== null && $other !== $staffId) return ['error' => 'Dieses authentik-Konto ist bereits mit einem anderen DeskHub-Konto verknüpft.'] + $back; authentik_link_set($staffId, $sub, $email); audit_log($staffId, 'authentik: Konto verknüpft (' . mb_substr($email, 0, 120) . ')'); return ['local' => '/staff/plugin.php?p=authentik&page=konto', 'notice' => 'Ihr authentik-Konto (' . $email . ') ist jetzt verknüpft. Die Anmeldung mit authentik führt künftig genau in dieses Konto.']; } // Anmelden: zuerst über die Verknüpfung, sonst (wenn erlaubt) über die E-Mail-Adresse $st = get_db()->prepare('SELECT id, is_active FROM staff WHERE id = ?'); $linked = authentik_link_staff_for($sub); if ($linked !== null) { $st->execute([$linked]); $s = $st->fetch(); if (!$s) { authentik_link_remove($linked); } elseif (!(int) $s['is_active']) return ['error' => 'Das mit Ihrem authentik-Konto verknüpfte DeskHub-Konto ist deaktiviert.']; else return ['staff_id' => (int) $s['id'], 'via' => 'authentik']; } if (plugin_setting(AUTHENTIK, 'only_linked', '0') === '1') { audit_log(null, 'authentik: ' . mb_substr($email, 0, 120) . ' abgelehnt (nicht verknüpft)'); return ['error' => 'Ihr authentik-Konto ist noch keinem DeskHub-Konto zugeordnet. Melden Sie sich mit E-Mail und Passwort an und verknüpfen Sie es dort unter „Mein authentik-Konto“.']; } // Zuordnung über die E-Mail-Adresse nur, wenn authentik sie als bestätigt meldet (sonst über „Mein authentik-Konto“ verknüpfen) if ($e = authentik_unverified_error($x, 'staff')) return ['error' => $e]; $q = get_db()->prepare('SELECT id FROM staff WHERE LOWER(email) = ? AND is_active = 1'); $q->execute([$email]); $id = $q->fetchColumn(); if (!$id) { audit_log(null, 'authentik: kein Mitarbeiterkonto für ' . mb_substr($email, 0, 120)); return ['error' => 'Für ' . $email . ' gibt es in DeskHub kein aktives Mitarbeiterkonto. Bitte einen Administrator bitten, es mit derselben E-Mail-Adresse anzulegen, oder ein bestehendes Konto unter „Mein authentik-Konto“ verknüpfen.']; } $id = (int) $id; // Hat dieses DeskHub-Konto schon ein anderes authentik-Konto, darf ein fremdes mit gleicher E-Mail-Adresse nicht hinein if (authentik_link_of($id) !== null) return ['error' => 'Dieses DeskHub-Konto ist mit einem anderen authentik-Konto verknüpft.']; // Erste Anmeldung über die E-Mail-Adresse: gleich fest verknüpfen (spätere Änderungen der Adresse stören dann nicht) authentik_link_set($id, $sub, $email); audit_log($id, 'authentik: Konto automatisch verknüpft (' . mb_substr($email, 0, 120) . ')'); return ['staff_id' => $id, 'via' => 'authentik']; }