feat: PWA-Token-Auth statt Dolibarr-REST-API-Key [deploy]
All checks were successful
Deploy Eplan / deploy (push) Successful in 9s
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:
parent
4f09c6e55c
commit
593c93f377
5 changed files with 292 additions and 0 deletions
|
|
@ -15,6 +15,7 @@ if (!$res) die("Include of main fails");
|
|||
|
||||
require_once DOL_DOCUMENT_ROOT.'/core/lib/admin.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'));
|
||||
|
||||
|
|
@ -33,6 +34,16 @@ if ($action == 'save' && $user->admin) {
|
|||
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";
|
||||
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 '</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 '<table class="border centpercent tableforfield">';
|
||||
print '<tr><td class="titlefield" colspan="2"><strong>'.$langs->trans("EplanAbout").'</strong></td></tr>';
|
||||
|
|
|
|||
175
ajax/pwa_api.php
Normal file
175
ajax/pwa_api.php
Normal 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();
|
||||
|
|
@ -8,6 +8,16 @@ EplanRightRead = Aufmaß einsehen
|
|||
EplanRightWrite = Aufmaß bearbeiten
|
||||
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
|
||||
EplanPwaUrl = PWA-URL
|
||||
EplanPwaUrlDesc = Vollständige URL zur ElektroPlan PWA (z.B. https://elektroplan.example.de)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,16 @@ EplanRightRead = View measurement
|
|||
EplanRightWrite = Edit measurement
|
||||
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
|
||||
EplanPwaUrl = PWA URL
|
||||
EplanPwaUrlDesc = Full URL to the ElektroPlan PWA (e.g. https://elektroplan.example.com)
|
||||
|
|
|
|||
63
lib/eplan_token.lib.php
Normal file
63
lib/eplan_token.lib.php
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue