feat: PWA-Token-Auth statt Dolibarr-REST-API-Key [deploy]
All checks were successful
Deploy Eplan / deploy (push) Successful in 9s

Das ElektroPlan-Backend spricht nicht mehr die Dolibarr-REST-API an
(API-Key-Flow kaputt, REST-Modul nicht zuverlässig), sondern einen
eigenen NOLOGIN-Endpoint im Eplan-Modul.

- ajax/pwa_api.php: NOLOGIN-Endpoint, Auth via X-Eplan-Token-Header
  gegen EPLAN_PWA_SECRET (hash_equals, timing-safe)
  Actions: ping, auftraege_listen, auftrag_details, kunden_suchen,
  dokument_upload (multipart POST)
- lib/eplan_token.lib.php: Lazy-init Token, Rotation, Verifikation
- admin/setup.php: Token-Feld (readonly, anklickbar zum Markieren),
  Endpoint-URL zum Kopieren, Button "Neuen Token erzeugen"
- de/en-Sprachdateien: EplanApiToken* Keys

Muster aus KB #354 (Bericht/Baustelle-PWA).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-04-19 11:02:27 +02:00
parent 4f09c6e55c
commit 593c93f377
5 changed files with 292 additions and 0 deletions

View file

@ -15,6 +15,7 @@ if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.lib.php';
dol_include_once('/eplan/lib/eplan.lib.php'); dol_include_once('/eplan/lib/eplan.lib.php');
dol_include_once('/eplan/lib/eplan_token.lib.php');
$langs->loadLangs(array('admin', 'eplan@eplan')); $langs->loadLangs(array('admin', 'eplan@eplan'));
@ -33,6 +34,16 @@ if ($action == 'save' && $user->admin) {
exit; exit;
} }
if ($action == 'token_rotieren' && $user->admin) {
eplanTokenRotieren();
setEventMessages($langs->trans("EplanTokenRotiert"), null, 'mesgs');
header("Location: ".$_SERVER['PHP_SELF']);
exit;
}
// Token beim ersten Aufruf automatisch erzeugen
$pwa_token = eplanTokenHolen();
$page_name = "EplanSetup"; $page_name = "EplanSetup";
llxHeader('', $langs->trans($page_name), '', '', 0, 0, '', '', '', 'mod-eplan page-admin-setup'); llxHeader('', $langs->trans($page_name), '', '', 0, 0, '', '', '', 'mod-eplan page-admin-setup');
@ -83,6 +94,29 @@ print '<input type="submit" class="button" value="'.$langs->trans("Save").'">';
print '</div>'; print '</div>';
print '</form>'; print '</form>';
// --- PWA-Token ---
print '<br><div class="fichecenter"><div class="underbanner clearboth"></div>';
print '<table class="border centpercent tableforfield">';
print '<tr><td class="titlefield" colspan="2"><strong>'.$langs->trans("EplanApiToken").'</strong></td></tr>';
print '<tr class="oddeven"><td colspan="2" class="opacitymedium small">';
print $langs->trans("EplanApiTokenDesc");
print '</td></tr>';
print '<tr class="oddeven"><td class="titlefield">'.$langs->trans("EplanApiTokenValue").'</td><td>';
print '<input type="text" id="eplan-token-field" readonly value="'.dol_escape_htmltag($pwa_token).'" ';
print 'style="width: 100%; max-width: 600px; font-family: monospace; font-size: 12px;" class="flat" onclick="this.select()">';
print '<br><span class="opacitymedium small">'.$langs->trans("EplanApiTokenHint").'</span>';
print '</td></tr>';
print '<tr class="oddeven"><td class="titlefield">'.$langs->trans("EplanApiEndpoint").'</td><td>';
print '<code>'.dol_escape_htmltag(DOL_MAIN_URL_ROOT.'/custom/eplan/ajax/pwa_api.php').'</code>';
print '</td></tr>';
print '</table>';
print '<div class="center" style="margin-top: 12px;">';
print '<a href="'.$_SERVER['PHP_SELF'].'?action=token_rotieren&token='.newToken().'" ';
print 'class="button" onclick="return confirm(\''.dol_escape_js($langs->trans("EplanTokenRotierenConfirm")).'\');">';
print '<i class="fas fa-sync"></i> '.$langs->trans("EplanTokenRotieren").'</a>';
print '</div>';
print '</div>';
print '<br><div class="fichecenter"><div class="underbanner clearboth"></div>'; print '<br><div class="fichecenter"><div class="underbanner clearboth"></div>';
print '<table class="border centpercent tableforfield">'; print '<table class="border centpercent tableforfield">';
print '<tr><td class="titlefield" colspan="2"><strong>'.$langs->trans("EplanAbout").'</strong></td></tr>'; print '<tr><td class="titlefield" colspan="2"><strong>'.$langs->trans("EplanAbout").'</strong></td></tr>';

