diff --git a/ChangeLog.md b/ChangeLog.md index f40e2f2..b25c7e2 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,29 @@ # Changelog NetDiag +## 1.4.0 — 2026-10-01 + +### Anmeldung per AWL-Konto (SSO über die awlauth-Seite) +Die App kann sich jetzt ohne Passworteingabe anmelden: Knopf „Mit AWL-Konto anmelden" öffnet +die awlauth-Anmeldeseite im Chrome-Tab. Ist Eddy im Handy-Chrome schon angemeldet (z. B. aus +einer der PWAs), springt die Seite sofort zurück in die App; sonst nach Passwort oder +Fingerabdruck auf der awlauth-Seite. Bis hierher war die Anmeldung in Chrome für die App +unsichtbar — ihr WebView hat einen eigenen Cookie-Speicher (KB #1451). + +Neuer Endpunkt `api/sso.php`: +- **GET** meldet, ob der Weg eingerichtet ist, und legt NetDiag beim ersten Aufruf selbst in + awlauth an (Reiter „Angebundene Apps", Rücksprung `de.data-it-solution.netdiag:/sso`). Ist die + App dort abgeschaltet oder fehlt die Adresse, bleibt es dabei — dann `available:false` mit + Grund, und die App bietet nur das Passwort an. +- **POST** `{code, code_verifier}` löst den Einmal-Code ein und antwortet wie `auth.php`. + Eingelöst wird **hier** und nicht über `awlauth/sso/token.php`: das verlangt das + App-Geheimnis, und eine App auf dem Handy kann keines verwahren. Gegen abgefangene Codes + schützt PKCE (Pflicht, zweimal geprüft). Rate-Limit je IP wie bei awlauth. +- Sitzung, Beschriftung („NetDiag-App · Android") und Gültigkeit sind dieselben wie bei der + Passwort-Anmeldung — „Gerät abmelden" in awlauth wirkt unverändert. + +**Braucht awlauth ≥ 1.6.0** (App-Schema + PKCE). Mit älterem awlauth antwortet GET mit +`available:false`, alles andere läuft weiter. Die Passwort-Anmeldung (`auth.php`) bleibt. + ## 1.3.1 — 2026-09-07 ### Reiter am Kunden nur noch bei vorhandenen Protokollen diff --git a/README.md b/README.md index 2a95a9a..9cae93e 100644 --- a/README.md +++ b/README.md @@ -42,21 +42,32 @@ Alle unter `https:///custom/netdiag/api/`: | Endpunkt | Methode | Zweck | |----------|---------|-------| | `auth.php` | POST `{login,password}` | Anmeldung → `{token,expiresIn,user}` | +| `sso.php` | GET | Anmeldung per AWL-Konto eingerichtet? → `{available,clientId,redirectUri,authorizePath}` (legt die App in awlauth beim ersten Aufruf an) | +| `sso.php` | POST `{code,code_verifier}` | Einmal-Code der awlauth-Seite einlösen (PKCE) → wie `auth.php` | | `customers.php` | GET `?q=` / `?id=` | Kundensuche / Kundendetail | | `orders.php` | GET `?open=1&q=` / `?id=` | Auftragsliste / Auftragsdetail | | `protocols.php` | GET `?id=` | Protokoll mit Geräten + Messungen | | `protocols.php` | POST `{action:"sync",protocol:{…}}` | Protokoll anlegen/aktualisieren (idempotent über `clientUuid`) | -| `pdf.php` | GET `?id=&jwt=` | Protokoll-PDF streamen | +| `pdf.php` | GET `?id=` | Protokoll-PDF streamen (nur Bearer-Header) | +| `update.php` | GET / GET `?download=1` | neueste APK-Version aus der Registry / APK streamen | +| `applog.php` | POST | Debug-Log der App entgegennehmen | -Authentifizierung per `Authorization: Bearer ` oder `?jwt=`. +Authentifizierung **ausschliesslich** per `Authorization: Bearer `. Jeder Endpunkt prüft ein Recht — `protocols.php` im GET-Zweig `protocol read` **oder** `write`: die Rechte sind in Dolibarr einzeln vergebbar, und ein Techniker mit Schreib- ohne ausdrückliches Leserecht darf nicht ausgesperrt werden (das fiele erst beim Kunden auf). -> `?jwt=` in der URL ist nur für den PDF-Download da (der Browser kann dort -> keinen Header setzen). Langzeit-Token in URLs landen in Zugriffs- und -> Proxy-Logs — Ablösung steht in `ROADMAP_UMSETZUNG.md`, Phase L5. +> **Token gehört nicht in die Adresszeile** (seit v1.3.0). Eine URL mit `?jwt=` +> steht in jedem Zugriffs- und Proxy-Log, und das Token gilt sieben Tage für die +> gesamte Kunden-API. `netdiag_api_read_token()` nimmt einen Query-Token deshalb +> nur noch an, wenn der Endpunkt es ausdrücklich verlangt. +> +> Genau **eine** solche Stelle gibt es, befristet: `update.php?download=1`. Die im +> Feld installierten App-Fassungen bauen die Download-Adresse mit dem Token darin — +> wer den Zweig sofort schließt, sperrt genau die Geräte vom Update aus, die die +> neue APK bräuchten. Zu entfernen, sobald der Rollout bestätigt ist; der Hinweis +> steht im Code an beiden Stellen (`api/update.php`, `api/netdiag_api.lib.php`). ## Neue Messart aus der App aufnehmen @@ -75,18 +86,30 @@ Spiegelbildlich dazu `app/src/lib/messfelder.ts` in der App. **Feldnamen immer aus dem erzeugenden Code der App ablesen** (`types.ts`, die `run()`-Rümpfe der Werkzeuge), nie aus dem Kopf und nie aus selbst -geschriebenen Testdaten — das ist hier schon zweimal schiefgegangen (KB #1084). +geschriebenen Testdaten — das ist hier schon dreimal schiefgegangen (KB #1084). +Beim dritten Mal war es eine **Namenskollision**: `port` gehört bereits dem +SNMP-Werkzeug („Switch-Port"), ein SIP-Port unter demselben Schlüssel wäre im +Kundendokument als „Switch-Port: 5060" erschienen. Vor dem Eintragen prüfen, ob der +Schlüssel schon vergeben ist, und im Zweifel im Werkzeug umbenennen (`zielPort`). Braucht die Messart eine eigene Tabellendarstellung im PDF, einen Zweig in `lib/netdiag_pdf.lib.php` ergänzen (Vorbilder: `netdiagPdfStressTest`, -`netdiagPdfWifiKanal`) — sonst wird das Ergebnis zu einer `|`-getrennten Zeile +`netdiagPdfWifiKanal`, `netdiagPdfSip`) — sonst wird das Ergebnis zu einer `|`-getrennten Zeile zusammengeschoben. ## Einrichtung **Einrichtung → Module → NetDiag → Einstellungen:** -- Token-Gültigkeit (Sekunden) - App-Download-URL (APK) — wird als QR-Code angezeigt +Die **Gültigkeit der Anmeldung** steht bewusst nicht hier, sondern allein in +`AWLAUTH_TTL` im awlauth-Setup (Standard 7 Tage). Zwei Stellschrauben für dieselbe +Sache sind eine Fehlerquelle — die hiesige hätte nach der Umstellung auf awlauth +still gar nichts mehr getan (Feld mit v1.2.0 entfernt). + +Nicht im Formular, aber nötig für die Update-Funktion: die Konstante +`NETDIAG_FORGEJO_TOKEN` (Lesezugriff auf die Paket-Registry). Ohne sie antwortet +`update.php` mit „Update-Funktion nicht konfiguriert". + ## Lizenz GPLv3 diff --git a/api/sso.php b/api/sso.php new file mode 100644 index 0000000..8b40a7c --- /dev/null +++ b/api/sso.php @@ -0,0 +1,172 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file netdiag/api/sso.php + * \ingroup netdiag + * \brief API-Endpunkt: Anmeldung der App ueber die awlauth-Anmeldeseite (SSO). + * + * GET -> {available, clientId, redirectUri, authorizePath} + * POST {code, code_verifier} -> {token, expiresIn, user} (wie auth.php) + * + * Ablauf: Die App oeffnet `authorizePath` im Chrome-Tab. Ist der Benutzer im + * Handy-Browser schon angemeldet (awl_sso-Cookie, z. B. aus einer der PWAs), + * springt awlauth ohne Rueckfrage mit einem Einmal-Code zurueck in die App + * (`de.data-it-solution.netdiag:/sso?code=…&state=…`), sonst erst nach + * Passwort oder Fingerabdruck auf der awlauth-Seite. Die App loest den Code + * hier ein. + * + * Warum hier und nicht ueber awlauth/sso/token.php: token.php verlangt das + * App-Geheimnis, und eine App auf dem Handy kann keines verwahren — es steckte + * lesbar in der APK. Dieses Modul laeuft im selben Dolibarr wie awlauth und + * loest den Code direkt ein. Gegen einen abgefangenen Code (jede App kann sich + * das Schema zusaetzlich registrieren) schuetzt PKCE: eingeloest wird nur mit + * dem Geheimnis, dessen Pruefwert die App beim Start mitgeschickt hat. + * + * Die App wird in awlauth beim ersten GET selbst als „NetDiag" angelegt. Ist + * sie dort abgeschaltet oder fehlt die Ruecksprungadresse, bleibt sie es — + * dann antwortet GET mit available=false und die App zeigt nur das Passwortfeld. + */ + +require_once __DIR__.'/netdiag_api.lib.php'; + +netdiag_api_bootstrap(); + +/** @var DoliDB $db */ +/** @var Translate $langs */ + +/** Kennung der App in awlauth und ihre einzige Ruecksprungadresse */ +const NETDIAG_SSO_CLIENT_ID = 'netdiag'; +const NETDIAG_SSO_REDIRECT = 'de.data-it-solution.netdiag:/sso'; + +/** + * Prueft, ob die Anmeldung ueber awlauth moeglich ist, und legt die App dort + * bei Bedarf an. + * + * @return array{0: array|null, 1: string} App-Zeile (oder null) und Grund + */ +function netdiag_sso_client() +{ + if (!netdiag_awlauth_available()) { + return array(null, 'Das Modul AWL-Auth ist nicht aktiv'); + } + dol_include_once('/awlauth/lib/awlauth_sso.lib.php'); + if (!function_exists('awlauth_sso_pkce_verify')) { + return array(null, 'AWL-Auth ist zu alt für die Anmeldung per Weiterleitung (ab Version 1.6.0)'); + } + + $client = awlauth_sso_client_fetch(NETDIAG_SSO_CLIENT_ID, false); + if ($client === null) { + $neu = awlauth_sso_client_create('NetDiag', array(NETDIAG_SSO_REDIRECT)); + if ($neu === null || $neu['client_id'] !== NETDIAG_SSO_CLIENT_ID) { + return array(null, 'NetDiag konnte in AWL-Auth nicht angelegt werden'); + } + dol_syslog('netdiag sso: App in awlauth angelegt', LOG_INFO); + $client = awlauth_sso_client_fetch(NETDIAG_SSO_CLIENT_ID, false); + } + if ($client === null || empty($client['active'])) { + return array(null, 'NetDiag ist in AWL-Auth abgeschaltet'); + } + if (!awlauth_sso_redirect_allowed($client, NETDIAG_SSO_REDIRECT)) { + return array(null, 'In AWL-Auth fehlt die Rücksprungadresse '.NETDIAG_SSO_REDIRECT); + } + return array($client, ''); +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + list($client, $grund) = netdiag_sso_client(); + if ($client === null) { + netdiag_api_respond(array('available' => false, 'reason' => $grund)); + } + netdiag_api_respond(array( + 'available' => true, + 'clientId' => (string) $client['client_id'], + 'redirectUri' => NETDIAG_SSO_REDIRECT, + 'authorizePath' => dol_buildpath('/awlauth/sso/authorize.php', 1), + )); +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + netdiag_api_error('Nur GET oder POST erlaubt', 405); +} + +list($client, $grund) = netdiag_sso_client(); +if ($client === null) { + netdiag_api_error('Anmeldung per Weiterleitung nicht möglich: '.$grund, 503); +} + +// Dieselbe Bremse wie bei awlauth/sso/token.php: Fehlversuche je Absender-IP +$rlKey = 'netdiag-sso:'.awlauth_client_ip(); +$rate = awlauth_rl_check($rlKey, 10, 900, true); +if (!$rate['allowed']) { + netdiag_api_error('Zu viele Fehlversuche — bitte später erneut versuchen', 429); +} + +$body = netdiag_api_read_body(); +$code = isset($body['code']) ? (string) $body['code'] : ''; +$verifier = isset($body['code_verifier']) ? (string) $body['code_verifier'] : ''; +if ($code === '' || $verifier === '') { + netdiag_api_error('code und code_verifier erforderlich', 400); +} + +$row = awlauth_sso_code_redeem($code, $client, NETDIAG_SSO_REDIRECT, $verifier); +// Zweite Sicherung: ohne hinterlegten Pruefwert wird hier nie eingeloest, +// auch wenn awlauth das irgendwann anders handhaben sollte. +if ($row === null || (string) $row['code_challenge'] === '') { + awlauth_rl_fail($rlKey, 900); + netdiag_api_error('Anmeldung abgelaufen oder ungültig — bitte erneut anmelden', 400); +} +awlauth_rl_clear($rlKey); + +require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php'; +$user = new User($db); +// Zwischen Anmeldung und Einloesen liegen Sekunden — massgeblich ist das Konto JETZT +if ($user->fetch((int) $row['fk_user']) <= 0 || $user->statut != 1 || !empty($user->socid)) { + netdiag_api_error('Benutzer nicht aktiv oder nicht für die App freigegeben', 403); +} +$user->getrights(); +if (!$user->hasRight('netdiag', 'protocol', 'read')) { + netdiag_api_error('Keine Berechtigung für NetDiag', 403); +} + +// Gleiche Sitzungsart und gleiche Beschriftung wie bei auth.php — in der +// awlauth-Geraeteliste steht das Handy so oder so als „NetDiag-App · Android". +$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : ''; +$label = 'NetDiag-App'; +if ($ua !== '' && function_exists('awlauth_device_label')) { + $parts = explode(' · ', awlauth_device_label($ua)); + if (!empty($parts[0])) { + $label .= ' · '.$parts[0]; + } +} + +$issued = awlauth_issue_bearer($user, 0, (string) $row['login_type'], $label); +if ($issued === null) { + netdiag_api_error('Anmeldung konnte nicht abgeschlossen werden', 500); +} + +netdiag_api_respond(array( + 'token' => $issued['token'], + 'expiresIn' => (int) $issued['expiresIn'], + 'user' => array( + 'id' => (int) $user->id, + 'login' => $user->login, + 'name' => $user->getFullName($langs), + 'email' => $user->email, + 'canWrite' => (bool) $user->hasRight('netdiag', 'protocol', 'write'), + ), +)); diff --git a/core/modules/modNetDiag.class.php b/core/modules/modNetDiag.class.php index dcdf789..16cee3b 100644 --- a/core/modules/modNetDiag.class.php +++ b/core/modules/modNetDiag.class.php @@ -67,7 +67,7 @@ class modNetDiag extends DolibarrModules $this->editor_url = ''; $this->editor_squarred_logo = ''; - $this->version = '1.3.1'; + $this->version = '1.4.0'; // Konstanten-Name in llx_const für Modul-Status $this->const_name = 'MAIN_MODULE_'.strtoupper($this->name);