feat(web): HTTP-Basic-Auth fuer alle Routen; webUser/webPassword in NVS; ElegantOTA.setAuth()

This commit is contained in:
MaPaLo76 2026-02-23 21:32:47 +01:00
parent 18a1f67f64
commit 07c99dc7d8
8 changed files with 113 additions and 15 deletions

View File

@ -157,9 +157,9 @@
- [x] **6.3** Publish-Methode `publishSession(sessionSec, summeSession, gratisSec)`:
- Topic: `lasercutter/session`
- JSON-Felder (aktualisiert):
- `session_minuten` (int, ceiling: 62 s = 2 min)
- `session_minutes` (int, ceiling: 62 s = 2 min)
- `session_seconds` (int, Rohwert Netto-Sekunden)
- `gratiszeit_s` (int)
- `freetime_s` (int)
- `ip` (string)
- Kein `total_min`, kein `session_start_time` (NTP später)
- Wird von LaserTracker-Logik in main aufgerufen
@ -262,6 +262,15 @@
- Reset während aktiver Session ✅
- Neustart nach akkumulierter Zeit → Zeit korrekt aus NVS geladen ✅
- [x] **9.6** Webinterface mit HTTP-Basic-Auth abgesichert
- `webUser` + `webPassword` in `Settings`-Struct + NVS-Keys in `config.h`
- `saveWebCredentials()` in `SettingsManager`
- `WebServerManager::requireAuth()` prüft Auth, sendet 401 wenn nicht autorisiert
- Standard: `webUser = "admin"`, `webPassword = ""` (leer = kein Schutz, rückwärtskompatibel)
- Alle Routen (`/`, `/config`, `/reset`) + ElegantOTA (`ElegantOTA.setAuth()`) geschützt
- Passwort änderbar über `/config`-Formular (neuer Abschnitt "Web-Zugang")
- Forward-Declaration `AsyncWebServerRequest` in `web_server.h` ergänzt (Fix Compile-Fehler)
---
## Phase 10 Dokumentation & Abschluss
@ -306,9 +315,9 @@ Phase 1 (Setup)
**`lasercutter/session`**
```json
{
"session_minuten": 2,
"session_minutes": 2,
"session_seconds": 125,
"gratiszeit_s": 20,
"freetime_s": 20,
"ip": "192.168.1.100"
}
```

View File