175
ajax/pwa_api.php Normal file
View file

@ -0,0 +1,175 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* GPL v3+
*
* NOLOGIN-Endpoint für die ElektroPlan-PWA / FastAPI-Backend.
* Auth: Header "X-Eplan-Token: <secret>" gegen Const EPLAN_PWA_SECRET (hash_equals).
*
* Actions (?action=...):
* - ping {aktiv: true, version: "1.1.0"}
* - auftraege_listen Liste offener Aufträge (Commande)
* - auftrag_details&id= Einzelner Auftrag mit Positionen
* - kunden_suchen&such= Third-Party-Suche
* - dokument_upload (POST) Plan-PDF an Auftrag/Kunde anhängen
*/
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('NOLOGIN')) define('NOLOGIN', '1');
$res = 0;
if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php";
$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))."/main.inc.php")) $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php";
if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) $res = @include dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php";
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) { http_response_code(500); echo json_encode(['error' => 'main.inc.php not found']); exit; }
dol_include_once('/eplan/lib/eplan_token.lib.php');
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
// --- Auth ---
$token = eplanTokenAusRequest();
if (!eplanTokenPruefen($token)) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized', 'detail' => 'Ungültiger oder fehlender Token']);
exit;
}
if (!isModEnabled('eplan')) {
http_response_code(503);
echo json_encode(['error' => 'Eplan-Modul deaktiviert']);
exit;
}
$action = GETPOST('action', 'aZ09');
try {
switch ($action) {
case 'ping':
echo json_encode([
'aktiv' => true,
'modul' => 'eplan',
'version' => '1.2.0',
'dolibarr' => DOL_VERSION,
]);
break;
case 'auftraege_listen':
$such = GETPOST('such', 'alphanohtml');
$limit = max(1, min(200, (int) GETPOST('limit', 'int') ?: 50));
$sql = "SELECT c.rowid as id, c.ref, c.ref_client, c.date_commande, c.total_ttc, c.fk_statut,
t.nom as kunde
FROM ".MAIN_DB_PREFIX."commande c
LEFT JOIN ".MAIN_DB_PREFIX."societe t ON t.rowid = c.fk_soc
WHERE c.entity IN (".getEntity('commande').")";
if (!empty($such)) {
$such_esc = $db->escape($such);
$sql .= " AND (c.ref LIKE '%".$such_esc."%'
OR c.ref_client LIKE '%".$such_esc."%'
OR t.nom LIKE '%".$such_esc."%')";
}
$sql .= " ORDER BY c.date_commande DESC LIMIT ".((int) $limit);
$resql = $db->query($sql);
$liste = [];
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$liste[] = [
'id' => (int) $obj->id,
'ref' => $obj->ref,
'ref_client' => $obj->ref_client,
'kunde' => $obj->kunde,
'status' => (int) $obj->fk_statut,
'datum' => $obj->date_commande,
'total_ttc' => $obj->total_ttc !== null ? (float) $obj->total_ttc : null,
];
}
$db->free($resql);
}
echo json_encode($liste);
break;
case 'auftrag_details':
$id = (int) GETPOST('id', 'int');
if ($id <= 0) { http_response_code(400); echo json_encode(['error' => 'id fehlt']); break; }
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
$c = new Commande($db);
if ($c->fetch($id) <= 0) { http_response_code(404); echo json_encode(['error' => 'nicht gefunden']); break; }
$c->fetch_thirdparty();
echo json_encode([
'id' => (int) $c->id,
'ref' => $c->ref,
'ref_client' => $c->ref_client,
'kunde' => $c->thirdparty ? $c->thirdparty->name : null,
'kunde_id' => $c->thirdparty ? (int) $c->thirdparty->id : null,
'datum' => $c->date_commande,
'status' => (int) $c->statut,
'total_ttc' => (float) $c->total_ttc,
]);
break;
case 'kunden_suchen':
$such = GETPOST('such', 'alphanohtml');
if (empty($such) || strlen($such) < 2) { echo json_encode([]); break; }
$such_esc = $db->escape($such);
$sql = "SELECT rowid as id, nom, code_client FROM ".MAIN_DB_PREFIX."societe
WHERE entity IN (".getEntity('societe').")
AND (nom LIKE '%".$such_esc."%' OR code_client LIKE '%".$such_esc."%')
ORDER BY nom LIMIT 30";
$resql = $db->query($sql);
$liste = [];
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$liste[] = ['id' => (int) $obj->id, 'name' => $obj->nom, 'code' => $obj->code_client];
}
$db->free($resql);
}
echo json_encode($liste);
break;
case 'dokument_upload':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405); echo json_encode(['error' => 'POST benötigt']); break;
}
$auftrag_id = (int) GETPOST('auftrag_id', 'int');
if ($auftrag_id <= 0) { http_response_code(400); echo json_encode(['error' => 'auftrag_id fehlt']); break; }
if (empty($_FILES['datei'])) { http_response_code(400); echo json_encode(['error' => 'Keine Datei']); break; }
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/files.lib.php';
$c = new Commande($db);
if ($c->fetch($auftrag_id) <= 0) { http_response_code(404); echo json_encode(['error' => 'Auftrag nicht gefunden']); break; }
$ref_sanitiert = dol_sanitizeFileName($c->ref);
$ziel_dir = $conf->commande->dir_output.'/'.$ref_sanitiert;
dol_mkdir($ziel_dir);
$dateiname = dol_sanitizeFileName($_FILES['datei']['name']);
$ziel = $ziel_dir.'/'.$dateiname;
$erfolg = dol_move_uploaded_file($_FILES['datei']['tmp_name'], $ziel, 1, 0, 0);
if ($erfolg > 0) {
echo json_encode(['erfolg' => true, 'pfad' => $ref_sanitiert.'/'.$dateiname]);
} else {
http_response_code(500);
echo json_encode(['error' => 'Upload fehlgeschlagen', 'code' => $erfolg]);
}
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Unbekannte Action', 'action' => $action]);
}
} catch (Throwable $e) {
dol_syslog('eplan/pwa_api: '.$e->getMessage(), LOG_ERR);
http_response_code(500);
echo json_encode(['error' => 'Server-Fehler', 'detail' => $e->getMessage()]);
}
$db->close();

