All checks were successful
Deploy netdiag / deploy (push) Successful in 14s
NetDiag war die letzte AWL-App mit eigenem Login: eigenes JWT, eigener Schluessel,
eigene Gueltigkeitsdauer, keine Moeglichkeit ein verlorenes Handy gezielt abzumelden.
- api/auth.php prueft das Passwort ueber awlauth_login(). Damit greifen dort auch die
Brute-Force-Bremse (5 Fehlversuche je IP in 15 min, 10 je Benutzername in 30 min)
und die einheitliche, nicht-verraeterische Fehlermeldung. Das Token kommt aus
awlauth_issue_bearer() und legt eine Sitzungszeile an: das Geraet erscheint als
"NetDiag-App · Android" in der awlauth-Geraeteliste und ist dort einzeln abmeldbar.
- netdiag_api_authenticate() prueft zuerst das awlauth-Token; ein widerrufenes Token
fuehrt sofort zu 401, auch wenn die Signatur noch stimmt.
- Uebergangsweise gilt ein bereits ausgestelltes altes netdiag-JWT weiter, damit die
Umstellung niemanden mitten im Einsatz aussperrt. Faellt weg, sobald alle Geraete
einmal neu angemeldet sind.
- Ohne aktives awlauth laeuft das Modul unveraendert mit dem eigenen JWT weiter.
Die Antwortform von auth.php bleibt bewusst {token, expiresIn, user}: bereits
installierte APKs laufen nach einer einmaligen Neuanmeldung ohne Update weiter.
Haette man sie geaendert, waeren alle Geraete ausgesperrt — und die neue APK gibt es
nur ueber update.php, das Anmeldung verlangt.
CORS: Wildcard-Origin raus. Die API liefert Kundendaten aus; ein * erlaubt jeder
Webseite die Antwort auszulesen, sobald sie an ein Token kommt. Erlaubt sind jetzt nur
die App-Origins und der Vite-Dev-Server, dazu Vary: Origin. Anfragen ohne Origin
(nativer Client, APK-Downloader im Plugin, curl) sind unveraendert.
Lokal gegen das Test-Dolibarr geprueft: Login liefert awlauth-Token, Sitzungszeile
entsteht, orders.php mit Bearer = 200, falsches Passwort = 401, kein/manipuliertes
Token = 401, 4. Fehlversuch = 429 mit Wartezeit, Alt-Token = 200 (Fallback),
fremder Origin bekommt keinen CORS-Header. Anschliessend im Emulator durchgespielt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
440 lines
13 KiB
PHP
440 lines
13 KiB
PHP
<?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/netdiag_api.lib.php
|
|
* \ingroup netdiag
|
|
* \brief Gemeinsame Funktionen der JSON-API: Bootstrap, JWT, Antworten.
|
|
*
|
|
* Wird von jedem API-Endpunkt eingebunden. Lädt die Dolibarr-Umgebung
|
|
* ohne Web-Session und authentifiziert die mobile App per JWT.
|
|
*/
|
|
|
|
// Konstanten setzen BEVOR Dolibarr geladen wird (kein Menü, kein HTML, kein Login)
|
|
if (!defined('NOLOGIN')) {
|
|
define('NOLOGIN', '1');
|
|
}
|
|
if (!defined('NOCSRFCHECK')) {
|
|
define('NOCSRFCHECK', '1');
|
|
}
|
|
if (!defined('NOTOKENRENEWAL')) {
|
|
define('NOTOKENRENEWAL', '1');
|
|
}
|
|
if (!defined('NOREQUIREMENU')) {
|
|
define('NOREQUIREMENU', '1');
|
|
}
|
|
if (!defined('NOREQUIREHTML')) {
|
|
define('NOREQUIREHTML', '1');
|
|
}
|
|
if (!defined('NOREQUIREAJAX')) {
|
|
define('NOREQUIREAJAX', '1');
|
|
}
|
|
if (!defined('NOREQUIRESOC')) {
|
|
define('NOREQUIRESOC', '1');
|
|
}
|
|
|
|
// ===================================================================
|
|
// Dolibarr-Umgebung laden — MUSS im globalen Scope passieren.
|
|
// master.inc.php innerhalb einer Funktion zu includen würde $conf,
|
|
// $db, $langs, $user ... in den Funktions-Scope legen; nach return
|
|
// wären sie weg und jeder DB-Zugriff liefe gegen null -> HTTP 500.
|
|
// Dieser Block läuft, sobald ein Endpunkt die Lib per require_once
|
|
// einbindet, also im File-Scope des Endpunkts = global.
|
|
// ===================================================================
|
|
$res = 0;
|
|
$tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME'];
|
|
$tmp2 = realpath(__FILE__);
|
|
$i = strlen($tmp) - 1;
|
|
$j = strlen($tmp2) - 1;
|
|
while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) {
|
|
$i--;
|
|
$j--;
|
|
}
|
|
if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/master.inc.php")) {
|
|
$res = @include substr($tmp, 0, ($i + 1))."/master.inc.php";
|
|
}
|
|
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/master.inc.php")) {
|
|
$res = @include dirname(substr($tmp, 0, ($i + 1)))."/master.inc.php";
|
|
}
|
|
if (!$res && file_exists("../../../master.inc.php")) {
|
|
$res = @include "../../../master.inc.php";
|
|
}
|
|
if (!$res && file_exists("../../../../master.inc.php")) {
|
|
$res = @include "../../../../master.inc.php";
|
|
}
|
|
if (!$res) {
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
http_response_code(500);
|
|
echo json_encode(array('error' => 'Dolibarr environment not found'));
|
|
exit;
|
|
}
|
|
unset($res, $tmp, $tmp2, $i, $j);
|
|
|
|
/**
|
|
* Herkünfte, die die API im Browser-Sinne aufrufen dürfen.
|
|
*
|
|
* Die App läuft im Capacitor-WebView auf einem eigenen Origin — je nach
|
|
* `androidScheme` in capacitor.config.ts ist das `https://localhost` (Release)
|
|
* oder `http://localhost` (lokaler Debug-Build gegen ein Test-Dolibarr).
|
|
* Dazu der Vite-Dev-Server für die Entwicklung im Browser.
|
|
*
|
|
* @return string[] Erlaubte Origins
|
|
*/
|
|
function netdiag_api_allowed_origins()
|
|
{
|
|
return array(
|
|
'https://localhost',
|
|
'http://localhost',
|
|
'capacitor://localhost',
|
|
'ionic://localhost',
|
|
'http://localhost:5173',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* CORS-Header setzen und Preflight (OPTIONS) sofort beantworten.
|
|
*
|
|
* Dolibarr selbst ist zu diesem Zeitpunkt bereits geladen (siehe Block
|
|
* oben, der beim require_once dieser Lib im globalen Scope läuft).
|
|
*
|
|
* Kein Wildcard-Origin mehr: die API liefert Kundendaten aus und wird per
|
|
* Bearer-Token authentifiziert. Ein `*` erlaubt jeder beliebigen Webseite,
|
|
* die Antwort auszulesen, sobald sie an ein Token kommt. Fehlt der
|
|
* Origin-Header ganz (native HTTP-Clients, curl, der APK-Downloader im
|
|
* Kotlin-Plugin), wird kein CORS-Header gesetzt — dort greift die Same-Origin-
|
|
* Policy des Browsers ohnehin nicht.
|
|
*
|
|
* @return void
|
|
*/
|
|
function netdiag_api_bootstrap()
|
|
{
|
|
$origin = isset($_SERVER['HTTP_ORIGIN']) ? (string) $_SERVER['HTTP_ORIGIN'] : '';
|
|
// Antwort hängt vom Origin ab -> Caches/Proxys müssen das wissen
|
|
header('Vary: Origin');
|
|
|
|
if ($origin !== '' && in_array($origin, netdiag_api_allowed_origins(), true)) {
|
|
header('Access-Control-Allow-Origin: '.$origin);
|
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type, Authorization');
|
|
header('Access-Control-Max-Age: 86400');
|
|
}
|
|
|
|
// Preflight sofort beantworten
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(204);
|
|
exit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Zentrales Auth-Modul einbinden, sofern vorhanden und aktiv.
|
|
*
|
|
* NetDiag läuft auch ohne awlauth weiter (Fallback auf das modul-eigene JWT) —
|
|
* so, wie es die awlauth-Doku für Fremdmodule vorschreibt. Damit lässt sich das
|
|
* Modul auch in einer Instanz betreiben, in der awlauth nicht installiert ist.
|
|
*
|
|
* @return bool True wenn awlauth benutzbar ist
|
|
*/
|
|
function netdiag_awlauth_available()
|
|
{
|
|
static $ok = null;
|
|
if ($ok !== null) {
|
|
return $ok;
|
|
}
|
|
$ok = false;
|
|
if (function_exists('dol_include_once')) {
|
|
dol_include_once('/awlauth/lib/awlauth.lib.php');
|
|
}
|
|
if (function_exists('awlauth_is_enabled') && function_exists('awlauth_verify_token')) {
|
|
$ok = awlauth_is_enabled();
|
|
}
|
|
return $ok;
|
|
}
|
|
|
|
/**
|
|
* JSON-Antwort senden und Skript beenden.
|
|
*
|
|
* @param mixed $data Antwortdaten
|
|
* @param int $httpstatus HTTP-Statuscode
|
|
* @return void
|
|
*/
|
|
function netdiag_api_respond($data, $httpstatus = 200)
|
|
{
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
http_response_code($httpstatus);
|
|
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Fehler-Antwort senden und Skript beenden.
|
|
*
|
|
* @param string $message Fehlermeldung
|
|
* @param int $httpstatus HTTP-Statuscode
|
|
* @return void
|
|
*/
|
|
function netdiag_api_error($message, $httpstatus = 400)
|
|
{
|
|
netdiag_api_respond(array('error' => $message), $httpstatus);
|
|
}
|
|
|
|
/**
|
|
* Base64-URL-kodieren (JWT-konform, ohne Padding).
|
|
*
|
|
* @param string $data Rohdaten
|
|
* @return string Kodierter String
|
|
*/
|
|
function netdiag_base64url_encode($data)
|
|
{
|
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
|
}
|
|
|
|
/**
|
|
* Base64-URL-dekodieren.
|
|
*
|
|
* @param string $data Kodierter String
|
|
* @return string Rohdaten
|
|
*/
|
|
function netdiag_base64url_decode($data)
|
|
{
|
|
return base64_decode(strtr($data, '-_', '+/'));
|
|
}
|
|
|
|
/**
|
|
* Geheimen JWT-Schlüssel des Moduls holen.
|
|
*
|
|
* @return string Schlüssel
|
|
*/
|
|
function netdiag_jwt_secret()
|
|
{
|
|
$secret = getDolGlobalString('NETDIAG_API_JWT_SECRET');
|
|
if (empty($secret)) {
|
|
// Fallback: Instanz-eindeutiger Wert (sollte nach Modulaktivierung nicht eintreten)
|
|
$secret = md5(DOL_DOCUMENT_ROOT.getDolGlobalString('MAIN_INFO_SOCIETE_NOM'));
|
|
}
|
|
return $secret;
|
|
}
|
|
|
|
/**
|
|
* JWT (HS256) erzeugen.
|
|
*
|
|
* @param array<string,mixed> $payload Nutzdaten (sub, name, exp werden ergänzt)
|
|
* @param int $ttl Gültigkeit in Sekunden
|
|
* @return string Signiertes Token
|
|
*/
|
|
function netdiag_jwt_encode($payload, $ttl)
|
|
{
|
|
$header = array('alg' => 'HS256', 'typ' => 'JWT');
|
|
$now = dol_now();
|
|
$payload['iat'] = $now;
|
|
$payload['exp'] = $now + $ttl;
|
|
|
|
$seg = array();
|
|
$seg[] = netdiag_base64url_encode(json_encode($header));
|
|
$seg[] = netdiag_base64url_encode(json_encode($payload));
|
|
$signinginput = implode('.', $seg);
|
|
$signature = hash_hmac('sha256', $signinginput, netdiag_jwt_secret(), true);
|
|
$seg[] = netdiag_base64url_encode($signature);
|
|
|
|
return implode('.', $seg);
|
|
}
|
|
|
|
/**
|
|
* JWT prüfen und Nutzdaten zurückgeben.
|
|
*
|
|
* @param string $token JWT
|
|
* @return array<string,mixed>|null Nutzdaten oder null bei ungültig/abgelaufen
|
|
*/
|
|
function netdiag_jwt_decode($token)
|
|
{
|
|
$parts = explode('.', (string) $token);
|
|
if (count($parts) !== 3) {
|
|
return null;
|
|
}
|
|
list($h, $p, $s) = $parts;
|
|
$expected = hash_hmac('sha256', $h.'.'.$p, netdiag_jwt_secret(), true);
|
|
$given = netdiag_base64url_decode($s);
|
|
if (!hash_equals($expected, $given)) {
|
|
return null;
|
|
}
|
|
$payload = json_decode(netdiag_base64url_decode($p), true);
|
|
if (!is_array($payload)) {
|
|
return null;
|
|
}
|
|
if (empty($payload['exp']) || $payload['exp'] < dol_now()) {
|
|
return null;
|
|
}
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* Token aus Request lesen (Authorization-Header oder ?jwt=).
|
|
*
|
|
* @return string Token oder leerer String
|
|
*/
|
|
function netdiag_api_read_token()
|
|
{
|
|
$auth = '';
|
|
if (!empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
|
$auth = $_SERVER['HTTP_AUTHORIZATION'];
|
|
} elseif (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
|
|
$auth = $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
|
|
} elseif (function_exists('apache_request_headers')) {
|
|
$headers = apache_request_headers();
|
|
if (!empty($headers['Authorization'])) {
|
|
$auth = $headers['Authorization'];
|
|
}
|
|
}
|
|
if (stripos($auth, 'Bearer ') === 0) {
|
|
return trim(substr($auth, 7));
|
|
}
|
|
if (!empty($_GET['jwt'])) {
|
|
return (string) $_GET['jwt'];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Aktuellen Request authentifizieren. Bricht mit 401 ab, wenn ungültig.
|
|
*
|
|
* Reihenfolge: zuerst das zentrale awlauth-Token (Signatur + serverseitig
|
|
* widerrufbare Sitzung), danach als Übergangslösung das alte modul-eigene JWT.
|
|
* Der Fallback hält bereits ausgestellte App-Tokens bis zu ihrem Ablauf gültig,
|
|
* damit die Umstellung niemanden mitten im Einsatz aussperrt. Er entfällt,
|
|
* sobald alle Geräte einmal neu angemeldet sind (siehe ROADMAP_UMSETZUNG L5).
|
|
*
|
|
* @param DoliDB $db Datenbank-Handler
|
|
* @return User Geladenes Benutzer-Objekt
|
|
*/
|
|
function netdiag_api_authenticate($db)
|
|
{
|
|
$token = netdiag_api_read_token();
|
|
if (empty($token)) {
|
|
netdiag_api_error('Kein Token übermittelt', 401);
|
|
}
|
|
|
|
$user = null;
|
|
|
|
// 1. Zentrales Auth-Modul
|
|
if (netdiag_awlauth_available()) {
|
|
$user = awlauth_verify_token($token);
|
|
}
|
|
|
|
// 2. Übergang: altes netdiag-JWT
|
|
if ($user === null) {
|
|
$payload = netdiag_jwt_decode($token);
|
|
if ($payload !== null && !empty($payload['sub'])) {
|
|
require_once DOL_DOCUMENT_ROOT.'/user/class/user.class.php';
|
|
$legacy = new User($db);
|
|
if ($legacy->fetch((int) $payload['sub']) > 0 && !empty($legacy->id)) {
|
|
$legacy->loadRights();
|
|
$user = $legacy;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($user === null || empty($user->id)) {
|
|
netdiag_api_error('Token ungültig oder abgelaufen', 401);
|
|
}
|
|
if (isset($user->statut) && $user->statut == 0) {
|
|
netdiag_api_error('Benutzer deaktiviert', 403);
|
|
}
|
|
|
|
if (!$user->hasRight('netdiag', 'protocol', 'read')) {
|
|
netdiag_api_error('Keine Berechtigung für NetDiag', 403);
|
|
}
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* JSON-Body eines POST-Requests einlesen.
|
|
*
|
|
* @return array<string,mixed> Dekodierte Daten (leer bei Fehler)
|
|
*/
|
|
function netdiag_api_read_body()
|
|
{
|
|
$raw = file_get_contents('php://input');
|
|
if (empty($raw)) {
|
|
return array();
|
|
}
|
|
$data = json_decode($raw, true);
|
|
return is_array($data) ? $data : array();
|
|
}
|
|
|
|
/**
|
|
* Zeitstempel der App in einen Unix-Zeitstempel (Sekunden) umrechnen.
|
|
*
|
|
* Die App (JavaScript) liefert Zeitstempel in Millisekunden (Date.now()).
|
|
* Dolibarr/`idate()` erwartet Sekunden — sonst: "Bad value ... for date".
|
|
*
|
|
* @param mixed $value Zeitstempel aus dem Request (ms, s oder leer)
|
|
* @return int Unix-Zeitstempel in Sekunden
|
|
*/
|
|
function netdiag_api_timestamp($value)
|
|
{
|
|
$v = (int) $value;
|
|
if ($v <= 0) {
|
|
return dol_now();
|
|
}
|
|
// 13-stellig (> ~Jahr 5138 in Sekunden) = Millisekunden -> auf Sekunden
|
|
if ($v > 100000000000) {
|
|
$v = (int) ($v / 1000);
|
|
}
|
|
return $v;
|
|
}
|
|
|
|
/**
|
|
* Liste von Diagnose-Protokollen als Array zurückgeben (für API-Antworten).
|
|
*
|
|
* @param DoliDB $db Datenbank-Handler
|
|
* @param string $filtersql Zusätzlicher SQL-Filter, beginnend mit ' AND ...'
|
|
* @return array<int,array<string,mixed>> Liste der Protokolle
|
|
*/
|
|
function netdiag_api_protocol_list($db, $filtersql = '')
|
|
{
|
|
$prefix = $db->prefix();
|
|
$sql = "SELECT p.rowid, p.ref, p.label, p.client_uuid, p.fk_soc, p.fk_commande,";
|
|
$sql .= " p.date_diag, p.standort, p.subnet, p.status,";
|
|
$sql .= " (SELECT COUNT(*) FROM ".$prefix."netdiag_device d WHERE d.fk_protocol = p.rowid) as devcount,";
|
|
$sql .= " (SELECT COUNT(*) FROM ".$prefix."netdiag_measurement m WHERE m.fk_protocol = p.rowid) as meascount";
|
|
$sql .= " FROM ".$prefix."netdiag_protocol as p";
|
|
$sql .= " WHERE p.entity IN (".getEntity('netdiagprotocol').")";
|
|
$sql .= $filtersql;
|
|
$sql .= " ORDER BY p.date_diag DESC, p.rowid DESC";
|
|
|
|
$list = array();
|
|
$resql = $db->query($sql);
|
|
if ($resql) {
|
|
while ($obj = $db->fetch_object($resql)) {
|
|
$list[] = array(
|
|
'id' => (int) $obj->rowid,
|
|
'ref' => $obj->ref,
|
|
'label' => $obj->label,
|
|
'clientUuid' => $obj->client_uuid,
|
|
'socId' => $obj->fk_soc ? (int) $obj->fk_soc : null,
|
|
'orderId' => $obj->fk_commande ? (int) $obj->fk_commande : null,
|
|
'dateDiag' => $db->jdate($obj->date_diag),
|
|
'location' => $obj->standort,
|
|
'subnet' => $obj->subnet,
|
|
'status' => (int) $obj->status,
|
|
'deviceCount' => (int) $obj->devcount,
|
|
'measureCount' => (int) $obj->meascount,
|
|
);
|
|
}
|
|
}
|
|
return $list;
|
|
}
|