Anmeldung per AWL-Konto: neuer Endpunkt api/sso.php - v1.4.0 [deploy]
All checks were successful
Deploy netdiag / deploy (push) Successful in 15s

GET meldet, ob die Anmeldung ueber die awlauth-Seite eingerichtet ist, und legt
NetDiag dort beim ersten Aufruf selbst an. POST loest den Einmal-Code mit
PKCE-Geheimnis ein und antwortet wie auth.php - eingeloest wird im Modul, weil
eine App auf dem Handy das App-Geheimnis fuer awlauth/sso/token.php nicht
verwahren kann. Braucht awlauth >= 1.6.0, sonst available:false.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-10-01 10:00:15 +02:00
parent bb6681ac37
commit b9d97dc027
4 changed files with 228 additions and 9 deletions

View file

@ -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

View file

@ -42,21 +42,32 @@ Alle unter `https://<dolibarr>/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 <token>` oder `?jwt=<token>`.
Authentifizierung **ausschliesslich** per `Authorization: Bearer <token>`.
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

172
api/sso.php Normal file
View file

@ -0,0 +1,172 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 <https://www.gnu.org/licenses/>.
*/
/**
* \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'),
),
));

View file

@ -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);