View file

@ -8,6 +8,16 @@ EplanRightRead = Aufmaß einsehen
EplanRightWrite = Aufmaß bearbeiten EplanRightWrite = Aufmaß bearbeiten
EplanRightAdmin = Eplan-Einstellungen verwalten EplanRightAdmin = Eplan-Einstellungen verwalten
# PWA-Token
EplanApiToken = PWA-Anbindung (Token)
EplanApiTokenDesc = Der Token authentifiziert die ElektroPlan-PWA gegen diesen Dolibarr. Trage URL + Token in den PWA-Einstellungen (⚙) ein.
EplanApiTokenValue = Token
EplanApiTokenHint = Einmal anklicken zum Markieren, dann Strg+C zum Kopieren.
EplanApiEndpoint = API-Endpoint
EplanTokenRotieren = Neuen Token erzeugen
EplanTokenRotierenConfirm = Neuen Token erzeugen? Der alte wird sofort ungültig und die PWA muss neu konfiguriert werden.
EplanTokenRotiert = Neuer Token erzeugt. PWA mit dem neuen Token aktualisieren.
EplanSetup = Eplan Einstellungen EplanSetup = Eplan Einstellungen
EplanPwaUrl = PWA-URL EplanPwaUrl = PWA-URL
EplanPwaUrlDesc = Vollständige URL zur ElektroPlan PWA (z.B. https://elektroplan.example.de) EplanPwaUrlDesc = Vollständige URL zur ElektroPlan PWA (z.B. https://elektroplan.example.de)

View file

@ -8,6 +8,16 @@ EplanRightRead = View measurement
EplanRightWrite = Edit measurement EplanRightWrite = Edit measurement
EplanRightAdmin = Manage Eplan settings EplanRightAdmin = Manage Eplan settings
# PWA token
EplanApiToken = PWA connection (token)
EplanApiTokenDesc = The token authenticates the ElektroPlan PWA against this Dolibarr. Enter URL + token in the PWA settings (gear icon).
EplanApiTokenValue = Token
EplanApiTokenHint = Click once to select, then Ctrl+C to copy.
EplanApiEndpoint = API endpoint
EplanTokenRotieren = Generate new token
EplanTokenRotierenConfirm = Generate new token? The old one is immediately invalid and the PWA must be reconfigured.
EplanTokenRotiert = New token generated. Update the PWA with the new token.
EplanSetup = Eplan Settings EplanSetup = Eplan Settings
EplanPwaUrl = PWA URL EplanPwaUrl = PWA URL
EplanPwaUrlDesc = Full URL to the ElektroPlan PWA (e.g. https://elektroplan.example.com) EplanPwaUrlDesc = Full URL to the ElektroPlan PWA (e.g. https://elektroplan.example.com)

63
lib/eplan_token.lib.php Normal file
View file

@ -0,0 +1,63 @@
<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
* GPL v3+
*
* Token-Handling für die Eplan-PWA-Anbindung.
* Kein JWT ein simpler Shared-Secret-Token reicht, weil nur der
* ElektroPlan-Backend-Container den Endpoint anspricht (Server-zu-Server
* über HTTPS). Kein User-Kontext, keine Rotation per Request.
*/
/**
* Lazy-init: Token aus llx_const laden, falls leer frisch erzeugen.
* @return string 64-Zeichen hex Token
*/
function eplanTokenHolen()
{
global $db, $conf;
$token = getDolGlobalString('EPLAN_PWA_SECRET');
if (!empty($token)) {
return $token;
}
$token = bin2hex(random_bytes(32));
dolibarr_set_const($db, 'EPLAN_PWA_SECRET', $token, 'chaine', 0, 'PWA-Token', $conf->entity);
return $token;
}
/**
* Neuen Token erzeugen (überschreibt den alten).
* @return string neuer Token
*/
function eplanTokenRotieren()
{
global $db, $conf;
$token = bin2hex(random_bytes(32));
dolibarr_set_const($db, 'EPLAN_PWA_SECRET', $token, 'chaine', 0, 'PWA-Token', $conf->entity);
return $token;
}
/**
* Vergleicht einen übergebenen Token mit dem gespeicherten (timing-safe).
* @param string $eingang Token aus dem Request
* @return bool
*/
function eplanTokenPruefen($eingang)
{
if (empty($eingang)) return false;
$gespeichert = getDolGlobalString('EPLAN_PWA_SECRET');
if (empty($gespeichert)) return false;
return hash_equals($gespeichert, (string) $eingang);
}
/**
* Token aus Request holen: bevorzugt Header X-Eplan-Token, sonst Query-Param `token`.
* @return string|null
*/
function eplanTokenAusRequest()
{
$header = $_SERVER['HTTP_X_EPLAN_TOKEN'] ?? '';
if (!empty($header)) return $header;
return $_GET['token'] ?? $_POST['token'] ?? null;
}