Upload via GUI (49 Dateien)
This commit is contained in:
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 267 KiB |
@@ -0,0 +1,42 @@
|
||||
# authentik-Anmeldung für DeskHub
|
||||
|
||||
Mitarbeiter und Kunden melden sich mit **authentik** an (OpenID Connect, Authorization-Code-Ablauf mit PKCE). Auf der Anmeldeseite des Mitarbeiterbereichs und, wenn eingeschaltet, des Kundenportals erscheint zusätzlich der Knopf „Mit authentik anmelden“. E-Mail und Passwort bleiben als Zugang erhalten.
|
||||
|
||||
Voraussetzung: DeskHub **1.0.52 oder neuer** (Berechtigungen `auth.login` und `auth.customer`, Anmelde-Adressen `/staff/plugin_login.php` und `/plugin_login.php`).
|
||||
|
||||
## Einrichtung
|
||||
|
||||
1. **In authentik** unter *Anwendungen → Provider* einen Provider vom Typ **OAuth2/OpenID** anlegen, Client-Typ *vertraulich*.
|
||||
2. **Umleitungs-URI** (Modus „Strict“): `https://<deine-DeskHub-Adresse>/staff/plugin_login.php?p=authentik&e=callback` (die genaue Adresse zeigt die Einstellungsseite).
|
||||
3. **Bereiche:** `openid`, `email`, `profile`; für die Gruppenprüfung zusätzlich ein Bereich, der `groups` liefert.
|
||||
4. Eine **Anwendung** mit diesem Provider anlegen und festlegen, wer sie nutzen darf.
|
||||
5. In DeskHub: Plugin hochladen und aktivieren (Berechtigungen `ui.menu`, `auth.login` und `auth.customer` bestätigen), dann im Adminportal unter **authentik** die Issuer-Adresse (`https://auth.example.de/application/o/<Anwendungs-Kürzel>`), Client-ID und Client-Geheimnis eintragen, „Anmeldung einschalten“, speichern, **Verbindung testen**.
|
||||
6. Mitarbeiter: Jeder hat sein DeskHub-Konto und verknüpft es einmal unter **Mein authentik-Konto** (oder meldet sich beim ersten Mal mit gleicher E-Mail-Adresse an). Kunden: unter „Kunden“ einschalten (siehe unten).
|
||||
|
||||
## Mitarbeiter
|
||||
|
||||
Angemeldet wird nur, wer in authentik bestätigt ist (E-Mail nicht als „nicht bestätigt“ markiert) **und** in DeskHub ein **aktives** Mitarbeiterkonto hat, das mit seinem authentik-Konto **verknüpft** ist. Die Verknüpfung macht jeder Mitarbeiter selbst unter **Mein authentik-Konto** (Menü) mit einem Klick; sie hängt an der festen Benutzerkennung von authentik, nicht an der E-Mail-Adresse. Ohne Verknüpfung klappt die erste Anmeldung auch, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist; dabei wird automatisch verknüpft. Mit der Einstellung **Nur verknüpfte Konten** geht das nicht mehr. Hat ein DeskHub-Konto schon eine Verknüpfung, kann ein fremdes authentik-Konto mit gleicher E-Mail-Adresse nicht hinein. Administratoren sehen alle Verknüpfungen in den Einstellungen und können sie lösen. Konten werden nicht automatisch angelegt. Optional zusätzlich nur Mitglieder einer authentik-Gruppe. Die zweite Stufe von DeskHub (Authenticator-Code) entfällt bei dieser Anmeldung, weil authentik sie übernimmt. Jede Anmeldung und jede Ablehnung steht im Admin-Protokoll.
|
||||
|
||||
## Kunden (Ticketersteller)
|
||||
|
||||
In den Einstellungen unter „Kunden“ einschalten. Dann erscheint der Knopf auch auf der Anmeldeseite des Kundenportals. Es zählt **nur die in authentik bestätigte E-Mail-Adresse**: Gibt es ein Kundenkonto mit dieser Adresse, wird es angemeldet; gibt es keines und „automatisch anlegen“ ist an, legt DeskHub es an (Name aus authentik, **ohne Passwort**; wer eines will, nutzt „Passwort vergessen“). Gesperrte Adressen (Sperrliste) kommen nicht hinein. Optional nur Kunden einer eigenen authentik-Gruppe. Die Rückkehr-Adresse in authentik ist dieselbe wie für Mitarbeiter, Sie brauchen keine zweite. Neue Kundenkonten stehen im Admin-Protokoll.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- PKCE (S256), zufälliger Zustandswert (`state`) und `nonce`, jeder Ablauf gilt nur einmal und höchstens 10 Minuten.
|
||||
- Das ID-Token kommt direkt vom Token-Endpunkt über eine **geprüfte HTTPS-Verbindung** (Zertifikat und Name werden geprüft, keine Weiterleitungen); geprüft werden Aussteller (`iss`), Zielgruppe (`aud`/`azp`), Ablauf (`exp`) und `nonce`. Eine zusätzliche Signaturprüfung ist dafür nach OIDC Core 3.1.3.7 nicht nötig.
|
||||
- **E-Mail nicht bestätigt:** Meldet authentik `email_verified = false`, ordnet DeskHub ein Konto nicht über die Adresse zu (nicht verknüpfte Mitarbeiter, Kunden). Das Verknüpfen unter „Mein authentik-Konto“ und die Anmeldung schon verknüpfter Konten gehen trotzdem. Ausschaltbar mit „Bestätigte E-Mail-Adresse verlangen“ (nur sinnvoll, wenn allein Sie in authentik Benutzer anlegen und Adressen ändern).
|
||||
- Adressen: nur `https` (`http` nur zu `localhost` zum Testen), ohne Zugangsdaten, Parameter und Anker.
|
||||
- Das Client-Geheimnis liegt verschlüsselt in der Datenbank und wird nie wieder angezeigt.
|
||||
- Fällt authentik aus, melden sich Mitarbeiter weiter mit E-Mail und Passwort an.
|
||||
|
||||
## Grenzen
|
||||
|
||||
- Mitarbeiterkonten werden nicht automatisch angelegt und es gibt keine Rollen- oder Abteilungszuordnung aus Gruppen. Kundenkonten entstehen nur auf Wunsch.
|
||||
- Kunden werden nur über die E-Mail-Adresse zugeordnet (keine feste Verknüpfung). Wer die E-Mail-Adresse in authentik ändern darf, könnte damit ein anderes Kundenkonto öffnen: Das in authentik absichern, wenn Kunden sich dort selbst registrieren dürfen.
|
||||
- Abmelden in DeskHub meldet nicht bei authentik ab (kein „Single Logout“).
|
||||
- Das Plugin schaltet die Anmeldung mit E-Mail und Passwort nicht ab.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/plugin_authentik.php` (OpenID-Ablauf mit nachgebautem authentik) und `tests/plugin_login.php` (Anmelde-Erweiterung des Servers) im DeskHub-Projekt.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Anmeldung mit authentik">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#0A5F95"/>
|
||||
<stop offset="1" stop-color="#0E8C7C"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
|
||||
<path d="M128 40 L200 66 V122 C200 166 170 196 128 214 C86 196 56 166 56 122 V66 Z" fill="#ffffff" fill-opacity=".16" stroke="#ffffff" stroke-width="8" stroke-linejoin="round"/>
|
||||
<circle cx="128" cy="104" r="20" fill="#ffd79a"/>
|
||||
<path d="M128 124 V176 M128 148 H148 M128 166 H142" fill="none" stroke="#ffd79a" stroke-width="12" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 725 B |
@@ -0,0 +1,143 @@
|
||||
{
|
||||
"_info": "English translations of the authentik plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number. Further languages: lang/<code>.json, for example lang/fr.json.",
|
||||
"(seit {}).": "(since {}).",
|
||||
". Alternativ klappt die erste Anmeldung auch, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist (dann wird automatisch verknüpft).": ". Alternatively the first sign-in also works if the email address is the same in authentik and DeskHub (it is then linked automatically).",
|
||||
". Für die Gruppenprüfung zusätzlich den Bereich, der": ". For the group check, additionally add the scope that",
|
||||
"Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein": "Only someone who is confirmed in authentik and has",
|
||||
"Anmeldung mit authentik einschalten": "Switch on sign-in with authentik",
|
||||
"Anwendung": "application",
|
||||
"Anwendungen → Provider": "Applications → Providers",
|
||||
"Auch Kunden mit authentik anmelden.": "Also sign in customers with authentik.",
|
||||
"Auf der Anmeldeseite des Mitarbeiterbereichs erscheint der Knopf „{}“.": "The button “{}” appears on the sign-in page of the staff area.",
|
||||
"Auf diesem Server fehlt die PHP-Erweiterung cURL.": "The PHP extension cURL is missing on this server.",
|
||||
"Ausgeschaltet.": "Switched off.",
|
||||
"Bereiche (Scopes)": "Scopes",
|
||||
"Bereiche:": "Scopes:",
|
||||
"Beschriftung": "Label",
|
||||
"Beschriftung des Knopfes": "Button label",
|
||||
"Bestätigte E-Mail-Adresse verlangen (empfohlen).": "Require a verified email address (recommended).",
|
||||
"Bitte die Issuer-Adresse mit https:// angeben, ohne Parameter (zum Beispiel https://auth.example.de/application/o/deskhub).": "Please enter the issuer address with https://, without parameters (for example https://auth.example.com/application/o/deskhub).",
|
||||
"Bitte zuerst anmelden.": "Please sign in first.",
|
||||
"Bitte zuerst eine gültige Issuer-Adresse speichern.": "Please save a valid issuer address first.",
|
||||
"Client-Geheimnis (Client Secret)": "Client secret",
|
||||
"Client-ID": "Client ID",
|
||||
"Client-ID und Client-Geheimnis hier eintragen. Die": "Enter the client ID and client secret here. The",
|
||||
"Client-Typ:": "Client type:",
|
||||
"Danach führt „{}“ auf der Anmeldeseite genau in dieses Konto. Auch ohne Verknüpfung klappt die Anmeldung, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist; dabei wird sie dann automatisch verknüpft.": "Afterwards “{}” on the sign-in page leads exactly into this account. Even without a link, signing in works if the email address is the same in authentik and DeskHub; it is then linked automatically.",
|
||||
"Danach führt „{}“ auf der Anmeldeseite genau in dieses Konto. Ohne Verknüpfung ist die Anmeldung mit authentik hier nicht möglich.": "Afterwards “{}” on the sign-in page leads exactly into this account. Without a link, signing in with authentik is not possible here.",
|
||||
"Das mit Ihrem authentik-Konto verknüpfte DeskHub-Konto ist deaktiviert.": "The DeskHub account linked to your authentik account is deactivated.",
|
||||
"Der Ablauf nutzt PKCE, einen Zustandswert und eine Nonce; Aussteller, Zielgruppe und Ablauf des ID-Tokens werden geprüft. Das Token kommt direkt von authentik über eine geprüfte HTTPS-Verbindung. Das Geheimnis liegt verschlüsselt in der Datenbank. Fällt authentik aus, melden sich Mitarbeiter weiter mit E-Mail und Passwort an. Jede Anmeldung steht im Admin-Protokoll.": "The flow uses PKCE, a state value and a nonce; issuer, audience and expiry of the ID token are checked. The token comes directly from authentik over a verified HTTPS connection. The secret is stored encrypted in the database. If authentik fails, staff keep signing in with email and password. Every sign-in is recorded in the admin log.",
|
||||
"Der DeskHub-Server kennt die Kunden-Anmeldung (Version 1.0.52 oder neuer).": "The DeskHub server supports customer sign-in (version 1.0.52 or newer).",
|
||||
"Der Issuer in der Antwort („{}“) passt nicht zur eingestellten Adresse.": "The issuer in the response (“{}”) does not match the configured address.",
|
||||
"Der Knopf erscheint auf der Anmeldeseite des Kundenportals. Es zählt nur die in authentik bestätigte E-Mail-Adresse: Hat ein Kundenkonto dieselbe Adresse, meldet DeskHub es an.": "The button appears on the sign-in page of the customer portal. Only the email address confirmed in authentik counts: if a customer account has the same address, DeskHub signs it in.",
|
||||
"Der Knopf erscheint dort noch nicht.": "The button does not appear there yet.",
|
||||
"Der Knopf erscheint erst nach dem Einschalten.": "The button only appears after switching on.",
|
||||
"Der Server ist zu alt: Bitte DeskHub 1.0.52 oder neuer einspielen (auch login.php, plugin_login.php und includes/plugins.php).": "The server is too old: please install DeskHub 1.0.52 or newer (including login.php, plugin_login.php and includes/plugins.php).",
|
||||
"Die Anmeldeseite des Kundenportals (login.php) ist auf dem neuesten Stand.": "The sign-in page of the customer portal (login.php) is up to date.",
|
||||
"Die Anmeldung ist abgelaufen oder wurde nicht hier gestartet. Bitte noch einmal auf „Mit authentik verknüpfen“ klicken.": "The sign-in has expired or was not started here. Please click “Link with authentik” again.",
|
||||
"Die Anmeldung ist abgelaufen oder wurde nicht hier gestartet. Bitte noch einmal auf „{}“ klicken.": "The sign-in has expired or was not started here. Please click “{}” again.",
|
||||
"Die Anmeldung mit authentik ist für Kunden nicht eingeschaltet.": "Sign-in with authentik is not switched on for customers.",
|
||||
"Die Anmeldung mit authentik ist nicht eingerichtet.": "Sign-in with authentik is not set up.",
|
||||
"Die Anmeldung mit authentik ist nicht eingerichtet. Ein Administrator kann sie unter „authentik“ einschalten.": "Sign-in with authentik is not set up. An administrator can switch it on under “authentik”.",
|
||||
"Die Antwort stammt nicht vom eingestellten authentik (Aussteller).": "The response does not come from the configured authentik (issuer).",
|
||||
"Die Antwort von authentik enthält keine Benutzerkennung.": "The response from authentik contains no user identifier.",
|
||||
"Die Antwort von authentik gehört nicht zu dieser Anmeldung (Nonce).": "The response from authentik does not belong to this sign-in (nonce).",
|
||||
"Die Antwort von authentik gilt für eine andere Anwendung.": "The response from authentik is for a different application.",
|
||||
"Die Antwort von authentik ist unlesbar.": "The response from authentik is unreadable.",
|
||||
"Die Antwort von authentik passt nicht zur gestarteten Anmeldung. Bitte noch einmal versuchen.": "The response from authentik does not match the sign-in that was started. Please try again.",
|
||||
"Die Berechtigung „auth.customer“ fehlt: Unter Plugins das Plugin deaktivieren und wieder aktivieren und die Berechtigungen bestätigen.": "The permission “auth.customer” is missing: under Plugins, deactivate and reactivate the plugin and confirm the permissions.",
|
||||
"Die Berechtigung „auth.customer“ ist freigegeben.": "The permission “auth.customer” is approved.",
|
||||
"Die Bereiche (Scopes) enthalten ungültige Zeichen.": "The scopes contain invalid characters.",
|
||||
"Die Bestätigung von authentik ist abgelaufen.": "The confirmation from authentik has expired.",
|
||||
"Die Client-ID ist ungültig.": "The client ID is invalid.",
|
||||
"Die Datei login.php im DeskHub-Hauptordner ist alt und zeigt keine Plugin-Knöpfe: Bitte die aktuelle login.php hochladen.": "The file login.php in the main DeskHub folder is outdated and shows no plugin buttons: please upload the current login.php.",
|
||||
"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.": "The email address is not verified in authentik, so DeskHub does not assign the account by address. Link your account once under “My authentik account” (after signing in with email and password); after that signing in with authentik works.",
|
||||
"Die E-Mail-Adresse ist in authentik nicht bestätigt. Bitte erst in authentik bestätigen.": "The email address is not verified in authentik. Please verify it in authentik first.",
|
||||
"Die Konfiguration von authentik enthält keinen gültigen Eintrag „{}“.": "The configuration from authentik has no valid entry “{}”.",
|
||||
"Die Verbindung ist nicht vollständig (Issuer, Client-ID, Geheimnis, eingeschaltet).": "The connection is incomplete (issuer, client ID, secret, switched on).",
|
||||
"Die Verbindung zu authentik ist eingerichtet und eingeschaltet.": "The connection to authentik is set up and switched on.",
|
||||
"Die Verknüpfung ist gelöst.": "The link has been removed.",
|
||||
"Die Verknüpfung ist gelöst. Mit authentik melden Sie sich erst wieder an, wenn Sie es neu verknüpfen (oder, falls erlaubt, über die E-Mail-Adresse).": "The link has been removed. You can only sign in with authentik again after linking it anew (or, if allowed, via the email address).",
|
||||
"Die Verknüpfung wurde von einem anderen Konto gestartet. Bitte noch einmal starten.": "The link was started by a different account. Please start it again.",
|
||||
"Dieses DeskHub-Konto ist mit einem anderen authentik-Konto verknüpft.": "This DeskHub account is linked to a different authentik account.",
|
||||
"Dieses authentik-Konto ist bereits mit einem anderen DeskHub-Konto verknüpft.": "This authentik account is already linked to a different DeskHub account.",
|
||||
"E-Mail in DeskHub": "Email in DeskHub",
|
||||
"E-Mail in authentik": "Email in authentik",
|
||||
"Eine": "Create an",
|
||||
"Eingeschaltet, aber unvollständig.": "Switched on, but incomplete.",
|
||||
"Einrichtung in authentik": "Setup in authentik",
|
||||
"Für jeden Mitarbeiter in DeskHub ein Konto mit derselben E-Mail-Adresse wie in authentik haben.": "Every staff member needs an account in DeskHub with the same email address as in authentik.",
|
||||
"Für {} 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.": "There is no active staff account in DeskHub for {}. Please ask an administrator to create it with the same email address, or link an existing account under “My authentik account”.",
|
||||
"Für {} gibt es in DeskHub kein aktives Mitarbeiterkonto. Bitte einen Administrator bitten, es mit derselben E-Mail-Adresse anzulegen.": "There is no active staff account in DeskHub for {}. Please ask an administrator to create it with the same email address.",
|
||||
"Gilt, wo die Adresse über das Konto entscheidet: bei Kunden und bei Mitarbeitern, die noch nicht verknüpft sind. Meldet authentik die Adresse als „nicht bestätigt“, kommen sie sonst nicht hinein. Wer schon verknüpft ist, meldet sich immer an. Nur ausschalten, wenn nur du in authentik Benutzer anlegst und Adressen ändern darfst.": "Applies where the address decides about the account: for customers and for staff who are not linked yet. If authentik reports the address as “not verified”, they cannot get in otherwise. Anyone who is already linked always signs in. Only switch off if you are the only one who creates users and changes addresses in authentik.",
|
||||
"Ihr DeskHub-Konto gehört zum authentik-Konto": "Your DeskHub account belongs to the authentik account",
|
||||
"Ihr authentik-Konto ({}) ist jetzt verknüpft. Die Anmeldung mit authentik führt künftig genau in dieses Konto.": "Your authentik account ({}) is now linked. Signing in with authentik now leads exactly into this account.",
|
||||
"Ihr authentik-Konto gehört nicht zur Gruppe „{}“, die für DeskHub nötig ist.": "Your authentik account does not belong to the group “{}” that DeskHub requires.",
|
||||
"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“.": "Your authentik account is not assigned to a DeskHub account yet. Sign in with email and password and link it there under “My authentik account”.",
|
||||
"In authentik unter": "In authentik, under",
|
||||
"Issuer-Adresse": "issuer address",
|
||||
"Issuer-Adresse (OpenID-Konfiguration)": "Issuer address (OpenID configuration)",
|
||||
"Issuer-Adresse, Client-ID und Geheimnis müssen eingetragen sein.": "The issuer address, client ID and secret must be entered.",
|
||||
"Jeder Mitarbeiter hat sein DeskHub-Konto und verknüpft es einmal selbst unter": "Every staff member has their DeskHub account and links it once themselves under",
|
||||
"Keine Berechtigung.": "No permission.",
|
||||
"Kunden (Ticketersteller)": "Customers (ticket creators)",
|
||||
"Kunden-Anmeldung": "Customer sign-in",
|
||||
"Kunden-Anmeldung ist ausgeschaltet: Unten unter „Kunden“ den Haken setzen und speichern.": "Customer sign-in is switched off: tick the box under “Customers” below and save.",
|
||||
"Kunden-Anmeldung ist eingeschaltet („Auch Kunden mit authentik anmelden“).": "Customer sign-in is switched on (“Also sign in customers with authentik”).",
|
||||
"Kundenkonto automatisch anlegen,": "Create the customer account automatically",
|
||||
"Lösen": "Remove",
|
||||
"Mein authentik-Konto": "My authentik account",
|
||||
"Mit authentik anmelden": "Sign in with authentik",
|
||||
"Mit authentik verknüpfen": "Link with authentik",
|
||||
"Mit einem anderen authentik-Konto neu verknüpfen": "Link again with a different authentik account",
|
||||
"Mit „{}“ auf der Anmeldeseite kommen Sie damit direkt in dieses Konto, auch wenn sich die E-Mail-Adresse in authentik oder DeskHub später ändert. Ein anderes authentik-Konto kann dieses DeskHub-Konto nicht öffnen.": "With “{}” on the sign-in page you now go straight into this account, even if the email address in authentik or DeskHub changes later. No other authentik account can open this DeskHub account.",
|
||||
"Mitarbeiter": "Staff member",
|
||||
"Mitarbeiter und Kunden melden sich mit authentik an (OpenID Connect, mit PKCE): Knopf „Mit authentik anmelden“ auf der Anmeldeseite des Mitarbeiterbereichs und, wenn eingeschaltet, des Kundenportals. Mitarbeiter: Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein aktives, verknüpftes Mitarbeiterkonto (oder eines mit derselben E-Mail-Adresse) hat; sie verknüpfen ihr Konto selbst unter „Mein authentik-Konto“. Kunden (Ticketersteller): Die bestätigte E-Mail-Adresse muss passen, das Konto wird bei Bedarf automatisch angelegt. Optional nur Mitglieder bestimmter authentik-Gruppen. E-Mail und Passwort bleiben als Zugang erhalten.": "Staff and customers sign in with authentik (OpenID Connect, with PKCE): a “Sign in with authentik” button on the sign-in page of the staff area and, if switched on, of the customer portal. Staff: only someone who is confirmed in authentik and has an active, linked staff account (or one with the same email address) in DeskHub is signed in; they link their account themselves under “My authentik account”. Customers (ticket creators): the confirmed email address must match, and the account is created automatically if wanted. Optionally restricted to authentik groups. Email and password remain available as a way in.",
|
||||
"Noch kein Mitarbeiter hat sein Konto verknüpft. Mitarbeiter tun das selbst unter „Mein authentik-Konto“; bei der ersten Anmeldung über die E-Mail-Adresse geschieht es automatisch.": "No staff member has linked their account yet. Staff do this themselves under “My authentik account”; it happens automatically on the first sign-in via the email address.",
|
||||
"Noch nicht verknüpft.": "Not linked yet.",
|
||||
"Nur Kunden aus dieser authentik-Gruppe (optional)": "Only customers from this authentik group (optional)",
|
||||
"Nur Mitglieder dieser authentik-Gruppe (optional)": "Only members of this authentik group (optional)",
|
||||
"Nur verknüpfte Konten anmelden.": "Only sign in linked accounts.",
|
||||
"Ohne Haken klappt die Anmeldung auch über die E-Mail-Adresse (das Konto wird dabei gleich verknüpft). Mit Haken muss jeder Mitarbeiter sein Konto erst unter „Mein authentik-Konto“ verknüpfen.": "Without the tick, signing in also works via the email address (the account is linked at the same time). With the tick, every staff member must first link their account under “My authentik account”.",
|
||||
"Ruft die Konfiguration von authentik ab (ohne Anmeldung). Vorher speichern.": "Fetches the configuration from authentik (without signing in). Save first.",
|
||||
"Seit": "Since",
|
||||
"Sicherheit:": "Security:",
|
||||
"Umleitungs-URI (Redirect URI)": "Redirect URI",
|
||||
"Ungültige Adresse.": "Invalid address.",
|
||||
"Verbindung in Ordnung: authentik meldet den Aussteller „{}“ und die Anmelde-Adresse {}.": "Connection OK: authentik reports the issuer “{}” and the sign-in address {}.",
|
||||
"Verbindung testen": "Test connection",
|
||||
"Verbindung zu authentik": "Connection to authentik",
|
||||
"Verknüpfen Sie Ihr DeskHub-Konto einmal mit Ihrem authentik-Konto. Sie melden sich dazu kurz bei authentik an und kommen hierher zurück.": "Link your DeskHub account once with your authentik account. You briefly sign in at authentik and come back here.",
|
||||
"Verknüpfte Konten ({#})": "Linked accounts ({})",
|
||||
"Verknüpfung dieses Mitarbeiters lösen?": "Remove this staff member's link?",
|
||||
"Verknüpfung lösen": "Remove link",
|
||||
"Verknüpfung wirklich lösen?": "Really remove the link?",
|
||||
"Zustand": "Status",
|
||||
"aktives Mitarbeiterkonto": "an active staff account",
|
||||
"aktives Mitarbeiterkonto mit derselben E-Mail-Adresse": "an active staff account with the same email address",
|
||||
"anlegen.": "create.",
|
||||
"authentik antwortet nicht mit einer gültigen Konfiguration (HTTP {}). Stimmt die Issuer-Adresse?": "authentik does not respond with a valid configuration (HTTP {}). Is the issuer address correct?",
|
||||
"authentik hat den Anmelde-Code nicht angenommen (HTTP {}). Stimmen Client-ID und Geheimnis?": "authentik did not accept the sign-in code (HTTP {}). Are the client ID and secret correct?",
|
||||
"authentik hat die Anmeldung nicht bestätigt ({}).": "authentik did not confirm the sign-in ({}).",
|
||||
"authentik hat keine E-Mail-Adresse geliefert. Im Provider den Bereich „email“ aktivieren.": "authentik did not provide an email address. Enable the scope “email” on the provider.",
|
||||
"authentik ist nicht erreichbar: {}": "authentik is not reachable: {}",
|
||||
"authentik-Anmeldung": "authentik sign-in",
|
||||
"authentik-Anmeldung (OpenID Connect)": "authentik sign-in (OpenID Connect)",
|
||||
"einen Provider vom Typ": "create a provider of type",
|
||||
"genau so eintragen (Modus „Strict“):": "enter it exactly like this (mode “Strict”):",
|
||||
"hat, das mit seinem authentik-Konto": "in DeskHub that is",
|
||||
"hat. Konten werden nicht automatisch angelegt. E-Mail und Passwort funktionieren weiter. Die zweite Stufe (Authenticator-Code) von DeskHub entfällt bei dieser Anmeldung: Dafür sorgt authentik.": "in DeskHub is signed in. Accounts are not created automatically. Email and password keep working. DeskHub's second step (authenticator code) is skipped for this sign-in: authentik takes care of that.",
|
||||
"ist (oder dieselbe E-Mail-Adresse hat). Konten werden nicht automatisch angelegt. E-Mail und Passwort funktionieren weiter. Die zweite Stufe (Authenticator-Code) von DeskHub entfällt bei dieser Anmeldung: Dafür sorgt authentik.": "to their authentik account (or has the same email address) is signed in. Accounts are not created automatically. Email and password keep working. DeskHub's second step (authenticator code) is skipped for this sign-in: authentik takes care of that.",
|
||||
"liefert (in authentik: Bereichs-Zuordnung „groups“).": "provides (in authentik: scope mapping “groups”).",
|
||||
"mindestens": "at least",
|
||||
"mit diesem Provider anlegen und bestimmen, wer sie nutzen darf (authentik prüft das selbst).": "with this provider and decide who may use it (authentik checks this itself).",
|
||||
"steht beim Provider unter „OpenID-Konfigurations-Aussteller“, sie sieht so aus:": "is shown on the provider under “OpenID Configuration Issuer”; it looks like this:",
|
||||
"verknüpft": "linked",
|
||||
"vertraulich (Confidential).": "confidential.",
|
||||
"wenn es zu der E-Mail-Adresse noch keines gibt (ohne Passwort; wer eines möchte, nutzt „Passwort vergessen“). Ohne Haken kommen nur Kunden hinein, die schon ein Konto haben.": "if there is none for the email address yet (without a password; anyone who wants one uses “Forgot password”). Without the tick only customers who already have an account get in.",
|
||||
"zum Beispiel deskhub-kunden": "for example deskhub-customers",
|
||||
"zum Beispiel deskhub-support": "for example deskhub-support",
|
||||
"✓ Der Knopf erscheint auf der Anmeldeseite des Kundenportals.": "✓ The button appears on the sign-in page of the customer portal.",
|
||||
"✓ Eingeschaltet.": "✓ Switched on.",
|
||||
"✓ Verknüpft.": "✓ Linked."
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// authentik-Anmeldung über OpenID Connect (Authorization-Code-Ablauf mit PKCE). Alle Funktionen beginnen mit authentik_.
|
||||
|
||||
const AUTHENTIK = 'authentik';
|
||||
|
||||
/** Einstellungen des Plugins (Geheimnis entschlüsselt). */
|
||||
function authentik_config(): array
|
||||
{
|
||||
$secret = (string) plugin_setting(AUTHENTIK, 'client_secret_enc', '');
|
||||
$label = trim((string) plugin_setting(AUTHENTIK, 'label', 'Mit authentik anmelden'));
|
||||
return [
|
||||
'enabled' => 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<array{staff_id:int,name:string,staff_email:string,email:string,at:string}> */
|
||||
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'];
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (($staff['role'] ?? '') !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo '<p>Keine Berechtigung.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
$message = '';
|
||||
$messageType = 'success';
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
verify_csrf();
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
if ($action === 'save') {
|
||||
$issuer = rtrim(trim((string) ($_POST['issuer'] ?? '')), '/');
|
||||
$clientId = trim((string) ($_POST['client_id'] ?? ''));
|
||||
$secretIn = (string) ($_POST['client_secret'] ?? '');
|
||||
$scopes = trim(preg_replace('/\s+/', ' ', (string) ($_POST['scopes'] ?? '')));
|
||||
if ($issuer !== '' && !authentik_url_ok($issuer)) {
|
||||
$message = 'Bitte die Issuer-Adresse mit https:// angeben, ohne Parameter (zum Beispiel https://auth.example.de/application/o/deskhub).';
|
||||
$messageType = 'error';
|
||||
} elseif (strlen($clientId) > 200 || preg_match('/[\x00-\x20]/', $clientId)) {
|
||||
$message = 'Die Client-ID ist ungültig.';
|
||||
$messageType = 'error';
|
||||
} elseif ($scopes !== '' && !preg_match('/^[A-Za-z0-9_.:\- ]{1,200}$/', $scopes)) {
|
||||
$message = 'Die Bereiche (Scopes) enthalten ungültige Zeichen.';
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
plugin_set_setting(AUTHENTIK, 'issuer', $issuer);
|
||||
plugin_set_setting(AUTHENTIK, 'client_id', $clientId);
|
||||
if ($secretIn !== '') plugin_set_setting(AUTHENTIK, 'client_secret_enc', encrypt_secret($secretIn));
|
||||
plugin_set_setting(AUTHENTIK, 'scopes', $scopes !== '' ? $scopes : 'openid email profile');
|
||||
plugin_set_setting(AUTHENTIK, 'group', mb_substr(trim((string) ($_POST['group'] ?? '')), 0, 100));
|
||||
plugin_set_setting(AUTHENTIK, 'label', mb_substr(trim((string) ($_POST['label'] ?? '')), 0, 60));
|
||||
plugin_set_setting(AUTHENTIK, 'enabled', !empty($_POST['enabled']) ? '1' : '0');
|
||||
plugin_set_setting(AUTHENTIK, 'only_linked', !empty($_POST['only_linked']) ? '1' : '0');
|
||||
plugin_set_setting(AUTHENTIK, 'require_verified', !empty($_POST['require_verified']) ? '1' : '0');
|
||||
plugin_set_setting(AUTHENTIK, 'customers', !empty($_POST['customers']) ? '1' : '0');
|
||||
plugin_set_setting(AUTHENTIK, 'customer_create', !empty($_POST['customer_create']) ? '1' : '0');
|
||||
plugin_set_setting(AUTHENTIK, 'customer_group', mb_substr(trim((string) ($_POST['customer_group'] ?? '')), 0, 100));
|
||||
plugin_set_setting(AUTHENTIK, 'discovery', ''); // neu laden
|
||||
audit_log((int) $staff['id'], 'authentik-Anmeldung: Einstellungen geändert' . (!empty($_POST['enabled']) ? ' (eingeschaltet)' : ' (ausgeschaltet)'));
|
||||
$message = 'Gespeichert.';
|
||||
}
|
||||
} elseif ($action === 'unlink_staff') {
|
||||
$sid = (int) ($_POST['staff_id'] ?? 0);
|
||||
if ($sid > 0 && authentik_link_of($sid) !== null) {
|
||||
authentik_link_remove($sid);
|
||||
audit_log((int) $staff['id'], 'authentik: Verknüpfung von Mitarbeiter #' . $sid . ' gelöst');
|
||||
$message = 'Die Verknüpfung ist gelöst.';
|
||||
}
|
||||
} elseif ($action === 'test') {
|
||||
$cfg = authentik_config();
|
||||
if (!authentik_url_ok($cfg['issuer'])) {
|
||||
$message = 'Bitte zuerst eine gültige Issuer-Adresse speichern.';
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
$d = authentik_discovery($cfg['issuer'], true);
|
||||
if ($d['ok']) {
|
||||
$message = 'Verbindung in Ordnung: authentik meldet den Aussteller „' . $d['doc']['issuer'] . '“ und die Anmelde-Adresse ' . $d['doc']['authorization_endpoint'] . '.';
|
||||
} else {
|
||||
$message = $d['error'];
|
||||
$messageType = 'error';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$cfg = authentik_config();
|
||||
$hasSecret = $cfg['client_secret'] !== '';
|
||||
$ready = authentik_ready();
|
||||
$redirect = authentik_redirect_uri();
|
||||
?>
|
||||
<h1>authentik-Anmeldung</h1>
|
||||
<?php if ($message !== ''): ?><div class="flash <?= $messageType === 'error' ? 'error' : 'success' ?>" role="status"><?= e($message) ?></div><?php endif; ?>
|
||||
|
||||
<section class="card" style="max-width:900px">
|
||||
<h3>Zustand</h3>
|
||||
<p>
|
||||
<?php if ($ready): ?><strong style="color:#157a45">✓ Eingeschaltet.</strong> Auf der Anmeldeseite des Mitarbeiterbereichs erscheint der Knopf „<?= e($cfg['label']) ?>“.
|
||||
<?php elseif ($cfg['enabled']): ?><strong style="color:#b45309">Eingeschaltet, aber unvollständig.</strong> Issuer-Adresse, Client-ID und Geheimnis müssen eingetragen sein.
|
||||
<?php else: ?><strong style="color:#b45309">Ausgeschaltet.</strong> Der Knopf erscheint erst nach dem Einschalten.<?php endif; ?>
|
||||
</p>
|
||||
<p class="muted" style="font-size:.88rem">Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein <strong>aktives Mitarbeiterkonto</strong> hat, das mit seinem authentik-Konto <strong>verknüpft</strong> ist (oder dieselbe E-Mail-Adresse hat). Konten werden nicht automatisch angelegt. E-Mail und Passwort funktionieren weiter. Die zweite Stufe (Authenticator-Code) von DeskHub entfällt bei dieser Anmeldung: Dafür sorgt authentik.</p>
|
||||
<?php
|
||||
// Warum erscheint (oder fehlt) der Knopf auf der Anmeldeseite des Kundenportals?
|
||||
$serverOk = function_exists('plugin_register_customer_login');
|
||||
$permOk = in_array('auth.customer', plugin_state()['perms'][AUTHENTIK] ?? [], true);
|
||||
$custRegistered = isset(plugin_state()['customer_logins'][AUTHENTIK]['start']);
|
||||
$checks = [
|
||||
[$serverOk, 'Der DeskHub-Server kennt die Kunden-Anmeldung (Version 1.0.52 oder neuer).', 'Der Server ist zu alt: Bitte DeskHub 1.0.52 oder neuer einspielen (auch login.php, plugin_login.php und includes/plugins.php).'],
|
||||
[$permOk, 'Die Berechtigung „auth.customer“ ist freigegeben.', 'Die Berechtigung „auth.customer“ fehlt: Unter Plugins das Plugin deaktivieren und wieder aktivieren und die Berechtigungen bestätigen.'],
|
||||
[$cfg['customers'], 'Kunden-Anmeldung ist eingeschaltet („Auch Kunden mit authentik anmelden“).', 'Kunden-Anmeldung ist ausgeschaltet: Unten unter „Kunden“ den Haken setzen und speichern.'],
|
||||
[$ready, 'Die Verbindung zu authentik ist eingerichtet und eingeschaltet.', 'Die Verbindung ist nicht vollständig (Issuer, Client-ID, Geheimnis, eingeschaltet).'],
|
||||
];
|
||||
// Zeichnet die Kunden-Anmeldeseite (login.php im DeskHub-Hauptordner) die Knöpfe der Plugins überhaupt? (Datei fehlt oder ist alt: Knopf bleibt weg)
|
||||
$loginFile = dirname(plugins_dir()) . '/login.php';
|
||||
$loginPhp = is_file($loginFile) ? (string) @file_get_contents($loginFile) : null;
|
||||
$loginOk = $loginPhp === null ? true : str_contains($loginPhp, 'customer.login.buttons');
|
||||
$checks[] = [$loginOk, 'Die Anmeldeseite des Kundenportals (login.php) ist auf dem neuesten Stand.', 'Die Datei login.php im DeskHub-Hauptordner ist alt und zeigt keine Plugin-Knöpfe: Bitte die aktuelle login.php hochladen.'];
|
||||
$allOk = $serverOk && $permOk && $cfg['customers'] && $ready && $custRegistered && $loginOk;
|
||||
?>
|
||||
<h4 style="margin:14px 0 6px">Kunden-Anmeldung</h4>
|
||||
<p style="margin:0 0 6px"><?php if ($allOk): ?><strong style="color:#157a45">✓ Der Knopf erscheint auf der Anmeldeseite des Kundenportals.</strong><?php else: ?><strong style="color:#b45309">Der Knopf erscheint dort noch nicht.</strong><?php endif; ?></p>
|
||||
<ul style="list-style:none;padding:0;margin:0;font-size:.88rem">
|
||||
<?php foreach ($checks as [$ok, $yes, $no]): ?><li><?= $ok ? '<span style="color:#157a45">✓</span> ' . e($yes) : '<span style="color:#b91c1c">✗</span> ' . e($no) ?></li><?php endforeach; ?>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<form method="post" class="card" style="max-width:900px;margin-top:16px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
<h3>Verbindung zu authentik</h3>
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:400"><input type="checkbox" name="enabled" value="1" style="width:auto" <?= $cfg['enabled'] ? 'checked' : '' ?>> Anmeldung mit authentik einschalten</label>
|
||||
<label for="issuer">Issuer-Adresse (OpenID-Konfiguration)</label>
|
||||
<input type="text" inputmode="url" id="issuer" name="issuer" value="<?= e($cfg['issuer']) ?>" placeholder="https://auth.example.de/application/o/deskhub" autocomplete="off">
|
||||
<label for="client_id">Client-ID</label>
|
||||
<input type="text" id="client_id" name="client_id" value="<?= e($cfg['client_id']) ?>" autocomplete="off">
|
||||
<label for="client_secret">Client-Geheimnis (Client Secret)</label>
|
||||
<input type="password" id="client_secret" name="client_secret" value="" placeholder="<?= $hasSecret ? '(unverändert)' : '' ?>" autocomplete="new-password">
|
||||
<label for="scopes">Bereiche (Scopes)</label>
|
||||
<input type="text" id="scopes" name="scopes" value="<?= e($cfg['scopes']) ?>">
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:10px"><input type="checkbox" name="only_linked" value="1" style="width:auto;margin-top:3px" <?= plugin_setting(AUTHENTIK, 'only_linked', '0') === '1' ? 'checked' : '' ?>> <span><strong>Nur verknüpfte Konten anmelden.</strong> Ohne Haken klappt die Anmeldung auch über die E-Mail-Adresse (das Konto wird dabei gleich verknüpft). Mit Haken muss jeder Mitarbeiter sein Konto erst unter „Mein authentik-Konto“ verknüpfen.</span></label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:10px"><input type="checkbox" name="require_verified" value="1" style="width:auto;margin-top:3px" <?= $cfg['require_verified'] ? 'checked' : '' ?>> <span><strong>Bestätigte E-Mail-Adresse verlangen (empfohlen).</strong> Gilt, wo die Adresse über das Konto entscheidet: bei Kunden und bei Mitarbeitern, die noch nicht verknüpft sind. Meldet authentik die Adresse als „nicht bestätigt“, kommen sie sonst nicht hinein. Wer schon verknüpft ist, meldet sich immer an. Nur ausschalten, wenn nur du in authentik Benutzer anlegst und Adressen ändern darfst.</span></label>
|
||||
<label for="group">Nur Mitglieder dieser authentik-Gruppe (optional)</label>
|
||||
<input type="text" id="group" name="group" value="<?= e($cfg['group']) ?>" placeholder="zum Beispiel deskhub-support">
|
||||
<h3 style="margin-top:22px">Kunden (Ticketersteller)</h3>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400"><input type="checkbox" name="customers" value="1" style="width:auto;margin-top:3px" <?= $cfg['customers'] ? 'checked' : '' ?>> <span><strong>Auch Kunden mit authentik anmelden.</strong> Der Knopf erscheint auf der Anmeldeseite des Kundenportals. Es zählt nur die in authentik bestätigte E-Mail-Adresse: Hat ein Kundenkonto dieselbe Adresse, meldet DeskHub es an.</span></label>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:10px"><input type="checkbox" name="customer_create" value="1" style="width:auto;margin-top:3px" <?= $cfg['customer_create'] ? 'checked' : '' ?>> <span><strong>Kundenkonto automatisch anlegen,</strong> wenn es zu der E-Mail-Adresse noch keines gibt (ohne Passwort; wer eines möchte, nutzt „Passwort vergessen“). Ohne Haken kommen nur Kunden hinein, die schon ein Konto haben.</span></label>
|
||||
<label for="customer_group">Nur Kunden aus dieser authentik-Gruppe (optional)</label>
|
||||
<input type="text" id="customer_group" name="customer_group" value="<?= e($cfg['customer_group']) ?>" placeholder="zum Beispiel deskhub-kunden">
|
||||
<h3 style="margin-top:22px">Beschriftung</h3>
|
||||
<label for="label">Beschriftung des Knopfes</label>
|
||||
<input type="text" id="label" name="label" value="<?= e($cfg['label']) ?>">
|
||||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||
</form>
|
||||
|
||||
<form method="post" class="card" style="max-width:900px;margin-top:16px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="test">
|
||||
<h3>Verbindung testen</h3>
|
||||
<p class="muted" style="font-size:.88rem">Ruft die Konfiguration von authentik ab (ohne Anmeldung). Vorher speichern.</p>
|
||||
<button type="submit" class="btn secondary">Verbindung testen</button>
|
||||
</form>
|
||||
|
||||
<?php $links = authentik_links_all(); ?>
|
||||
<section class="card" style="max-width:900px;margin-top:16px">
|
||||
<h3>Verknüpfte Konten (<?= count($links) ?>)</h3>
|
||||
<?php if (!$links): ?>
|
||||
<p class="muted">Noch kein Mitarbeiter hat sein Konto verknüpft. Mitarbeiter tun das selbst unter „Mein authentik-Konto“; bei der ersten Anmeldung über die E-Mail-Adresse geschieht es automatisch.</p>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x:auto"><table style="width:100%">
|
||||
<thead><tr><th>Mitarbeiter</th><th>E-Mail in DeskHub</th><th>E-Mail in authentik</th><th>Seit</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($links as $l): ?>
|
||||
<tr><td><?= e($l['name']) ?></td><td><?= e($l['staff_email']) ?></td><td><?= e($l['email']) ?></td><td><?= e(format_dt($l['at'])) ?></td>
|
||||
<td><form method="post" data-confirm="Verknüpfung dieses Mitarbeiters lösen?" onsubmit="return confirm(this.dataset.confirm)"><?= csrf_field() ?><input type="hidden" name="action" value="unlink_staff"><input type="hidden" name="staff_id" value="<?= (int) $l['staff_id'] ?>"><button class="btn secondary" style="padding:3px 10px;font-size:.8rem" type="submit">Lösen</button></form></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card" style="max-width:900px;margin-top:16px">
|
||||
<h3>Einrichtung in authentik</h3>
|
||||
<ol style="line-height:1.7;padding-left:20px;font-size:.92rem">
|
||||
<li>In authentik unter <strong>Anwendungen → Provider</strong> einen Provider vom Typ <strong>OAuth2/OpenID</strong> anlegen. <strong>Client-Typ:</strong> vertraulich (Confidential).</li>
|
||||
<li><strong>Umleitungs-URI (Redirect URI)</strong> genau so eintragen (Modus „Strict“): <code><?= e($redirect) ?></code></li>
|
||||
<li><strong>Bereiche:</strong> mindestens <code>openid</code>, <code>email</code> und <code>profile</code>. Für die Gruppenprüfung zusätzlich den Bereich, der <code>groups</code> liefert (in authentik: Bereichs-Zuordnung „groups“).</li>
|
||||
<li>Eine <strong>Anwendung</strong> mit diesem Provider anlegen und bestimmen, wer sie nutzen darf (authentik prüft das selbst).</li>
|
||||
<li>Client-ID und Client-Geheimnis hier eintragen. Die <strong>Issuer-Adresse</strong> steht beim Provider unter „OpenID-Konfigurations-Aussteller“, sie sieht so aus: <code>https://auth.example.de/application/o/<Anwendungs-Kürzel>/</code>.</li>
|
||||
<li>Jeder Mitarbeiter hat sein DeskHub-Konto und verknüpft es einmal selbst unter <strong>Mein authentik-Konto</strong>. Alternativ klappt die erste Anmeldung auch, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist (dann wird automatisch verknüpft).</li>
|
||||
</ol>
|
||||
<p class="muted" style="font-size:.85rem"><strong>Sicherheit:</strong> Der Ablauf nutzt PKCE, einen Zustandswert und eine Nonce; Aussteller, Zielgruppe und Ablauf des ID-Tokens werden geprüft. Das Token kommt direkt von authentik über eine geprüfte HTTPS-Verbindung. Das Geheimnis liegt verschlüsselt in der Datenbank. Fällt authentik aus, melden sich Mitarbeiter weiter mit E-Mail und Passwort an. Jede Anmeldung steht im Admin-Protokoll.</p>
|
||||
</section>
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// „Mein authentik-Konto“: jeder Mitarbeiter verknüpft sein DeskHub-Konto selbst mit seinem authentik-Konto.
|
||||
$staffId = (int) ($staff['id'] ?? 0);
|
||||
$message = '';
|
||||
$messageType = 'success';
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
verify_csrf();
|
||||
if (($_POST['action'] ?? '') === 'unlink' && authentik_link_of($staffId) !== null) {
|
||||
authentik_link_remove($staffId);
|
||||
audit_log($staffId, 'authentik: Verknüpfung gelöst');
|
||||
$message = 'Die Verknüpfung ist gelöst. Mit authentik melden Sie sich erst wieder an, wenn Sie es neu verknüpfen (oder, falls erlaubt, über die E-Mail-Adresse).';
|
||||
}
|
||||
}
|
||||
$ready = authentik_ready();
|
||||
$link = authentik_link_of($staffId);
|
||||
$onlyLinked = plugin_setting(AUTHENTIK, 'only_linked', '0') === '1';
|
||||
?>
|
||||
<h1>Mein authentik-Konto</h1>
|
||||
<?php if ($message !== ''): ?><div class="flash <?= $messageType === 'error' ? 'error' : 'success' ?>" role="status"><?= e($message) ?></div><?php endif; ?>
|
||||
|
||||
<section class="card" style="max-width:760px">
|
||||
<?php if (!$ready): ?>
|
||||
<p>Die Anmeldung mit authentik ist nicht eingerichtet. Ein Administrator kann sie unter „authentik“ einschalten.</p>
|
||||
<?php elseif ($link): ?>
|
||||
<p><strong style="color:#157a45">✓ Verknüpft.</strong> Ihr DeskHub-Konto gehört zum authentik-Konto <strong><?= e((string) ($link['email'] ?? '')) ?></strong> (seit <?= e(format_dt((string) ($link['at'] ?? ''))) ?>).</p>
|
||||
<p class="muted" style="font-size:.9rem">Mit „<?= e(authentik_config()['label']) ?>“ auf der Anmeldeseite kommen Sie damit direkt in dieses Konto, auch wenn sich die E-Mail-Adresse in authentik oder DeskHub später ändert. Ein anderes authentik-Konto kann dieses DeskHub-Konto nicht öffnen.</p>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||
<a class="btn secondary" href="<?= e(plugin_login_url(AUTHENTIK, 'link')) ?>">Mit einem anderen authentik-Konto neu verknüpfen</a>
|
||||
<form method="post" data-confirm="Verknüpfung wirklich lösen?" onsubmit="return confirm(this.dataset.confirm)"><?= csrf_field() ?><input type="hidden" name="action" value="unlink"><button class="btn secondary" type="submit">Verknüpfung lösen</button></form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p><strong style="color:#b45309">Noch nicht verknüpft.</strong> Verknüpfen Sie Ihr DeskHub-Konto einmal mit Ihrem authentik-Konto. Sie melden sich dazu kurz bei authentik an und kommen hierher zurück.</p>
|
||||
<p class="muted" style="font-size:.9rem">Danach führt „<?= e(authentik_config()['label']) ?>“ auf der Anmeldeseite genau in dieses Konto. <?= $onlyLinked ? 'Ohne Verknüpfung ist die Anmeldung mit authentik hier nicht möglich.' : 'Auch ohne Verknüpfung klappt die Anmeldung, wenn die E-Mail-Adresse in authentik und DeskHub gleich ist; dabei wird sie dann automatisch verknüpft.' ?></p>
|
||||
<a class="btn" href="<?= e(plugin_login_url(AUTHENTIK, 'link')) ?>">Mit authentik verknüpfen</a>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"slug": "authentik",
|
||||
"name": "authentik-Anmeldung (OpenID Connect)",
|
||||
"version": "1.2.4",
|
||||
"description": "Mitarbeiter und Kunden melden sich mit authentik an (OpenID Connect, mit PKCE): Knopf „Mit authentik anmelden“ auf der Anmeldeseite des Mitarbeiterbereichs und, wenn eingeschaltet, des Kundenportals. Mitarbeiter: Angemeldet wird nur, wer in authentik bestätigt ist und in DeskHub ein aktives, verknüpftes Mitarbeiterkonto (oder eines mit derselben E-Mail-Adresse) hat; sie verknüpfen ihr Konto selbst unter „Mein authentik-Konto“. Kunden (Ticketersteller): Die bestätigte E-Mail-Adresse muss passen, das Konto wird bei Bedarf automatisch angelegt. Optional nur Mitglieder bestimmter authentik-Gruppen. E-Mail und Passwort bleiben als Zugang erhalten.",
|
||||
"author": "DeskHub",
|
||||
"entry": "plugin.php",
|
||||
"requires": "1.0.52",
|
||||
"permissions": [
|
||||
"ui.menu",
|
||||
"auth.login",
|
||||
"auth.customer"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// authentik-Anmeldung: Knopf auf der Anmeldeseite, Start und Rückkehr des OpenID-Connect-Ablaufs, Einstellungsseite für Administratoren.
|
||||
if (!function_exists('plugin_register_login')) {
|
||||
return; // älterer DeskHub-Server ohne Anmelde-Erweiterung (Server 1.0.51 oder neuer nötig)
|
||||
}
|
||||
require_once __DIR__ . '/lib.php';
|
||||
|
||||
/** Kleines Symbol vor der Beschriftung des Anmelde-Knopfes (icon.svg, als Daten-Adresse: braucht keine eigene Datei-Adresse). */
|
||||
function authentik_button_icon(): string
|
||||
{
|
||||
static $html = null;
|
||||
if ($html === null) {
|
||||
$svg = @file_get_contents(__DIR__ . '/icon.svg');
|
||||
$html = $svg ? '<img src="data:image/svg+xml;base64,' . base64_encode($svg) . '" alt="" width="20" height="20" style="vertical-align:-5px;margin-right:8px;border-radius:5px">' : '';
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
|
||||
plugin_register_menu('authentik', 'einstellungen', null, 'admin');
|
||||
plugin_register_menu('Mein authentik-Konto', 'konto'); // für alle Mitarbeiter: das eigene Konto verknüpfen
|
||||
plugin_register_login('start', 'authentik_login_start');
|
||||
plugin_register_login('link', 'authentik_link_start', true);
|
||||
plugin_register_login('callback', 'authentik_login_callback', true); // gleiche Rückkehr-Adresse für Anmelden und Verknüpfen
|
||||
if (function_exists('plugin_register_customer_login')) { // Server 1.0.52 oder neuer
|
||||
plugin_register_customer_login('start', 'authentik_customer_start');
|
||||
plugin_add_filter('customer.login.buttons', function ($html) {
|
||||
if (!authentik_customer_ready()) return $html;
|
||||
$cfg = authentik_config();
|
||||
return $html . '<a class="btn secondary" style="display:block;text-align:center" href="' . e(plugin_customer_login_url(AUTHENTIK, 'start')) . '">' . authentik_button_icon() . e($cfg['label']) . '</a>';
|
||||
});
|
||||
}
|
||||
plugin_add_filter('staff.login.buttons', function ($html) {
|
||||
if (!authentik_ready()) return $html;
|
||||
$cfg = authentik_config();
|
||||
return $html . '<a class="btn secondary" style="display:block;text-align:center" href="' . e(plugin_login_url(AUTHENTIK, 'start')) . '">' . authentik_button_icon() . e($cfg['label']) . '</a>';
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"_info": "English translations for this plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number.",
|
||||
"Abwesenheit": "Absence",
|
||||
"Abwesenheit eintragen": "Enter absence",
|
||||
"Abwesenheit und Vertretung": "Absence and substitute",
|
||||
"An die Vertretung": "To the substitute",
|
||||
"Bis (einschließlich)": "Until (inclusive)",
|
||||
"Bitte Beginn und Ende als Datum angeben.": "Please enter the start and end as dates.",
|
||||
"Bitte eine aktive Vertretung wählen.": "Please choose an active substitute.",
|
||||
"Das Ende darf nicht vor dem Beginn liegen.": "The end must not be before the start.",
|
||||
"Der Zeitraum liegt schon in der Vergangenheit.": "The period is already in the past.",
|
||||
"Die Abwesenheit ist eingetragen.": "The absence has been entered.",
|
||||
"Die Abwesenheit wurde beendet, offene Tickets sind wieder bei der ursprünglichen Person.": "The absence has ended; open tickets are back with the original person.",
|
||||
"Die Vertretung muss eine andere Person sein.": "The substitute must be a different person.",
|
||||
"Diese Abwesenheit kann nicht beendet werden.": "This absence cannot be ended.",
|
||||
"Dieser Mitarbeiter ist nicht aktiv.": "This staff member is not active.",
|
||||
"Eintragen": "Enter",
|
||||
"In diesem Zeitraum ist für diese Person schon eine Abwesenheit eingetragen.": "An absence has already been entered for this person in this period.",
|
||||
"Keine Abwesenheiten eingetragen.": "No absences entered.",
|
||||
"Laufend und geplant": "Ongoing and planned",
|
||||
"Mitarbeiter tragen Abwesenheiten mit Vertretung ein. Zum Beginn werden ihre offenen Tickets an die Vertretung verteilt, neue Tickets gehen automatisch dorthin, und am Ende bekommt der Mitarbeiter seine noch offenen Tickets zurück. Baut auf dem Urlaubsstatus im Profil und auf der automatischen Zuweisung der Abteilungen auf.": "Staff enter absences with a substitute. At the start their open tickets are distributed to the substitute, new tickets go there automatically, and at the end the staff member gets back their still-open tickets. Builds on the vacation status in the profile and on the automatic assignment of the departments.",
|
||||
"Nicht angetreten": "Not started",
|
||||
"Urlaub und Vertretung": "Vacation and substitute",
|
||||
"Vertretung": "Substitute",
|
||||
"Vertretung für": "Substitute for",
|
||||
"Wer ist abwesend?": "Who is absent?",
|
||||
"zum Beispiel Sommerurlaub": "for example summer vacation",
|
||||
"– wählen –": "– choose –",
|
||||
"Die Vertretung sieht": "The substitute sees",
|
||||
"Abteilungen der übernommenen Tickets": "Departments of the tickets taken over",
|
||||
"Dieser Server ist älter als 1.0.55: Die Vertretung bekommt die Tickets, sieht sie aber nur, wenn sie auch Zugang zur Abteilung hat. Bitte den Server aktualisieren.": "This server is older than 1.0.55: the substitute receives the tickets but only sees them with access to the department. Please update the server.",
|
||||
"Zum Beginn gehen Ihre offenen Tickets an die Vertretung, neue Tickets ebenfalls (auch bei automatischer Zuweisung). Am Ende bekommen Sie die Tickets zurück, die dann noch bei der Vertretung liegen. Der Urlaubsstatus im Profil wird dafür automatisch gesetzt. Solange die Abwesenheit dauert, sieht und bearbeitet die Vertretung auch die Abteilungen der abwesenden Person, selbst wenn sie dort sonst keinen Zugang hat (Server ab 1.0.55).": "At the start your open tickets go to the substitute, as do new tickets (also with automatic assignment). At the end you get back the tickets that are still with the substitute. The vacation status in the profile is set automatically for this. While the absence lasts, the substitute also sees and handles the absent person’s departments, even without their own access (server 1.0.55 or later)."
|
||||
}
|
||||
@@ -33,7 +33,8 @@ $past = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="create">
|
||||
<h3>Abwesenheit eintragen</h3>
|
||||
<p class="muted" style="font-size:.85rem">Zum Beginn gehen Ihre offenen Tickets an die Vertretung, neue Tickets ebenfalls (auch bei automatischer Zuweisung). Am Ende bekommen Sie die Tickets zurück, die dann noch bei der Vertretung liegen. Der Urlaubsstatus im Profil wird dafür automatisch gesetzt.</p>
|
||||
<p class="muted" style="font-size:.85rem">Zum Beginn gehen Ihre offenen Tickets an die Vertretung, neue Tickets ebenfalls (auch bei automatischer Zuweisung). Am Ende bekommen Sie die Tickets zurück, die dann noch bei der Vertretung liegen. Der Urlaubsstatus im Profil wird dafür automatisch gesetzt. Solange die Abwesenheit dauert, sieht und bearbeitet die Vertretung auch die Abteilungen der abwesenden Person, selbst wenn sie dort sonst keinen Zugang hat (Server ab 1.0.55).</p>
|
||||
<?php if (!function_exists('staff_substitution_department_ids')): ?><div class="flash warning">Dieser Server ist älter als 1.0.55: Die Vertretung bekommt die Tickets, sieht sie aber nur, wenn sie auch Zugang zur Abteilung hat. Bitte den Server aktualisieren.</div><?php endif; ?>
|
||||
<div style="display:flex;gap:14px;flex-wrap:wrap">
|
||||
<?php if ($isAdmin): ?>
|
||||
<div><label>Wer ist abwesend?</label><select name="staff_id"><?php foreach ($people as $p): ?><option value="<?= (int) $p['id'] ?>" <?= (int) $p['id'] === (int) $staff['id'] ? 'selected' : '' ?>><?= e($p['name']) ?></option><?php endforeach; ?></select></div>
|
||||
@@ -50,15 +51,21 @@ $past = $pdo->query("SELECT a.*, (SELECT COUNT(*) FROM plugin_vertretung_moves m
|
||||
<h3>Laufend und geplant</h3>
|
||||
<?php if (!$current): ?><p class="muted">Keine Abwesenheiten eingetragen.</p><?php else: ?>
|
||||
<table style="width:100%">
|
||||
<thead><tr><th>Wer</th><th>Vertretung</th><th>Zeitraum</th><th>Stand</th><th>Tickets</th><th></th></tr></thead>
|
||||
<thead><tr><th>Wer</th><th>Vertretung</th><th>Zeitraum</th><th>Stand</th><th>Tickets</th><th>Die Vertretung sieht</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($current as $a): ?>
|
||||
<?php foreach ($current as $a):
|
||||
$dq = $pdo->prepare('SELECT d.name FROM departments d WHERE d.id = (SELECT department_id FROM staff WHERE id = ?) OR d.id IN (SELECT department_id FROM staff_department WHERE staff_id = ?) ORDER BY d.name');
|
||||
$dq->execute([(int) $a['staff_id'], (int) $a['staff_id']]);
|
||||
$deptNames = $dq->fetchAll(PDO::FETCH_COLUMN);
|
||||
$absentAdmin = ($pdo->query('SELECT role FROM staff WHERE id = ' . (int) $a['staff_id'])->fetchColumn() === 'admin');
|
||||
?>
|
||||
<tr>
|
||||
<td><?= $name($a['staff_id']) ?><?php if ($a['note']): ?><div class="muted" style="font-size:.76rem"><?= e((string) $a['note']) ?></div><?php endif; ?></td>
|
||||
<td><?= $name($a['substitute_id']) ?></td>
|
||||
<td><?= e(vertretung_date((string) $a['start_date'])) ?> – <?= e(vertretung_date((string) $a['end_date'])) ?></td>
|
||||
<td><?= e($labels[$a['status']] ?? $a['status']) ?></td>
|
||||
<td><?= $a['status'] === 'active' ? (int) $a['holding'] . ' bei der Vertretung' : '–' ?></td>
|
||||
<td style="font-size:.82rem"><?= $absentAdmin ? 'Abteilungen der übernommenen Tickets' : ($deptNames ? e(implode(', ', $deptNames)) : '–') ?></td>
|
||||
<td>
|
||||
<?php if (vertretung_can_manage($staff, (int) $a['staff_id'])): ?>
|
||||
<form method="post" style="display:inline" onsubmit="return confirm('Abwesenheit beenden? Offene Tickets gehen zurück.')"><?= csrf_field() ?><input type="hidden" name="action" value="cancel"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>"><button class="btn secondary" type="submit"><?= $a['status'] === 'active' ? 'Jetzt beenden' : 'Löschen' ?></button></form>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"slug": "vertretung",
|
||||
"name": "Urlaub und Vertretung",
|
||||
"version": "1.0.0",
|
||||
"description": "Mitarbeiter tragen Abwesenheiten mit Vertretung ein. Zum Beginn werden ihre offenen Tickets an die Vertretung verteilt, neue Tickets gehen automatisch dorthin, und am Ende bekommt der Mitarbeiter seine noch offenen Tickets zurück. Baut auf dem Urlaubsstatus im Profil und auf der automatischen Zuweisung der Abteilungen auf.",
|
||||
"version": "1.1.0",
|
||||
"description": "Mitarbeiter tragen Abwesenheiten mit Vertretung ein. Zum Beginn werden ihre offenen Tickets an die Vertretung verteilt, neue Tickets gehen automatisch dorthin, und am Ende bekommt der Mitarbeiter seine noch offenen Tickets zurück. Die Vertretung sieht während der Abwesenheit auch die Abteilungen der abwesenden Person, selbst ohne eigenen Zugang. Baut auf dem Urlaubsstatus im Profil und auf der automatischen Zuweisung der Abteilungen auf (Server ab 1.0.55).",
|
||||
"author": "DeskHub",
|
||||
"requires": "1.0.0",
|
||||
"requires": "1.0.55",
|
||||
"permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"]
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
# VirusTotal-Anhangsprüfung für DeskHub
|
||||
|
||||
Jeder Anhang eines Tickets wird **zurückgehalten, bis VirusTotal ihn geprüft hat**. Erst danach ist er für Mitarbeiter herunterladbar.
|
||||
|
||||
Der Unterschied zum Plugin **Anhänge-Virenscan**: Der Virenscan prüft sofort lokal und fragt VirusTotal (optional) erst **nachträglich**; die Datei ist in der Zwischenzeit schon verfügbar. Dieses Plugin sperrt zuerst und gibt erst nach der Antwort von VirusTotal frei. Beide laufen nebeneinander (der Virenscan prüft eine Datei erst, wenn sie hier freigegeben ist).
|
||||
|
||||
## Ablauf
|
||||
|
||||
1. Neue Anhänge (Ticket anlegen, Antwort, E-Mail-Abruf) werden sofort **zurückgehalten**: Die Datei wandert in den Ordner `uploads/vt-pruefung/` (nicht von außen erreichbar), der Anhang heißt `[IN PRÜFUNG] …` und lässt sich nicht herunterladen. Im Ticket steht ein interner Hinweis.
|
||||
2. Der Hintergrunddienst fragt bei VirusTotal den **SHA-256-Fingerabdruck** der Datei ab (nicht die Datei).
|
||||
3. Ergebnis:
|
||||
- **Bekannt und sauber** (weniger als *Schwelle* Scanner melden etwas, Standard 3): freigegeben, alles wie vorher.
|
||||
- **Schadsoftware** (mindestens *Schwelle* Scanner): bleibt gesperrt (`[GESPERRT] …`), Tag „Virus-Verdacht“, Hinweis im Ticket, Mail an den Administrator.
|
||||
- **Unbekannt:** Standard ist, die Datei **zurückzuhalten**, bis ein Administrator sie prüft und freigibt oder löscht. Einstellbar: freigeben, oder (nur wenn ausdrücklich eingeschaltet) **zur Analyse hochladen**.
|
||||
- **VirusTotal nicht erreichbar:** mehrere Versuche mit wachsendem Abstand, danach zurückhalten (oder freigeben, einstellbar).
|
||||
|
||||
## Einrichtung
|
||||
|
||||
1. Plugin hochladen und aktivieren (Berechtigungen `hooks.tickets`, `hooks.cron`, `ui.menu`, `db.migrations`).
|
||||
2. Auf virustotal.com ein (kostenloses) Konto anlegen und im Profil den **API Key** kopieren (64 Zeichen).
|
||||
3. Im Adminportal unter **VirusTotal** den Schlüssel eintragen, speichern und **Schlüssel testen**.
|
||||
|
||||
**Ohne Schlüssel werden keine Anhänge zurückgehalten.** Anhänge, die vor dem Einschalten da waren, bleiben unberührt.
|
||||
|
||||
## Datenschutz (wichtig)
|
||||
|
||||
- Standard: **Keine Datei verlässt den Server.** Es wird nur der Fingerabdruck abgefragt.
|
||||
- „Unbekannte Dateien hochladen“ (aus, bis zu 32 MB) sendet die **Datei** an VirusTotal. Dort wird sie gespeichert und steht Sicherheitsfirmen und zahlenden Kunden zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten (Ausweise, Bilder, Dokumente). Nur einschalten, wenn das für eure Nutzer in Ordnung ist und in der Datenschutzerklärung steht.
|
||||
|
||||
## Limits und Verhalten
|
||||
|
||||
- Kostenloser Schlüssel: **4 Abfragen pro Minute, 500 pro Tag.** Das Plugin hält sich daran (4 pro Lauf, höchstens 480 pro Tag). Bei vielen Anhängen entsteht eine Warteschlange; ist das Tageslimit erreicht, stehen die Dateien bis zum nächsten Tag an. Ein bezahlter Schlüssel hebt die Grenzen auf; die Zahl im Plugin bleibt konservativ.
|
||||
- Die Abfragen laufen im Hintergrunddienst von DeskHub (jede Minute, wenn der echte Cronjob eingerichtet ist, sonst alle 5 Minuten bzw. bei Seitenaufrufen). **Mit Cronjob** (Einstellungen → Cron-Jobs → „Jetzt installieren“) sind Anhänge nach etwa einer Minute frei.
|
||||
- Bis zum Ergebnis erscheint der Anhang im Ticket als `[IN PRÜFUNG]` und lässt sich nicht herunterladen (auch nicht vom Kunden, falls er seine Anhänge sieht).
|
||||
- Der **Virenscan-Schutz bleibt wichtig**: VirusTotal kennt neue, gezielte Schadsoftware oft nicht. Unbekannt heißt nicht sauber; darum ist „zurückhalten“ der Standard.
|
||||
|
||||
## Grenzen
|
||||
|
||||
- Geprüft werden Dateien bis 32 MB per Upload; die Abfrage des Fingerabdrucks funktioniert für jede Größe.
|
||||
- Es wird nur der Fingerabdruck bewertet (kein Entpacken von Archiven, kein Lesen des Inhalts); dafür gibt es den Virenscan.
|
||||
- Bereits freigegebene Dateien werden nicht erneut geprüft.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/plugin_virustotal.php` im DeskHub-Projekt (VirusTotal wird dort durch eine Funktion ersetzt; es gibt keine Verbindung zu VirusTotal).
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"_info": "English translations of the VirusTotal plugin. Key = German source text, value = English. {} stands for a changing part, {#} for a number. Further languages: lang/<code>.json, for example lang/fr.json.",
|
||||
"VirusTotal-Anhangsprüfung": "VirusTotal attachment check",
|
||||
"Hält jeden Anhang eines Tickets zurück, bis VirusTotal ihn geprüft hat: Erst wird nur der Fingerabdruck (SHA-256) abgefragt, die Datei selbst verlässt den Server nicht. Ist die Datei sauber, wird sie freigegeben; melden mehrere Scanner Schadsoftware, bleibt sie gesperrt (Quarantäne, Hinweis im Ticket, Mail an den Administrator). Unbekannte Dateien werden zur manuellen Prüfung zurückgehalten, freigegeben oder (nur wenn ausdrücklich eingeschaltet) zur Analyse bei VirusTotal hochgeladen. Arbeitet auch neben dem Plugin „Anhänge-Virenscan“. Nur für Administratoren.": "Holds back every ticket attachment until VirusTotal has checked it: first only the fingerprint (SHA-256) is looked up; the file itself does not leave the server. If the file is clean it is released; if several scanners report malware it stays blocked (quarantine, note in the ticket, email to the administrator). Unknown files are held back for manual review, released, or (only if explicitly switched on) uploaded to VirusTotal for analysis. Also works alongside the “Attachment virus scan” plugin. For administrators only.",
|
||||
"VirusTotal": "VirusTotal",
|
||||
"Zustand": "Status",
|
||||
"Noch kein Schlüssel.": "No key yet.",
|
||||
"Ohne VirusTotal-Schlüssel werden Anhänge": "Without a VirusTotal key, attachments are",
|
||||
"nicht": "not",
|
||||
"zurückgehalten und nicht geprüft. Einen kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com im Profil unter „API Key“.": "held back and not checked. A free key is available after signing in at virustotal.com, in your profile under “API Key”.",
|
||||
"✓ Aktiv.": "✓ Active.",
|
||||
"Neue Anhänge sind gesperrt, bis VirusTotal sie geprüft hat.": "New attachments are blocked until VirusTotal has checked them.",
|
||||
"warten ·": "waiting ·",
|
||||
"gesperrt ·": "blocked ·",
|
||||
"unbekannt ·": "unknown ·",
|
||||
"mit Fehler ·": "with error ·",
|
||||
"freigegeben": "released",
|
||||
"Abfragen heute: {#} von {#} (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: {}.": "Queries today: {} of {} (the free key allows 4 per minute and 500 per day). Last run: {}.",
|
||||
"Abfragen heute: {#} von {#} (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: noch nicht.": "Queries today: {} of {} (the free key allows 4 per minute and 500 per day). Last run: not yet.",
|
||||
"Vorgehen: Jeder neue Anhang wird sofort zurückgehalten (nicht herunterladbar). Dann fragt das Plugin bei VirusTotal nur den": "Procedure: every new attachment is held back immediately (not downloadable). The plugin then asks VirusTotal only for the",
|
||||
"Fingerabdruck (SHA-256)": "fingerprint (SHA-256)",
|
||||
"der Datei ab. Ist die Datei bekannt und sauber, wird sie freigegeben; melden mindestens {#} Scanner Schadsoftware, bleibt sie gesperrt. Anhänge, die schon vor dem Einschalten da waren, bleiben unberührt.": "of the file. If the file is known and clean it is released; if at least {} scanners report malware it stays blocked. Attachments that existed before the plugin was switched on are left untouched.",
|
||||
"Einstellungen": "Settings",
|
||||
"VirusTotal-Schlüssel (API Key)": "VirusTotal key (API key)",
|
||||
"64 Zeichen": "64 characters",
|
||||
"Schlüssel entfernen (Anhänge werden dann nicht mehr geprüft)": "Remove key (attachments are then no longer checked)",
|
||||
"Sperren ab so vielen Scannern, die Schadsoftware melden": "Block from this many scanners that report malware",
|
||||
"Dateien, die VirusTotal nicht kennt": "Files that VirusTotal does not know",
|
||||
"Zurückhalten, bis ein Administrator sie prüft und freigibt (sicher)": "Hold back until an administrator reviews and releases them (safe)",
|
||||
"Freigeben (bequem, aber neue Schadsoftware kennt VirusTotal oft noch nicht)": "Release (convenient, but VirusTotal often does not know new malware yet)",
|
||||
"Unbekannte Dateien zur Analyse bei VirusTotal hochladen (bis 32 MB).": "Upload unknown files to VirusTotal for analysis (up to 32 MB).",
|
||||
"Achtung Datenschutz:": "Privacy warning:",
|
||||
"Hochgeladene Dateien werden bei VirusTotal gespeichert und stehen Sicherheitsfirmen und zahlenden Kunden von VirusTotal zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten. Ausgeschaltet verlässt": "Uploaded files are stored at VirusTotal and are available to security companies and paying customers of VirusTotal. Ticket attachments can contain personal content. When switched off,",
|
||||
"keine Datei": "no file",
|
||||
"den Server, nur der Fingerabdruck wird abgefragt.": "leaves the server; only the fingerprint is looked up.",
|
||||
"Wenn VirusTotal nach mehreren Versuchen nicht erreichbar ist": "If VirusTotal cannot be reached after several attempts",
|
||||
"Zurückhalten (sicher)": "Hold back (safe)",
|
||||
"Mail an den Administrator, wenn ein Anhang gesperrt wird": "Email the administrator when an attachment is blocked",
|
||||
"Speichern": "Save",
|
||||
"Schlüssel testen": "Test key",
|
||||
"Fragt bei VirusTotal den Fingerabdruck der Standard-Testdatei (EICAR) ab. Es wird nichts hochgeladen. Vorher speichern.": "Looks up the fingerprint of the standard test file (EICAR) at VirusTotal. Nothing is uploaded. Save first.",
|
||||
"Zurückgehaltene und gesperrte Anhänge": "Held-back and blocked attachments",
|
||||
"Nichts zurückgehalten.": "Nothing held back.",
|
||||
"Zuletzt geprüft": "Recently checked",
|
||||
"Noch nichts geprüft.": "Nothing checked yet.",
|
||||
"Datei": "File",
|
||||
"Ticket": "Ticket",
|
||||
"Größe": "Size",
|
||||
"Hinweis": "Note",
|
||||
"Ergebnis": "Result",
|
||||
"Wann": "When",
|
||||
"Freigeben": "Release",
|
||||
"Erneut prüfen": "Check again",
|
||||
"Löschen": "Delete",
|
||||
"Diese Datei freigeben? Sie wird für Mitarbeiter herunterladbar.": "Release this file? Staff will be able to download it.",
|
||||
"Diese Datei endgültig löschen?": "Delete this file permanently?",
|
||||
"Wartet auf VirusTotal": "Waiting for VirusTotal",
|
||||
"Wird bei VirusTotal analysiert": "Being analysed at VirusTotal",
|
||||
"Unbekannt (zurückgehalten)": "Unknown (held back)",
|
||||
"Fehler (zurückgehalten)": "Error (held back)",
|
||||
"Gesperrt (Schadsoftware)": "Blocked (malware)",
|
||||
"Sauber, freigegeben": "Clean, released",
|
||||
"Von Hand freigegeben": "Released by hand",
|
||||
"Gelöscht": "Deleted",
|
||||
"Gespeichert.": "Saved.",
|
||||
"Die Datei ist freigegeben.": "The file has been released.",
|
||||
"Die Datei ließ sich nicht freigeben.": "The file could not be released.",
|
||||
"Die Datei ist gelöscht.": "The file has been deleted.",
|
||||
"Die Datei ließ sich nicht löschen.": "The file could not be deleted.",
|
||||
"Die Datei wird beim nächsten Lauf noch einmal geprüft.": "The file will be checked again on the next run.",
|
||||
"Das ging nicht.": "That did not work.",
|
||||
"Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).": "The VirusTotal key consists of 64 characters (0–9, a–f).",
|
||||
"Bitte zuerst einen Schlüssel eintragen und speichern.": "Please enter a key and save it first.",
|
||||
"Der Schlüssel funktioniert: Die Standard-Testdatei (EICAR) wurde von VirusTotal als gefährlich erkannt ({} von {} Scannern).": "The key works: VirusTotal recognised the standard test file (EICAR) as dangerous ({} of {} scanners).",
|
||||
"Das Abfrage-Limit von VirusTotal ist gerade erreicht. Bitte in einer Minute noch einmal versuchen.": "The VirusTotal query limit has just been reached. Please try again in a minute.",
|
||||
"Der Schlüssel wird angenommen, die Testdatei wurde aber unerwartet bewertet ({}).": "The key is accepted, but the test file was rated unexpectedly ({}).",
|
||||
"Die Abfrage ist fehlgeschlagen.": "The query failed.",
|
||||
"Keine Berechtigung.": "No permission.",
|
||||
"VirusTotal: {} von {} Scannern melden Schadsoftware": "VirusTotal: {} of {} scanners report malware",
|
||||
"VirusTotal: {} von {} Scannern melden etwas": "VirusTotal: {} of {} scanners report something",
|
||||
"VirusTotal: keine Funde": "VirusTotal: no findings",
|
||||
"Der VirusTotal-Schlüssel wird abgelehnt.": "The VirusTotal key is rejected.",
|
||||
"VirusTotal nicht erreichbar (Status {}).": "VirusTotal not reachable (status {}).",
|
||||
"Unerwartete Antwort von VirusTotal.": "Unexpected response from VirusTotal.",
|
||||
"Upload zu VirusTotal fehlgeschlagen (Status {}).": "Upload to VirusTotal failed (status {}).",
|
||||
"Zur Analyse bei VirusTotal hochgeladen": "Uploaded to VirusTotal for analysis",
|
||||
"Die Analyse bei VirusTotal wurde nicht rechtzeitig fertig.": "The analysis at VirusTotal was not finished in time.",
|
||||
"Bei VirusTotal unbekannt, freigegeben (Einstellung)": "Unknown at VirusTotal, released (setting)",
|
||||
"Bei VirusTotal unbekannt und zu groß für den Upload (über 32 MB).": "Unknown at VirusTotal and too large for the upload (over 32 MB).",
|
||||
"Bei VirusTotal unbekannt: bitte selbst prüfen und freigeben oder löschen.": "Unknown at VirusTotal: please check it yourself and release or delete it.",
|
||||
"VirusTotal nicht erreichbar, freigegeben (Einstellung)": "VirusTotal not reachable, released (setting)",
|
||||
"Von Hand freigegeben am {}": "Released by hand on {}",
|
||||
"Gelöscht am {}": "Deleted on {}",
|
||||
"Datei nicht gefunden.": "File not found.",
|
||||
"Die Datei konnte nicht zurückgehalten werden (Schreibrechte im Ordner uploads prüfen).": "The file could not be held back (check write permissions in the uploads folder).",
|
||||
"{}: Das Tageslimit des VirusTotal-Schlüssels ist erreicht, es geht morgen weiter.": "{}: The daily limit of the VirusTotal key has been reached; it continues tomorrow.",
|
||||
"{}: Der VirusTotal-Schlüssel wird abgelehnt.": "{}: The VirusTotal key is rejected.",
|
||||
"{}: VirusTotal nicht erreichbar (Status {}).": "{}: VirusTotal not reachable (status {}).",
|
||||
"{}: Unerwartete Antwort von VirusTotal.": "{}: Unexpected response from VirusTotal."
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// VirusTotal-Anhangsprüfung: jeder Anhang wird zurückgehalten, bis VirusTotal ihn geprüft hat. Alle Funktionen beginnen mit vt_.
|
||||
|
||||
const VT = 'virustotal';
|
||||
const VT_API = 'https://www.virustotal.com/api/v3';
|
||||
const VT_DAY_LIMIT = 480; // der kostenlose Schlüssel erlaubt 500 Abfragen pro Tag
|
||||
const VT_UPLOAD_MAX = 33554432; // 32 MB: größere Dateien nimmt der einfache Upload von VirusTotal nicht an
|
||||
const VT_EICAR_SHA256 = '275a021bbfb6489e54d471899f7db9d1663fc695ec2fe2a2c4538aabf651fd0f';
|
||||
|
||||
/** Selbstheilung: Fehlt die Tabelle nach einem Update, wird sie nachgeholt, statt dass das Plugin abgeschaltet wird. */
|
||||
function vt_ensure_schema(bool $force = false): void
|
||||
{
|
||||
static $done = false;
|
||||
if ($done && !$force) return;
|
||||
$done = true;
|
||||
try {
|
||||
get_db()->query('SELECT sha256, analysis_id FROM plugin_virustotal_files LIMIT 1');
|
||||
} catch (Throwable $e) {
|
||||
try {
|
||||
plugin_run_migrations(get_db(), VT);
|
||||
} catch (Throwable $e2) {
|
||||
error_log('virustotal schema: ' . $e2->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function vt_config(): array
|
||||
{
|
||||
$key = '';
|
||||
$enc = (string) plugin_setting(VT, 'key_enc', '');
|
||||
if ($enc !== '') { try { $key = decrypt_secret($enc); } catch (Throwable $e) { $key = ''; } }
|
||||
$unknown = (string) plugin_setting(VT, 'unknown', 'hold');
|
||||
return [
|
||||
'key' => $key,
|
||||
'threshold' => max(1, min(30, (int) plugin_setting(VT, 'threshold', '3'))),
|
||||
'unknown' => in_array($unknown, ['hold', 'allow'], true) ? $unknown : 'hold',
|
||||
'upload' => plugin_setting(VT, 'upload', '0') === '1',
|
||||
'notify' => plugin_setting(VT, 'notify', '1') === '1',
|
||||
'on_error' => plugin_setting(VT, 'on_error', 'hold') === 'allow' ? 'allow' : 'hold',
|
||||
];
|
||||
}
|
||||
|
||||
function vt_uploads_dir(): string
|
||||
{
|
||||
return rtrim(str_replace('\\', '/', (string) (config()['uploads']['path'] ?? dirname(__DIR__, 2) . '/uploads')), '/');
|
||||
}
|
||||
|
||||
/** Ordner für zurückgehaltene und gesperrte Dateien (nicht von außen erreichbar). */
|
||||
function vt_hold_dir(): string
|
||||
{
|
||||
$dir = vt_uploads_dir() . '/vt-pruefung';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0750, true);
|
||||
@file_put_contents($dir . '/.htaccess', "Require all denied\nDeny from all\n");
|
||||
@file_put_contents($dir . '/index.html', '');
|
||||
}
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-Aufruf zu VirusTotal (für Tests austauschbar über $GLOBALS['virustotal_http']). Nur Adressen der VirusTotal-Schnittstelle.
|
||||
* @param array{file?:string,name?:string} $opts bei POST: Datei für den Upload
|
||||
* @return array{status:int,body:string}
|
||||
*/
|
||||
function vt_http(string $method, string $path, string $key, array $opts = []): array
|
||||
{
|
||||
$url = VT_API . '/' . ltrim($path, '/');
|
||||
if (isset($GLOBALS['virustotal_http']) && is_callable($GLOBALS['virustotal_http'])) return ($GLOBALS['virustotal_http'])($method, $url, $key, $opts);
|
||||
if (!function_exists('curl_init')) return ['status' => 0, 'body' => ''];
|
||||
$body = '';
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => $method === 'POST' ? 120 : 12, CURLOPT_CONNECTTIMEOUT => 8,
|
||||
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_HTTPHEADER => ['x-apikey: ' . $key, 'Accept: application/json', 'User-Agent: DeskHub-VirusTotal'],
|
||||
CURLOPT_WRITEFUNCTION => function ($c, string $data) use (&$body): int {
|
||||
$body .= $data;
|
||||
return strlen($body) > 1048576 ? 0 : strlen($data);
|
||||
},
|
||||
]);
|
||||
if ($method === 'POST') {
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, ['file' => new CURLFile((string) ($opts['file'] ?? ''), 'application/octet-stream', (string) ($opts['name'] ?? 'datei.bin'))]);
|
||||
}
|
||||
curl_exec($ch);
|
||||
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
curl_close($ch);
|
||||
return ['status' => $status, 'body' => $body];
|
||||
}
|
||||
|
||||
/** Wertet Scan-Zahlen aus. @return array{verdict:string,malicious:int,engines:int} verdict: bad, clean oder none (noch keine Analyse) */
|
||||
function vt_verdict(array $stats, int $threshold): array
|
||||
{
|
||||
$malicious = (int) ($stats['malicious'] ?? 0);
|
||||
$engines = 0;
|
||||
foreach (['malicious', 'suspicious', 'undetected', 'harmless'] as $k) $engines += (int) ($stats[$k] ?? 0);
|
||||
if ($engines === 0) return ['verdict' => 'none', 'malicious' => 0, 'engines' => 0];
|
||||
return ['verdict' => $malicious >= $threshold ? 'bad' : 'clean', 'malicious' => $malicious, 'engines' => $engines];
|
||||
}
|
||||
|
||||
/** Fragt den Fingerabdruck einer Datei ab. @return array{result:string,detail:?string,malicious:int,engines:int} result: bad, clean, unknown, none, limit, key, error */
|
||||
function vt_lookup(string $sha256, string $key, int $threshold): array
|
||||
{
|
||||
$r = vt_http('GET', 'files/' . $sha256, $key);
|
||||
$base = ['detail' => null, 'malicious' => 0, 'engines' => 0];
|
||||
if ($r['status'] === 404) return ['result' => 'unknown'] + $base;
|
||||
if ($r['status'] === 429) return ['result' => 'limit'] + $base;
|
||||
if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'] + $base;
|
||||
if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').'] + $base;
|
||||
$j = json_decode($r['body'], true);
|
||||
$stats = $j['data']['attributes']['last_analysis_stats'] ?? null;
|
||||
if (!is_array($stats)) return ['result' => 'error', 'detail' => 'Unerwartete Antwort von VirusTotal.'] + $base;
|
||||
$v = vt_verdict($stats, $threshold);
|
||||
$label = (string) ($j['data']['attributes']['popular_threat_classification']['suggested_threat_label'] ?? '');
|
||||
$detail = $v['verdict'] === 'bad' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden Schadsoftware' . ($label !== '' ? ' (' . mb_substr($label, 0, 60) . ')' : '')
|
||||
: ($v['verdict'] === 'clean' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden etwas' : null);
|
||||
return ['result' => $v['verdict'], 'detail' => $detail, 'malicious' => $v['malicious'], 'engines' => $v['engines']];
|
||||
}
|
||||
|
||||
/** Lädt eine Datei zur Analyse hoch. @return array{ok:bool,id:?string,detail:?string} */
|
||||
function vt_upload(string $path, string $name, string $key): array
|
||||
{
|
||||
$r = vt_http('POST', 'files', $key, ['file' => $path, 'name' => $name]);
|
||||
if ($r['status'] === 429) return ['ok' => false, 'id' => null, 'detail' => 'limit'];
|
||||
if ($r['status'] === 401 || $r['status'] === 403) return ['ok' => false, 'id' => null, 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'];
|
||||
$j = json_decode($r['body'], true);
|
||||
$id = (string) ($j['data']['id'] ?? '');
|
||||
if ($r['status'] !== 200 || $id === '') return ['ok' => false, 'id' => null, 'detail' => 'Upload zu VirusTotal fehlgeschlagen (Status ' . $r['status'] . ').'];
|
||||
return ['ok' => true, 'id' => mb_substr($id, 0, 250), 'detail' => null];
|
||||
}
|
||||
|
||||
/** Fragt eine Analyse nach einem Upload ab. @return array{result:string,detail:?string,malicious:int,engines:int} result: bad, clean, wait, none, limit, key, error */
|
||||
function vt_analysis(string $id, string $key, int $threshold): array
|
||||
{
|
||||
$r = vt_http('GET', 'analyses/' . rawurlencode($id), $key);
|
||||
$base = ['detail' => null, 'malicious' => 0, 'engines' => 0];
|
||||
if ($r['status'] === 429) return ['result' => 'limit'] + $base;
|
||||
if ($r['status'] === 401 || $r['status'] === 403) return ['result' => 'key', 'detail' => 'Der VirusTotal-Schlüssel wird abgelehnt.'] + $base;
|
||||
if ($r['status'] !== 200) return ['result' => 'error', 'detail' => 'VirusTotal nicht erreichbar (Status ' . $r['status'] . ').'] + $base;
|
||||
$j = json_decode($r['body'], true);
|
||||
$attr = $j['data']['attributes'] ?? null;
|
||||
if (!is_array($attr)) return ['result' => 'error', 'detail' => 'Unerwartete Antwort von VirusTotal.'] + $base;
|
||||
if (($attr['status'] ?? '') !== 'completed') return ['result' => 'wait'] + $base;
|
||||
$v = vt_verdict((array) ($attr['stats'] ?? []), $threshold);
|
||||
return ['result' => $v['verdict'], 'detail' => $v['verdict'] === 'bad' ? 'VirusTotal: ' . $v['malicious'] . ' von ' . $v['engines'] . ' Scannern melden Schadsoftware' : null, 'malicious' => $v['malicious'], 'engines' => $v['engines']];
|
||||
}
|
||||
|
||||
// ── Anhänge zurückhalten ───────────────────────────────────────────────────
|
||||
|
||||
/** Ab welchem Anhang wird geprüft? Bestand vor dem Einschalten bleibt unberührt. */
|
||||
function vt_start_id(PDO $pdo): int
|
||||
{
|
||||
$v = plugin_setting(VT, 'start_id', null);
|
||||
if ($v === null) {
|
||||
$v = (string) (int) $pdo->query('SELECT COALESCE(MAX(id), 0) FROM ticket_attachments')->fetchColumn();
|
||||
plugin_set_setting(VT, 'start_id', $v);
|
||||
}
|
||||
return (int) $v;
|
||||
}
|
||||
|
||||
/** Anhänge ohne Eintrag (noch nicht zurückgehalten), optional nur eines Tickets. */
|
||||
function vt_unseen(PDO $pdo, int $limit = 25, ?int $ticketId = null): array
|
||||
{
|
||||
$sql = "SELECT a.id, a.original_name, a.stored_name, a.filesize, t.ticket_id FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id
|
||||
WHERE a.id > ? AND a.stored_name <> 'gesperrt' AND a.id NOT IN (SELECT attachment_id FROM plugin_virustotal_files)";
|
||||
$params = [vt_start_id($pdo)];
|
||||
if ($ticketId !== null) { $sql .= ' AND t.ticket_id = ?'; $params[] = $ticketId; }
|
||||
$stmt = $pdo->prepare($sql . ' ORDER BY a.id LIMIT ' . max(1, $limit));
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/** Hält einen Anhang zurück (Datei in den Prüfordner, Anhang nicht mehr herunterladbar) und legt den Eintrag an. @return string pending oder error */
|
||||
function vt_hold(PDO $pdo, array $att): string
|
||||
{
|
||||
$now = db_now();
|
||||
$from = vt_uploads_dir() . '/' . basename((string) $att['stored_name']);
|
||||
$insert = fn(string $status, ?string $sha, ?string $detail) => $pdo->prepare('INSERT INTO plugin_virustotal_files (attachment_id, ticket_id, original_name, stored_name, filesize, sha256, status, detail, tries, next_try, created_at) VALUES (?,?,?,?,?,?,?,?,0,?,?)')
|
||||
->execute([(int) $att['id'], $att['ticket_id'] ?? null, mb_substr((string) $att['original_name'], 0, 255), mb_substr((string) $att['stored_name'], 0, 255), (int) ($att['filesize'] ?? 0), $sha, $status, $detail, $now, $now]);
|
||||
if (!is_file($from)) { $insert('error', null, 'Datei nicht gefunden.'); return 'error'; }
|
||||
$sha = hash_file('sha256', $from);
|
||||
$to = vt_hold_dir() . '/' . basename((string) $att['stored_name']);
|
||||
if (!@rename($from, $to) && (!@copy($from, $to) || !@unlink($from))) {
|
||||
// Ohne Zurückhalten gäbe es keine Sperre: ehrlich vermerken statt so zu tun
|
||||
$insert('error', $sha, 'Die Datei konnte nicht zurückgehalten werden (Schreibrechte im Ordner uploads prüfen).');
|
||||
return 'error';
|
||||
}
|
||||
$pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[IN PRÜFUNG] ' . $att['original_name'], 0, 255), (int) $att['id']]);
|
||||
$insert('pending', $sha, null);
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
/** Hält die neuen Anhänge eines Tickets zurück und vermerkt es einmal im Ticket. @return int Anzahl */
|
||||
function vt_hold_ticket(PDO $pdo, int $ticketId): int
|
||||
{
|
||||
$n = 0;
|
||||
$names = [];
|
||||
foreach (vt_unseen($pdo, 25, $ticketId) as $att) {
|
||||
if (vt_hold($pdo, $att) === 'pending') { $n++; $names[] = (string) $att['original_name']; }
|
||||
}
|
||||
if ($n > 0) {
|
||||
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")
|
||||
->execute([$ticketId, e('Anhang' . ($n > 1 ? 'e' : '') . ' wird bei VirusTotal geprüft und ist bis dahin gesperrt: ' . implode(', ', array_map(fn($x) => '„' . $x . '“', array_slice($names, 0, 5))) . ($n > 5 ? ' …' : '')), db_now()]);
|
||||
log_activity($pdo, $ticketId, null, 'VirusTotal: ' . $n . ' Anhang/Anhänge zur Prüfung zurückgehalten');
|
||||
}
|
||||
return $n;
|
||||
}
|
||||
|
||||
function vt_row(PDO $pdo, int $id): ?array
|
||||
{
|
||||
$s = $pdo->prepare('SELECT * FROM plugin_virustotal_files WHERE id = ?');
|
||||
$s->execute([$id]);
|
||||
return $s->fetch() ?: null;
|
||||
}
|
||||
|
||||
/** Gibt eine zurückgehaltene Datei frei (sauber, bewusst freigegeben oder zugelassen). */
|
||||
function vt_release(PDO $pdo, array $row, string $status, string $detail): bool
|
||||
{
|
||||
$from = vt_hold_dir() . '/' . basename((string) $row['stored_name']);
|
||||
$to = vt_uploads_dir() . '/' . basename((string) $row['stored_name']);
|
||||
if (is_file($from) && !@rename($from, $to) && (!@copy($from, $to) || !@unlink($from))) return false;
|
||||
$pdo->prepare('UPDATE ticket_attachments SET stored_name = ?, original_name = ? WHERE id = ?')->execute([$row['stored_name'], $row['original_name'], (int) $row['attachment_id']]);
|
||||
$pdo->prepare('UPDATE plugin_virustotal_files SET status = ?, detail = ?, checked_at = ? WHERE id = ?')->execute([$status, mb_substr($detail, 0, 250), db_now(), (int) $row['id']]);
|
||||
if ($row['ticket_id']) log_activity($pdo, (int) $row['ticket_id'], null, 'VirusTotal: Anhang freigegeben: ' . $row['original_name'] . ' (' . $detail . ')');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sperrt eine Datei dauerhaft (bleibt im Prüfordner), vermerkt es im Ticket und benachrichtigt den Administrator. */
|
||||
function vt_block(PDO $pdo, array $row, string $detail, int $malicious = 0, int $engines = 0): void
|
||||
{
|
||||
$pdo->prepare("UPDATE ticket_attachments SET stored_name = 'gesperrt', original_name = ? WHERE id = ?")->execute([mb_substr('[GESPERRT] ' . $row['original_name'], 0, 255), (int) $row['attachment_id']]);
|
||||
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'bad', detail = ?, malicious = ?, engines = ?, checked_at = ? WHERE id = ?")->execute([mb_substr($detail, 0, 250), $malicious, $engines, db_now(), (int) $row['id']]);
|
||||
$ticketId = (int) ($row['ticket_id'] ?? 0);
|
||||
if ($ticketId > 0) {
|
||||
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")
|
||||
->execute([$ticketId, e('Der Anhang „' . $row['original_name'] . '“ wurde von VirusTotal als gefährlich eingestuft (' . $detail . ') und bleibt gesperrt. Er liegt in der Quarantäne und lässt sich unter VirusTotal prüfen oder löschen.'), db_now()]);
|
||||
log_activity($pdo, $ticketId, null, 'VirusTotal: Anhang gesperrt: ' . $row['original_name'] . ' (' . $detail . ')');
|
||||
if (function_exists('apply_tag_to_ticket')) apply_tag_to_ticket($pdo, $ticketId, 'Virus-Verdacht');
|
||||
}
|
||||
if (vt_config()['notify']) vt_notify($pdo, $ticketId, (string) $row['original_name'], $detail);
|
||||
}
|
||||
|
||||
function vt_notify(PDO $pdo, int $ticketId, string $name, string $detail): void
|
||||
{
|
||||
$to = (string) (get_setting('admin_email', '') ?: (config()['site']['admin_email'] ?? ''));
|
||||
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) return;
|
||||
$t = $pdo->prepare('SELECT ticket_number FROM tickets WHERE id = ?');
|
||||
$t->execute([$ticketId]);
|
||||
$number = (string) $t->fetchColumn();
|
||||
try {
|
||||
send_mail($to, 'VirusTotal: Anhang in Ticket #' . $number . ' gesperrt', '<p>Der Anhang <strong>' . e($name) . '</strong> in Ticket <strong>#' . e($number) . '</strong> wurde gesperrt (' . e($detail) . ').</p><p>Er liegt in der Quarantäne. Prüfen oder löschen kannst du ihn unter <strong>VirusTotal</strong> im Mitarbeiterbereich.</p>');
|
||||
} catch (Throwable $e) {
|
||||
// Mailfehler dürfen das Sperren nie verhindern
|
||||
}
|
||||
}
|
||||
|
||||
/** Löscht eine zurückgehaltene oder gesperrte Datei endgültig (der Anhang bleibt als „gelöscht“ im Ticket vermerkt). */
|
||||
function vt_delete(PDO $pdo, int $rowId): bool
|
||||
{
|
||||
$row = vt_row($pdo, $rowId);
|
||||
if (!$row || !in_array($row['status'], ['bad', 'unknown', 'error', 'pending', 'analyzing'], true)) return false;
|
||||
$file = vt_hold_dir() . '/' . basename((string) $row['stored_name']);
|
||||
if (is_file($file)) @unlink($file);
|
||||
$pdo->prepare('UPDATE ticket_attachments SET original_name = ? WHERE id = ?')->execute([mb_substr('[GELÖSCHT] ' . $row['original_name'], 0, 255), (int) $row['attachment_id']]);
|
||||
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'deleted', detail = ?, checked_at = ? WHERE id = ?")->execute(['Gelöscht am ' . db_now(), db_now(), $rowId]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Von Hand freigeben (zum Beispiel bei Fehlalarm oder unbekannter Datei). */
|
||||
function vt_release_manual(PDO $pdo, int $rowId): bool
|
||||
{
|
||||
$row = vt_row($pdo, $rowId);
|
||||
if (!$row || !in_array($row['status'], ['bad', 'unknown', 'error', 'pending', 'analyzing'], true)) return false;
|
||||
return vt_release($pdo, $row, 'released', 'Von Hand freigegeben am ' . db_now());
|
||||
}
|
||||
|
||||
/** Noch einmal prüfen lassen (zum Beispiel nach einem Fehler). */
|
||||
function vt_recheck(PDO $pdo, int $rowId): bool
|
||||
{
|
||||
$row = vt_row($pdo, $rowId);
|
||||
if (!$row || !in_array($row['status'], ['unknown', 'error'], true)) return false;
|
||||
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'pending', tries = 0, next_try = ?, detail = NULL, analysis_id = NULL WHERE id = ?")->execute([db_now(), $rowId]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Hintergrundlauf ────────────────────────────────────────────────────────
|
||||
|
||||
function vt_day_used(): int
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
if (plugin_setting(VT, 'day', '') !== $today) { plugin_set_setting(VT, 'day', $today); plugin_set_setting(VT, 'day_count', '0'); }
|
||||
return (int) plugin_setting(VT, 'day_count', '0');
|
||||
}
|
||||
|
||||
function vt_day_add(int $n = 1): void
|
||||
{
|
||||
plugin_set_setting(VT, 'day_count', (string) (vt_day_used() + $n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Entscheidung nach einer Abfrage. @return bool false = Lauf beenden (Tageslimit, Minutenlimit oder Schlüsselproblem)
|
||||
*/
|
||||
function vt_apply(PDO $pdo, array $row, array $res, array $cfg): bool
|
||||
{
|
||||
$soon = fn(int $sec) => date('Y-m-d H:i:s', time() + $sec);
|
||||
switch ($res['result']) {
|
||||
case 'limit':
|
||||
$pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]);
|
||||
return false;
|
||||
case 'key':
|
||||
plugin_set_setting(VT, 'last_error', db_now() . ': ' . $res['detail']);
|
||||
return false;
|
||||
case 'error':
|
||||
plugin_set_setting(VT, 'last_error', db_now() . ': ' . $res['detail']);
|
||||
$tries = (int) $row['tries'] + 1;
|
||||
if ($tries >= 6) {
|
||||
if ($cfg['on_error'] === 'allow') vt_release($pdo, $row, 'released', 'VirusTotal nicht erreichbar, freigegeben (Einstellung)');
|
||||
else $pdo->prepare("UPDATE plugin_virustotal_files SET status = 'error', detail = ?, tries = ?, checked_at = ? WHERE id = ?")->execute([mb_substr((string) $res['detail'], 0, 250), $tries, db_now(), $row['id']]);
|
||||
} else {
|
||||
$pdo->prepare('UPDATE plugin_virustotal_files SET tries = ?, next_try = ?, detail = ? WHERE id = ?')->execute([$tries, $soon(min(3600, 60 * (2 ** $tries))), mb_substr((string) $res['detail'], 0, 250), $row['id']]);
|
||||
}
|
||||
return true;
|
||||
case 'bad':
|
||||
plugin_set_setting(VT, 'last_error', '');
|
||||
vt_block($pdo, $row, (string) $res['detail'], (int) $res['malicious'], (int) $res['engines']);
|
||||
return true;
|
||||
case 'clean':
|
||||
plugin_set_setting(VT, 'last_error', '');
|
||||
vt_release($pdo, $row, 'clean', (string) ($res['detail'] ?? 'VirusTotal: keine Funde'));
|
||||
$pdo->prepare('UPDATE plugin_virustotal_files SET malicious = ?, engines = ? WHERE id = ?')->execute([(int) $res['malicious'], (int) $res['engines'], $row['id']]);
|
||||
return true;
|
||||
case 'wait':
|
||||
if (time() - (int) strtotime((string) $row['created_at']) > 3600) {
|
||||
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'error', detail = ?, checked_at = ? WHERE id = ?")->execute(['Die Analyse bei VirusTotal wurde nicht rechtzeitig fertig.', db_now(), $row['id']]);
|
||||
} else {
|
||||
$pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]);
|
||||
}
|
||||
return true;
|
||||
default: // unknown oder none: VirusTotal kennt die Datei (noch) nicht
|
||||
$path = vt_hold_dir() . '/' . basename((string) $row['stored_name']);
|
||||
$size = is_file($path) ? (int) filesize($path) : 0;
|
||||
if ($cfg['upload'] && $size > 0 && $size <= VT_UPLOAD_MAX && $row['status'] === 'pending') {
|
||||
$up = vt_upload($path, (string) $row['original_name'], $cfg['key']);
|
||||
vt_day_add();
|
||||
if (!$up['ok']) {
|
||||
if ($up['detail'] === 'limit') { $pdo->prepare('UPDATE plugin_virustotal_files SET next_try = ? WHERE id = ?')->execute([$soon(60), $row['id']]); return false; }
|
||||
return vt_apply($pdo, $row, ['result' => 'error', 'detail' => $up['detail'], 'malicious' => 0, 'engines' => 0], $cfg);
|
||||
}
|
||||
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'analyzing', analysis_id = ?, next_try = ?, detail = 'Zur Analyse bei VirusTotal hochgeladen' WHERE id = ?")->execute([$up['id'], $soon(60), $row['id']]);
|
||||
return true;
|
||||
}
|
||||
if ($cfg['unknown'] === 'allow') {
|
||||
vt_release($pdo, $row, 'released', 'Bei VirusTotal unbekannt, freigegeben (Einstellung)');
|
||||
return true;
|
||||
}
|
||||
$why = $cfg['upload'] && $size > VT_UPLOAD_MAX ? 'Bei VirusTotal unbekannt und zu groß für den Upload (über 32 MB).' : 'Bei VirusTotal unbekannt: bitte selbst prüfen und freigeben oder löschen.';
|
||||
$pdo->prepare("UPDATE plugin_virustotal_files SET status = 'unknown', detail = ?, checked_at = ? WHERE id = ?")->execute([$why, db_now(), $row['id']]);
|
||||
if ((int) $row['ticket_id'] > 0) log_activity($pdo, (int) $row['ticket_id'], null, 'VirusTotal: Anhang bleibt gesperrt (unbekannt): ' . $row['original_name']);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hintergrundlauf (jede Minute oder alle 5 Minuten): zurückgehaltene Anhänge abfragen, höchstens 4 pro Lauf (Grenze des kostenlosen
|
||||
* Schlüssels) und 480 pro Tag. @return int Zahl der Abfragen
|
||||
*/
|
||||
function vt_tick(PDO $pdo): int
|
||||
{
|
||||
$cfg = vt_config();
|
||||
if ($cfg['key'] === '') return 0;
|
||||
// Zwei Auslöser (jede Minute und alle 5 Minuten) sollen sich nicht überlappen
|
||||
if (time() - (int) plugin_setting(VT, 'tick_at', '0') < 25) return 0;
|
||||
plugin_set_setting(VT, 'tick_at', (string) time());
|
||||
// Anhänge, die kein Ereignis gemeldet hat (zum Beispiel aus E-Mails), nachträglich zurückhalten
|
||||
foreach (vt_unseen($pdo, 25) as $att) vt_hold($pdo, $att);
|
||||
$rows = $pdo->prepare("SELECT * FROM plugin_virustotal_files WHERE status IN ('pending','analyzing') AND next_try <= ? ORDER BY id LIMIT 4");
|
||||
$rows->execute([db_now()]);
|
||||
$done = 0;
|
||||
foreach ($rows->fetchAll() as $row) {
|
||||
if (vt_day_used() >= VT_DAY_LIMIT) { plugin_set_setting(VT, 'last_error', db_now() . ': Das Tageslimit des VirusTotal-Schlüssels ist erreicht, es geht morgen weiter.'); break; }
|
||||
$res = $row['status'] === 'analyzing' && $row['analysis_id']
|
||||
? vt_analysis((string) $row['analysis_id'], $cfg['key'], $cfg['threshold'])
|
||||
: vt_lookup((string) $row['sha256'], $cfg['key'], $cfg['threshold']);
|
||||
vt_day_add();
|
||||
$done++;
|
||||
$pdo->prepare('UPDATE plugin_virustotal_files SET checked_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
|
||||
if (!vt_apply($pdo, $row, $res, $cfg)) break;
|
||||
}
|
||||
if ($done > 0) plugin_set_setting(VT, 'last_run', db_now());
|
||||
return $done;
|
||||
}
|
||||
|
||||
/** Zahlen für die Seite. @return array<string,int> */
|
||||
function vt_stats(PDO $pdo): array
|
||||
{
|
||||
$out = ['pending' => 0, 'analyzing' => 0, 'clean' => 0, 'bad' => 0, 'unknown' => 0, 'error' => 0, 'released' => 0, 'deleted' => 0];
|
||||
foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM plugin_virustotal_files GROUP BY status')->fetchAll() as $r) $out[$r['status']] = (int) $r['n'];
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Prüft, ob der Schlüssel funktioniert (Abfrage des Fingerabdrucks der Standard-Testdatei EICAR). @return array{ok:bool,message:string} */
|
||||
function vt_test_key(string $key, int $threshold): array
|
||||
{
|
||||
if ($key === '') return ['ok' => false, 'message' => 'Bitte zuerst einen Schlüssel eintragen und speichern.'];
|
||||
$r = vt_lookup(VT_EICAR_SHA256, $key, $threshold);
|
||||
if ($r['result'] === 'bad') return ['ok' => true, 'message' => 'Der Schlüssel funktioniert: Die Standard-Testdatei (EICAR) wurde von VirusTotal als gefährlich erkannt (' . $r['malicious'] . ' von ' . $r['engines'] . ' Scannern).'];
|
||||
if ($r['result'] === 'limit') return ['ok' => false, 'message' => 'Das Abfrage-Limit von VirusTotal ist gerade erreicht. Bitte in einer Minute noch einmal versuchen.'];
|
||||
if ($r['result'] === 'clean' || $r['result'] === 'unknown' || $r['result'] === 'none') return ['ok' => true, 'message' => 'Der Schlüssel wird angenommen, die Testdatei wurde aber unerwartet bewertet (' . $r['result'] . ').'];
|
||||
return ['ok' => false, 'message' => (string) ($r['detail'] ?? 'Die Abfrage ist fehlgeschlagen.')];
|
||||
}
|
||||
|
||||
plugin_add_hook('plugin.uninstall.' . VT, function () {
|
||||
try {
|
||||
get_db()->exec('DROP TABLE IF EXISTS plugin_virustotal_files');
|
||||
} catch (Throwable $e) {
|
||||
// Die Dateien im Ordner uploads/vt-pruefung bleiben liegen
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS plugin_virustotal_files (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
attachment_id INT NOT NULL,
|
||||
ticket_id INT NULL,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
stored_name VARCHAR(255) NOT NULL,
|
||||
filesize BIGINT NOT NULL DEFAULT 0,
|
||||
sha256 VARCHAR(64) NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
detail VARCHAR(255) NULL,
|
||||
analysis_id VARCHAR(255) NULL,
|
||||
malicious INT NULL,
|
||||
engines INT NULL,
|
||||
tries INT NOT NULL DEFAULT 0,
|
||||
next_try DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
checked_at DATETIME NULL,
|
||||
UNIQUE KEY idx_plugin_virustotal_attachment (attachment_id),
|
||||
KEY idx_plugin_virustotal_status (status, next_try)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS plugin_virustotal_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
attachment_id INTEGER NOT NULL,
|
||||
ticket_id INTEGER NULL,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
stored_name VARCHAR(255) NOT NULL,
|
||||
filesize INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 VARCHAR(64) NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
detail VARCHAR(255) NULL,
|
||||
analysis_id VARCHAR(255) NULL,
|
||||
malicious INTEGER NULL,
|
||||
engines INTEGER NULL,
|
||||
tries INTEGER NOT NULL DEFAULT 0,
|
||||
next_try DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
checked_at DATETIME NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_plugin_virustotal_attachment ON plugin_virustotal_files (attachment_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plugin_virustotal_status ON plugin_virustotal_files (status, next_try)
|
||||
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
if (($staff['role'] ?? '') !== 'admin') {
|
||||
http_response_code(403);
|
||||
echo '<p>Keine Berechtigung.</p>';
|
||||
return;
|
||||
}
|
||||
$pdo = get_db();
|
||||
vt_ensure_schema();
|
||||
$message = '';
|
||||
$messageType = 'success';
|
||||
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
verify_csrf();
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if ($action === 'save') {
|
||||
$keyIn = trim((string) ($_POST['key'] ?? ''));
|
||||
if ($keyIn !== '' && !preg_match('/^[a-f0-9]{64}$/i', $keyIn)) {
|
||||
$message = 'Der VirusTotal-Schlüssel besteht aus 64 Zeichen (0–9, a–f).';
|
||||
$messageType = 'error';
|
||||
} else {
|
||||
if ($keyIn !== '') plugin_set_setting(VT, 'key_enc', encrypt_secret($keyIn));
|
||||
if (!empty($_POST['clear_key'])) plugin_set_setting(VT, 'key_enc', '');
|
||||
plugin_set_setting(VT, 'threshold', (string) max(1, min(30, (int) ($_POST['threshold'] ?? 3))));
|
||||
plugin_set_setting(VT, 'unknown', ($_POST['unknown'] ?? 'hold') === 'allow' ? 'allow' : 'hold');
|
||||
plugin_set_setting(VT, 'on_error', ($_POST['on_error'] ?? 'hold') === 'allow' ? 'allow' : 'hold');
|
||||
plugin_set_setting(VT, 'upload', !empty($_POST['upload']) ? '1' : '0');
|
||||
plugin_set_setting(VT, 'notify', !empty($_POST['notify']) ? '1' : '0');
|
||||
audit_log((int) $staff['id'], 'VirusTotal: Einstellungen geändert' . (!empty($_POST['upload']) ? ' (Upload unbekannter Dateien an)' : ''));
|
||||
$message = 'Gespeichert.';
|
||||
}
|
||||
} elseif ($action === 'test') {
|
||||
$t = vt_test_key(vt_config()['key'], vt_config()['threshold']);
|
||||
$message = $t['message'];
|
||||
$messageType = $t['ok'] ? 'success' : 'error';
|
||||
} elseif ($action === 'release' && $id > 0) {
|
||||
$ok = vt_release_manual($pdo, $id);
|
||||
if ($ok) audit_log((int) $staff['id'], 'VirusTotal: Anhang #' . $id . ' von Hand freigegeben');
|
||||
$message = $ok ? 'Die Datei ist freigegeben.' : 'Die Datei ließ sich nicht freigeben.';
|
||||
$messageType = $ok ? 'success' : 'error';
|
||||
} elseif ($action === 'delete' && $id > 0) {
|
||||
$ok = vt_delete($pdo, $id);
|
||||
if ($ok) audit_log((int) $staff['id'], 'VirusTotal: Anhang #' . $id . ' gelöscht');
|
||||
$message = $ok ? 'Die Datei ist gelöscht.' : 'Die Datei ließ sich nicht löschen.';
|
||||
$messageType = $ok ? 'success' : 'error';
|
||||
} elseif ($action === 'recheck' && $id > 0) {
|
||||
$ok = vt_recheck($pdo, $id);
|
||||
$message = $ok ? 'Die Datei wird beim nächsten Lauf noch einmal geprüft.' : 'Das ging nicht.';
|
||||
$messageType = $ok ? 'success' : 'error';
|
||||
}
|
||||
}
|
||||
|
||||
$cfg = vt_config();
|
||||
$stats = vt_stats($pdo);
|
||||
$used = vt_day_used();
|
||||
$lastError = (string) plugin_setting(VT, 'last_error', '');
|
||||
$lastRun = (string) plugin_setting(VT, 'last_run', '');
|
||||
$held = $pdo->query("SELECT f.*, t.ticket_number FROM plugin_virustotal_files f LEFT JOIN tickets t ON t.id = f.ticket_id WHERE f.status IN ('pending','analyzing','unknown','error','bad') ORDER BY (CASE f.status WHEN 'bad' THEN 0 WHEN 'unknown' THEN 1 WHEN 'error' THEN 2 ELSE 3 END), f.id DESC LIMIT 100")->fetchAll();
|
||||
$recent = $pdo->query("SELECT * FROM plugin_virustotal_files WHERE status IN ('clean','released','deleted') ORDER BY id DESC LIMIT 30")->fetchAll();
|
||||
$labels = ['pending' => ['Wartet auf VirusTotal', '#b45309'], 'analyzing' => ['Wird bei VirusTotal analysiert', '#b45309'], 'unknown' => ['Unbekannt (zurückgehalten)', '#b45309'], 'error' => ['Fehler (zurückgehalten)', '#b91c1c'],
|
||||
'bad' => ['Gesperrt (Schadsoftware)', '#b91c1c'], 'clean' => ['Sauber, freigegeben', '#15803d'], 'released' => ['Von Hand freigegeben', '#15803d'], 'deleted' => ['Gelöscht', '#64748b']];
|
||||
$mb = fn($b) => $b >= 1048576 ? round($b / 1048576, 1) . ' MB' : max(1, (int) round($b / 1024)) . ' KB';
|
||||
?>
|
||||
<h1>VirusTotal</h1>
|
||||
<?php if ($message !== ''): ?><div class="flash <?= $messageType === 'error' ? 'error' : 'success' ?>" role="status"><?= e($message) ?></div><?php endif; ?>
|
||||
|
||||
<section class="card" style="max-width:960px">
|
||||
<h3>Zustand</h3>
|
||||
<?php if ($cfg['key'] === ''): ?>
|
||||
<p><strong style="color:#b45309">Noch kein Schlüssel.</strong> Ohne VirusTotal-Schlüssel werden Anhänge <strong>nicht</strong> zurückgehalten und nicht geprüft. Einen kostenlosen Schlüssel gibt es nach der Anmeldung bei virustotal.com im Profil unter „API Key“.</p>
|
||||
<?php else: ?>
|
||||
<p><strong style="color:#157a45">✓ Aktiv.</strong> Neue Anhänge sind gesperrt, bis VirusTotal sie geprüft hat.</p>
|
||||
<?php endif; ?>
|
||||
<p style="font-size:.9rem">
|
||||
<strong><?= (int) ($stats['pending'] + $stats['analyzing']) ?></strong> warten ·
|
||||
<strong style="color:#b91c1c"><?= (int) $stats['bad'] ?></strong> gesperrt ·
|
||||
<strong style="color:#b45309"><?= (int) $stats['unknown'] ?></strong> unbekannt ·
|
||||
<strong><?= (int) $stats['error'] ?></strong> mit Fehler ·
|
||||
<strong><?= (int) ($stats['clean'] + $stats['released']) ?></strong> freigegeben
|
||||
</p>
|
||||
<p class="muted" style="font-size:.85rem">Abfragen heute: <?= (int) $used ?> von <?= (int) VT_DAY_LIMIT ?> (der kostenlose Schlüssel erlaubt 4 pro Minute und 500 pro Tag). Letzter Lauf: <?= $lastRun !== '' ? e(format_dt($lastRun)) : 'noch nicht' ?>.</p>
|
||||
<?php if ($lastError !== ''): ?><p style="color:#b91c1c;font-size:.88rem"><?= e($lastError) ?></p><?php endif; ?>
|
||||
<p class="muted" style="font-size:.85rem">Vorgehen: Jeder neue Anhang wird sofort zurückgehalten (nicht herunterladbar). Dann fragt das Plugin bei VirusTotal nur den <strong>Fingerabdruck (SHA-256)</strong> der Datei ab. Ist die Datei bekannt und sauber, wird sie freigegeben; melden mindestens <?= (int) $cfg['threshold'] ?> Scanner Schadsoftware, bleibt sie gesperrt. Anhänge, die schon vor dem Einschalten da waren, bleiben unberührt.</p>
|
||||
</section>
|
||||
|
||||
<form method="post" class="card" style="max-width:960px;margin-top:16px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
<h3>Einstellungen</h3>
|
||||
<label for="key">VirusTotal-Schlüssel (API Key)</label>
|
||||
<input type="password" id="key" name="key" value="" placeholder="<?= $cfg['key'] !== '' ? '(unverändert)' : '64 Zeichen' ?>" autocomplete="new-password">
|
||||
<?php if ($cfg['key'] !== ''): ?><label style="display:flex;gap:8px;align-items:center;font-weight:400;margin-top:6px"><input type="checkbox" name="clear_key" value="1" style="width:auto"> Schlüssel entfernen (Anhänge werden dann nicht mehr geprüft)</label><?php endif; ?>
|
||||
<label for="threshold">Sperren ab so vielen Scannern, die Schadsoftware melden</label>
|
||||
<input type="number" id="threshold" name="threshold" min="1" max="30" value="<?= (int) $cfg['threshold'] ?>" style="max-width:120px">
|
||||
<label for="unknown">Dateien, die VirusTotal nicht kennt</label>
|
||||
<select id="unknown" name="unknown" style="max-width:520px">
|
||||
<option value="hold" <?= $cfg['unknown'] === 'hold' ? 'selected' : '' ?>>Zurückhalten, bis ein Administrator sie prüft und freigibt (sicher)</option>
|
||||
<option value="allow" <?= $cfg['unknown'] === 'allow' ? 'selected' : '' ?>>Freigeben (bequem, aber neue Schadsoftware kennt VirusTotal oft noch nicht)</option>
|
||||
</select>
|
||||
<label style="display:flex;gap:8px;align-items:flex-start;font-weight:400;margin-top:12px"><input type="checkbox" name="upload" value="1" style="width:auto;margin-top:3px" <?= $cfg['upload'] ? 'checked' : '' ?>> <span><strong>Unbekannte Dateien zur Analyse bei VirusTotal hochladen (bis 32 MB).</strong> <span style="color:#b91c1c">Achtung Datenschutz:</span> Hochgeladene Dateien werden bei VirusTotal gespeichert und stehen Sicherheitsfirmen und zahlenden Kunden von VirusTotal zur Verfügung. Ticket-Anhänge können persönliche Inhalte enthalten. Ausgeschaltet verlässt <strong>keine Datei</strong> den Server, nur der Fingerabdruck wird abgefragt.</span></label>
|
||||
<label for="on_error" style="margin-top:12px">Wenn VirusTotal nach mehreren Versuchen nicht erreichbar ist</label>
|
||||
<select id="on_error" name="on_error" style="max-width:520px">
|
||||
<option value="hold" <?= $cfg['on_error'] === 'hold' ? 'selected' : '' ?>>Zurückhalten (sicher)</option>
|
||||
<option value="allow" <?= $cfg['on_error'] === 'allow' ? 'selected' : '' ?>>Freigeben</option>
|
||||
</select>
|
||||
<label style="display:flex;gap:8px;align-items:center;font-weight:400;margin-top:12px"><input type="checkbox" name="notify" value="1" style="width:auto" <?= $cfg['notify'] ? 'checked' : '' ?>> Mail an den Administrator, wenn ein Anhang gesperrt wird</label>
|
||||
<div class="btn-row"><button type="submit" class="btn">Speichern</button></div>
|
||||
</form>
|
||||
|
||||
<form method="post" class="card" style="max-width:960px;margin-top:16px">
|
||||
<?= csrf_field() ?><input type="hidden" name="action" value="test">
|
||||
<h3>Schlüssel testen</h3>
|
||||
<p class="muted" style="font-size:.88rem">Fragt bei VirusTotal den Fingerabdruck der Standard-Testdatei (EICAR) ab. Es wird nichts hochgeladen. Vorher speichern.</p>
|
||||
<button type="submit" class="btn secondary">Schlüssel testen</button>
|
||||
</form>
|
||||
|
||||
<section class="card" style="margin-top:16px">
|
||||
<h3>Zurückgehaltene und gesperrte Anhänge</h3>
|
||||
<?php if (!$held): ?>
|
||||
<p class="muted">Nichts zurückgehalten.</p>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x:auto"><table style="width:100%">
|
||||
<thead><tr><th>Datei</th><th>Ticket</th><th>Größe</th><th>Zustand</th><th>Hinweis</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($held as $r): [$label, $color] = $labels[$r['status']] ?? [$r['status'], '#64748b']; ?>
|
||||
<tr>
|
||||
<td><?= e($r['original_name']) ?><div class="muted" style="font-size:.72rem;word-break:break-all"><?= e((string) $r['sha256']) ?></div></td>
|
||||
<td><?php if (!empty($r['ticket_number'])): ?><a href="/staff/ticket_view.php?n=<?= e(urlencode((string) $r['ticket_number'])) ?>">#<?= e((string) $r['ticket_number']) ?></a><?php endif; ?></td>
|
||||
<td><?= e($mb((int) $r['filesize'])) ?></td>
|
||||
<td><strong style="color:<?= e($color) ?>"><?= e($label) ?></strong></td>
|
||||
<td style="max-width:300px;font-size:.82rem"><?= e((string) $r['detail']) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<?php foreach ([['release', 'Freigeben', 'Diese Datei freigeben? Sie wird für Mitarbeiter herunterladbar.'], ['recheck', 'Erneut prüfen', ''], ['delete', 'Löschen', 'Diese Datei endgültig löschen?']] as [$act, $text, $ask]):
|
||||
if ($act === 'recheck' && !in_array($r['status'], ['unknown', 'error'], true)) continue; ?>
|
||||
<form method="post" style="display:inline"<?= $ask !== '' ? ' data-confirm="' . e($ask) . '" onsubmit="return confirm(this.dataset.confirm)"' : '' ?>><?= csrf_field() ?><input type="hidden" name="action" value="<?= e($act) ?>"><input type="hidden" name="id" value="<?= (int) $r['id'] ?>"><button class="btn secondary" style="padding:3px 10px;font-size:.8rem" type="submit"><?= e($text) ?></button></form>
|
||||
<?php endforeach; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top:16px">
|
||||
<h3>Zuletzt geprüft</h3>
|
||||
<?php if (!$recent): ?>
|
||||
<p class="muted">Noch nichts geprüft.</p>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x:auto"><table style="width:100%">
|
||||
<thead><tr><th>Datei</th><th>Zustand</th><th>Ergebnis</th><th>Wann</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($recent as $r): [$label, $color] = $labels[$r['status']] ?? [$r['status'], '#64748b']; ?>
|
||||
<tr><td><?= e($r['original_name']) ?></td><td><strong style="color:<?= e($color) ?>"><?= e($label) ?></strong></td><td class="muted" style="font-size:.82rem"><?= e((string) $r['detail']) ?></td><td><?= e(format_dt((string) ($r['checked_at'] ?: $r['created_at']))) ?></td></tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"slug": "virustotal",
|
||||
"name": "VirusTotal-Anhangsprüfung",
|
||||
"version": "1.0.0",
|
||||
"description": "Hält jeden Anhang eines Tickets zurück, bis VirusTotal ihn geprüft hat: Erst wird nur der Fingerabdruck (SHA-256) abgefragt, die Datei selbst verlässt den Server nicht. Ist die Datei sauber, wird sie freigegeben; melden mehrere Scanner Schadsoftware, bleibt sie gesperrt (Quarantäne, Hinweis im Ticket, Mail an den Administrator). Unbekannte Dateien werden zur manuellen Prüfung zurückgehalten, freigegeben oder (nur wenn ausdrücklich eingeschaltet) zur Analyse bei VirusTotal hochgeladen. Arbeitet auch neben dem Plugin „Anhänge-Virenscan“. Nur für Administratoren.",
|
||||
"author": "DeskHub",
|
||||
"entry": "plugin.php",
|
||||
"requires": "1.0.0",
|
||||
"permissions": ["hooks.tickets", "hooks.cron", "ui.menu", "db.migrations"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
// VirusTotal-Anhangsprüfung: hält Anhänge neuer Tickets und Antworten zurück, bis VirusTotal sie geprüft hat.
|
||||
require_once __DIR__ . '/lib.php';
|
||||
vt_ensure_schema();
|
||||
try {
|
||||
vt_start_id(get_db()); // Startmarke beim ersten Laden setzen: was vorher schon da war, bleibt unberührt (und der erste neue Anhang wird sicher erfasst)
|
||||
} catch (Throwable $e) {
|
||||
error_log('virustotal start: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
foreach (['ticket.created', 'ticket.replied'] as $hook) {
|
||||
plugin_add_hook($hook, function (array $payload) {
|
||||
// Nur zurückhalten, wenn ein Schlüssel eingetragen ist: ohne Schlüssel gäbe es nie ein Ergebnis und die Anhänge blieben für immer gesperrt
|
||||
if (!empty($payload['ticketId']) && vt_config()['key'] !== '') vt_hold_ticket(get_db(), (int) $payload['ticketId']);
|
||||
});
|
||||
}
|
||||
// Abfragen: jede Minute (wenn der Hintergrunddienst es kann), sonst alle 5 Minuten
|
||||
plugin_add_hook('cron.minute', function () {
|
||||
vt_tick(get_db());
|
||||
});
|
||||
plugin_add_hook('cron.run', function () {
|
||||
vt_tick(get_db());
|
||||
});
|
||||
|
||||
plugin_register_menu('VirusTotal', 'virustotal', null, 'admin');
|
||||
Reference in New Issue
Block a user