@ -218,10 +218,10 @@ Der ESP32 verbindet sich mit einem MQTT-Broker (konfigurierbar über Webinterfac
### JSON-Format `lasercutter/session`
```json
{
"session_minuten": 2,
"session_minutes": 2,
"session_seconds": 95,
"session_start_time": "2026-02-23T12:34:56Z",
"gratiszeit_s": 20,
"freetime_s": 20,
"ip": "192.168.2.62"
}
```
@ -261,10 +261,12 @@ Das Webinterface ist über die IP-Adresse des ESP32 im Browser erreichbar.
| Seite | URL | Funktion |
|------------------|-------------|--------------------------------------------------------------|
| Status | `/` | Aktuelle Laserzeit, letzter Session-Wert, Systemstatus |
| Konfiguration | `/config` | MQTT-Broker (IP, Port, User, Passwort), Gratiszeit, Polarität |
| Konfiguration | `/config` | MQTT-Broker (IP, Port, User, Passwort), Gratiszeit, Polarität, Web-Passwort |
| Reset | `/reset` | Setzt akkumulierte Laserzeit zurück |
| OTA Update | `/update` | Firmware-Update über Browser |
> Alle Seiten sind per **HTTP Basic Auth** geschützt, wenn in der Konfiguration ein Web-Passwort gesetzt ist.
---
## Konfiguration & Persistenz
@ -280,6 +282,8 @@ Alle Einstellungen werden im **NVS (Non-Volatile Storage)** des ESP32 gespeicher
| Gratiszeit | `20` | Sekunden, 0120 |
| Signal-Polarität | `LOW_ACTIVE` | `LOW_ACTIVE` oder `HIGH_ACTIVE` |
| Akkumulierte Zeit | `0.0` | Gespeicherte Laserzeit in Minuten (NVS) |
| Web-Benutzername | `admin` | HTTP-Basic-Auth Benutzername |
| Web-Passwort | *(leer)* | Leer = kein Passwortschutz; setzen über `/config` |
---

View File

@ -84,11 +84,17 @@
#define NVS_KEY_GRATIS_SEC "gratis_sec"
#define NVS_KEY_SIGNAL_POL "signal_pol" // 0 = LOW_ACTIVE, 1 = HIGH_ACTIVE
#define NVS_KEY_TOTAL_MINUTES "total_min"
#define NVS_KEY_WEB_USER "web_user"
#define NVS_KEY_WEB_PASSWORD "web_pass"
// Signal-Polarität Werte
#define SIGNAL_POL_LOW_ACTIVE 0 // LOW = Laser aktiv (INPUT_PULLUP Default)
#define SIGNAL_POL_HIGH_ACTIVE 1 // HIGH = Laser aktiv
// Webinterface-Authentifizierung
#define DEFAULT_WEB_USER "admin"
#define DEFAULT_WEB_PASSWORD "" // leer = kein Passwortschutz
// -----------------------------------------------------------------------------
// Serielles Debugging
// -----------------------------------------------------------------------------

View File

@ -26,6 +26,8 @@ struct Settings {
uint8_t gratisSeconds; // Gratiszeit in Sekunden (0120)
uint8_t signalPolarity; // SIGNAL_POL_LOW_ACTIVE oder _HIGH_ACTIVE
float totalMinutes; // akkumulierte Laserzeit in Minuten
char webUser[32]; // Webinterface Benutzername (Standard: "admin")
char webPassword[32]; // Webinterface Passwort (leer = kein Schutz)
};
// -----------------------------------------------------------------------------
@ -54,6 +56,7 @@ public:
void saveSignalPolarity(uint8_t polarity);
void saveMqttConfig(const char* broker, uint16_t port,
const char* user, const char* password);
void saveWebCredentials(const char* user, const char* password);
// ---- Zugriff auf Werte --------------------------------------------------
const Settings& get() const { return _s; }

View File

@ -21,9 +21,10 @@
#include <Arduino.h>
// Forward-Declaration: ESPAsyncWebServer.h nur in web_server.cpp einbinden
// Forward-Declarations: ESPAsyncWebServer.h nur in web_server.cpp einbinden
// (verhindert HTTP_GET/POST-Enum-Konflikt mit WiFiManager in main.cpp)
class AsyncWebServer;
class AsyncWebServerRequest;
class WebServerManager {
public:
@ -37,6 +38,9 @@ private:
void registerRoutes();
// HTTP-Basic-Auth pruefen; gibt false zurueck und sendet 401 wenn nicht autorisiert
bool requireAuth(AsyncWebServerRequest* request);
// HTML-Seiten als Strings aufbauen
String buildStatusPage();
String buildConfigPage();

View File

@ -21,6 +21,8 @@ void SettingsManager::applyDefaults() {
_s.gratisSeconds = DEFAULT_GRATIS_SECONDS;
_s.signalPolarity = SIGNAL_POL_LOW_ACTIVE;
_s.totalMinutes = 0.0f;
strlcpy(_s.webUser, DEFAULT_WEB_USER, sizeof(_s.webUser));
strlcpy(_s.webPassword, DEFAULT_WEB_PASSWORD, sizeof(_s.webPassword));
}
// ---------------------------------------------------------------------------
@ -64,6 +66,12 @@ void SettingsManager::load() {
_s.gratisSeconds = _prefs.getUChar(NVS_KEY_GRATIS_SEC, DEFAULT_GRATIS_SECONDS);
_s.signalPolarity = _prefs.getUChar(NVS_KEY_SIGNAL_POL, SIGNAL_POL_LOW_ACTIVE);
_s.totalMinutes = _prefs.getFloat(NVS_KEY_TOTAL_MINUTES, 0.0f);
strlcpy(_s.webUser,
_prefs.getString(NVS_KEY_WEB_USER, DEFAULT_WEB_USER).c_str(),
sizeof(_s.webUser));
strlcpy(_s.webPassword,
_prefs.getString(NVS_KEY_WEB_PASSWORD, DEFAULT_WEB_PASSWORD).c_str(),
sizeof(_s.webPassword));
validateAndClamp();
}
@ -77,6 +85,8 @@ void SettingsManager::save() {
_prefs.putUChar (NVS_KEY_GRATIS_SEC, _s.gratisSeconds);
_prefs.putUChar (NVS_KEY_SIGNAL_POL, _s.signalPolarity);
_prefs.putFloat (NVS_KEY_TOTAL_MINUTES, _s.totalMinutes);
_prefs.putString(NVS_KEY_WEB_USER, _s.webUser);
_prefs.putString(NVS_KEY_WEB_PASSWORD, _s.webPassword);
}
// ---------------------------------------------------------------------------
@ -120,6 +130,14 @@ void SettingsManager::saveMqttConfig(const char* broker, uint16_t port,
_prefs.putString(NVS_KEY_MQTT_PASSWORD, password);
}
// ---------------------------------------------------------------------------
void SettingsManager::saveWebCredentials(const char* user, const char* password) {
strlcpy(_s.webUser, user, sizeof(_s.webUser));
strlcpy(_s.webPassword, password, sizeof(_s.webPassword));
_prefs.putString(NVS_KEY_WEB_USER, user);
_prefs.putString(NVS_KEY_WEB_PASSWORD, password);
}
// ---------------------------------------------------------------------------
void SettingsManager::printToSerial() const {
LOG_I("NVS", "=== Geladene Einstellungen ===");
@ -131,5 +149,7 @@ void SettingsManager::printToSerial() const {
LOG_I("NVS", " Signalpolarit : %s",
_s.signalPolarity == SIGNAL_POL_LOW_ACTIVE ? "LOW_ACTIVE" : "HIGH_ACTIVE");
LOG_I("NVS", " Laserzeit ges.: %.2f min", _s.totalMinutes);
LOG_I("NVS", " Web User : %s", _s.webUser);
LOG_I("NVS", " Web Passwort : %s", _s.webPassword[0] ? "***" : "(kein Schutz)");
LOG_I("NVS", "==============================");
}

View File

@ -32,11 +32,30 @@ WebServerManager::WebServerManager() : _server(new AsyncWebServer(80)) {}
// -----------------------------------------------------------------------------
void WebServerManager::begin() {
registerRoutes();
// ElegantOTA-Auth: nur wenn Passwort gesetzt
const Settings& s = settings.get();
if (s.webPassword[0] != '\0') {
ElegantOTA.setAuth(s.webUser, s.webPassword);
}
ElegantOTA.begin(_server);
_server->begin();
Serial.println(F("[WebServer] gestartet auf Port 80"));
Serial.print(F("[WebServer] URL: http://"));
Serial.println(WiFi.localIP());
LOG_I("WEB", "Auth: %s", s.webPassword[0] ? "aktiv" : "deaktiviert (kein Passwort)");
}
// -----------------------------------------------------------------------------
// requireAuth() HTTP Basic Auth pruefen (nur wenn Passwort konfiguriert)
// -----------------------------------------------------------------------------
bool WebServerManager::requireAuth(AsyncWebServerRequest* request) {
const Settings& s = settings.get();
if (s.webPassword[0] == '\0') return true; // kein Passwort = kein Schutz
if (!request->authenticate(s.webUser, s.webPassword)) {
request->requestAuthentication("LaserCutter Display");
return false;
}
return true;
}
// -----------------------------------------------------------------------------
@ -46,16 +65,19 @@ void WebServerManager::registerRoutes() {
// --- GET / Statusseite -------------------------------------------------
_server->on("/", HTTP_GET, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
request->send(200, "text/html; charset=utf-8", buildStatusPage());
});
// --- GET /config Konfigurationsformular --------------------------------
_server->on("/config", HTTP_GET, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
request->send(200, "text/html; charset=utf-8", buildConfigPage());
});
// --- POST /config Konfiguration speichern ------------------------------
_server->on("/config", HTTP_POST, [](AsyncWebServerRequest* request) {
_server->on("/config", HTTP_POST, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
const Settings& s = settings.get();
// MQTT
@ -87,6 +109,13 @@ void WebServerManager::registerRoutes() {
settings.saveSignalPolarity(pol);
}
// Webinterface-Passwort
if (request->hasParam("web_user", true) && request->hasParam("web_password", true)) {
const char* wUser = request->getParam("web_user", true)->value().c_str();
const char* wPass = request->getParam("web_password", true)->value().c_str();
settings.saveWebCredentials(wUser, wPass);
}
Serial.println(F("[WebServer] Konfiguration gespeichert Neustart empfohlen"));
// Weiterleitung mit Hinweis
@ -102,7 +131,8 @@ void WebServerManager::registerRoutes() {
});
// --- POST /reset Session zurücksetzen ---------------------------------
_server->on("/reset", HTTP_POST, [](AsyncWebServerRequest* request) {
_server->on("/reset", HTTP_POST, [this](AsyncWebServerRequest* request) {
if (!requireAuth(request)) return;
laserTracker.resetSession();
Serial.println(F("[WebServer] Session zurueckgesetzt"));
request->send(200, "text/html; charset=utf-8",
@ -157,10 +187,11 @@ String WebServerManager::buildStatusPage() {
"<body>"
"<h1>LaserCutter Display</h1>"
"<table>"
"<tr><td>Summe Session</td><td>%ALLSESSIONS% min</td></tr>"
"<tr><td>Maschinenlaufzeit (gesamt)</td><td>%TOTAL% min</td></tr>"
"<tr><td>Summe Laserzeit</td><td>%ALLSESSIONS% min</td></tr>"
"<tr><td>Aktuelle Laserzeit</td><td>%CURRENTSESSION% s</td></tr>"
"<tr><td>Letzte Laserzeit</td><td>%LASTSESSION% s</td></tr>"
"<tr><td>Laser</td><td>%LASER%</td></tr>"
"<tr><td>Maschinenlaufzeit (gesamt)</td><td>%TOTAL% min</td></tr>"
"<tr><td>IP-Adresse</td><td>%IP%</td></tr>"
"<tr><td>MQTT</td><td>%MQTT%</td></tr>"
"<tr><td>Broker</td><td>%BROKER%:%PORT%</td></tr>"
@ -179,6 +210,7 @@ String WebServerManager::buildStatusPage() {
);
html.replace("%ALLSESSIONS%", String(laserTracker.getAllSessionsSumMinutes()));
html.replace("%CURRENTSESSION%", String(laserTracker.getRunningSessionSeconds()));
html.replace("%TOTAL%", String(totalBuf));
html.replace("%LASTSESSION%", String(laserTracker.getLastSessionSeconds()));
html.replace("%LASER%", laserStatus);
@ -234,6 +266,13 @@ String WebServerManager::buildConfigPage() {
"<label class='radio-label'>"
"<input type='radio' name='polarity' value='1' %POL_HIGH%>"
"HIGH-aktiv</label>"
"<hr style='margin-top:1.5rem'>"
"<h2 style='font-size:1.1rem'>Web-Zugang</h2>"
"<label>Benutzername</label>"
"<input type='text' name='web_user' value='%WEB_USER%' maxlength='31'>"
"<label>Passwort <small style='color:#888'>(leer = kein Schutz)</small></label>"
"<input type='password' name='web_password' value='' maxlength='31' "
"placeholder='unverändert lassen wenn leer'>"
"<br>"
"<button class='btn btn-primary' type='submit'>Speichern &amp; Zur&uuml;ck</button>"
"</form><br>"
@ -248,6 +287,7 @@ String WebServerManager::buildConfigPage() {
html.replace("%GRATIS%", String(s.gratisSeconds));
html.replace("%POL_LOW%", s.signalPolarity == 0 ? "checked" : "");
html.replace("%POL_HIGH%", s.signalPolarity == 1 ? "checked" : "");
html.replace("%WEB_USER%", String(s.webUser));
return html;
}

View File

@ -297,7 +297,7 @@ pio device monitor -e test-mqtt
3. Serial: `SessionEnd -> publishSession`
4. MQTT Explorer: `lasercutter/session` erscheint mit JSON:
```json
{"session_minuten": 1, "session_seconds": 5, "gratiszeit_s": 20, "ip": "192.168.x.x"}
{"session_minutes": 1, "session_seconds": 5, "freetime_s": 20, "ip": "192.168.x.x"}
```
**C) Gratis-Session (< 20 s):**
@ -342,7 +342,7 @@ pio device monitor -e test-mqtt
...
[LaserTracker] SessionEnd: gesamt=35s netto=15s sessionNetSec=15 total=0.58min
[I][TEST-MQTT] SessionEnd -> publishSession (lastSession=15s)
[MQTT] publishSession: {"session_minuten":1,"session_seconds":15,"gratiszeit_s":20,"ip":"192.168.x.x"} -> OK
[MQTT] publishSession: {"session_minutes":1,"session_seconds":15,"freetime_s":20,"ip":"192.168.x.x"} -> OK
```
### Checkliste
@ -352,7 +352,7 @@ pio device monitor -e test-mqtt
| Verbindung zu mqtt.majufilo.eu | `[MQTT] Verbunden!` im Serial | [x] |
| Heartbeat beim Connect | `lasercutter/status` retained im MQTT Explorer | [x] |
| Heartbeat alle 60 s | Topic aktualisiert sich periodisch | [x] |
| Session-Publish nach Session-Ende | `lasercutter/session` mit `session_minuten`, `session_seconds` | [x] |
| Session-Publish nach Session-Ende | `lasercutter/session` mit `session_minutes`, `session_seconds` | [x] |
| Gratis-Session publiziert NICHT | kein Topic-Update bei lastSession=0 | [ ] |
| Reset via MQTT (`lasercutter/reset` = `{"reset":true}`) | `total_min` springt auf 0 | [x] |
| LWT `{"online":false}` nach Verbindungsabbruch | Nach 60 s im MQTT Explorer sichtbar | [x] |
@ -409,6 +409,14 @@ IP-Adresse aus dem Serial Monitor ablesen (erscheint nach WiFi-Connect):
2. ElegantOTA-Seite erscheint
3. (Optional) Test-Firmware hochladen und Neustart beobachten
**E) HTTP-Basic-Auth:**
1. In `/config` Abschnitt "Web-Zugang": Passwort setzen (z.B. `geheim`) und speichern
2. ESP32 neu starten
3. Browser: `http://<IP>/` → Browser-Login-Dialog erscheint
4. Benutzername `admin`, Passwort `geheim` eingeben → Zugang gewährt
5. Falsches Passwort eingeben → Browser zeigt 401 / erneuten Dialog
6. In `/config` Passwort wieder leeren → nach Neustart kein Schutz mehr
### Checkliste
| Pruefpunkt | Erwartung | Ergebnis |
@ -421,6 +429,10 @@ IP-Adresse aus dem Serial Monitor ablesen (erscheint nach WiFi-Connect):
| Polaritaet aendern + speichern | NVS-Wert nach Neustart korrekt | [ ] |
| Session-Reset per Browser | Session = 0 min, Gesamtzeit (NVS) unveraendert | [ ] |
| `/update` erreichbar | ElegantOTA-Seite erscheint | [ ] |
| Passwort setzen + Neustart | Browser-Login-Dialog erscheint | [ ] |
| Korrektes Passwort | Zugang gewaehrt | [ ] |
| Falsches Passwort | 401 / erneuter Dialog | [ ] |
| Passwort leeren + Neustart | Kein Login-Dialog | [ ] |
---