Skip to content

Commit e74e712

Browse files
fhirschmannclaude
andcommitted
feat: add simple password protection for the web interface
A single password (no username) guards all routes including the websocket via a server middleware. Logging in sets a session cookie for 90 days; the token is derived from password + stored salt, so changing the password invalidates all sessions. Includes a self-contained login page, a logout entry in the menu, brute-force lockout and a settings section in the wifi tab. Protection is skipped in accesspoint mode so initial setup always works. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5d9d591 commit e74e712

5 files changed

Lines changed: 248 additions & 0 deletions

File tree

‎html/locales/de.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"shutdown": "Ausschalten",
88
"log": "Log",
99
"logdownload": "Herunterladen",
10+
"logout": "Abmelden",
1011
"info": "Information",
1112
"delete": "Löschen",
1213
"cancel": "Abbrechen",
@@ -96,6 +97,14 @@
9697
"reverse": "Drehrichtung umkehren"
9798
}
9899
},
100+
"security": {
101+
"title": "Webinterface-Passwort",
102+
"password": {
103+
"title": "Passwort",
104+
"placeholder": "Leer lassen, um den Schutz zu deaktivieren",
105+
"desc": "Schützt das Webinterface mit einem Passwort. Nach dem Anmelden bleibt man 90 Tage eingeloggt. Im Hotspot-Modus ist der Schutz inaktiv."
106+
}
107+
},
99108
"wifi": {
100109
"title": "WLAN-Einstellungen",
101110
"networks": "Netzwerke",

‎html/locales/en.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"shutdown": "Shutdown",
88
"log": "Log",
99
"logdownload": "Download",
10+
"logout": "Logout",
1011
"info": "Information",
1112
"delete": "Delete",
1213
"cancel": "Cancel",
@@ -97,6 +98,14 @@
9798
"reverse": "Reverse direction of rotation"
9899
}
99100
},
101+
"security": {
102+
"title": "Web interface password",
103+
"password": {
104+
"title": "Password",
105+
"placeholder": "Leave empty to disable protection",
106+
"desc": "Protects the web interface with a password. Once logged in you stay logged in for 90 days. Protection is inactive in hotspot mode."
107+
}
108+
},
100109
"wifi": {
101110
"title": "WiFi-configuration",
102111
"networks": "Networks",

‎html/locales/fr.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"shutdown": "Éteindre",
88
"log": "Journal",
99
"logdownload": "Télécharger",
10+
"logout": "Déconnexion",
1011
"info": "Information",
1112
"delete": "Supprimer",
1213
"cancel": "Annuler",
@@ -89,6 +90,14 @@
8990
"reverse": "Inversion du sens de rotation"
9091
}
9192
},
93+
"security": {
94+
"title": "Mot de passe de l'interface web",
95+
"password": {
96+
"title": "Mot de passe",
97+
"placeholder": "Laisser vide pour désactiver la protection",
98+
"desc": "Protège l'interface web par un mot de passe. Une fois connecté, vous restez connecté pendant 90 jours. La protection est inactive en mode hotspot."
99+
}
100+
},
92101
"wifi": {
93102
"title": "Configuration WiFi",
94103
"networks": "Réseaux",

‎html/management.html‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,9 @@
10551055
class="fas fa-book menu-icon"></i> <span data-i18n="log"></span></a></li>
10561056
<li><a class="dropdown-item py-2 openPopupInfo" href="javascript:void(0);"><i
10571057
class="fas fa-info menu-icon"></i> <span data-i18n="info"></span></a></li>
1058+
<li id="menuLogout" data-visible="false"><a class="dropdown-item py-2"
1059+
href="javascript:void(0);"><i class="fas fa-sign-out-alt menu-icon"></i> <span
1060+
data-i18n="logout"></span></a></li>
10581061
<li>
10591062
<label class="dropdown-item py-2 form-check-label" for="lightSwitch"
10601063
style="cursor: pointer;"><i class="fas fa-adjust menu-icon"></i> <span
@@ -1331,6 +1334,22 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
13311334
<ul class="list-group" id="wifiListSavedSSIDs">
13321335
</ul>
13331336
</div>
1337+
<hr>
1338+
<div>
1339+
<legend data-i18n="security.title"></legend>
1340+
<form action="#securityConfig" method="POST" onsubmit="securityConfig(); return false">
1341+
<div class="mb-3 col-md-12">
1342+
<label for="wwwPassword" data-i18n="[prepend]security.password.title">:</label>
1343+
<input type="password" class="form-control" id="wwwPassword" name="wwwPassword"
1344+
maxlength="64" autocomplete="new-password"
1345+
data-i18n="[placeholder]security.password.placeholder">
1346+
<small class="form-text" data-i18n="security.password.desc"></small>
1347+
</div>
1348+
<div class="text-center">
1349+
<button type="submit" class="btn btn-primary" data-i18n="submit"></button>
1350+
</div>
1351+
</form>
1352+
</div>
13341353
</div>
13351354
<br />
13361355
</div>
@@ -4281,6 +4300,34 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
42814300
}
42824301
return dDisplay + hDisplay + mDisplay;
42834302
}
4303+
async function refreshSecurityStatus() {
4304+
try {
4305+
const status = await (await fetch("http://" + host + "/security")).json();
4306+
document.getElementById('menuLogout').setAttribute('data-visible', status.enabled === true);
4307+
} catch (e) {
4308+
console.log("failed to fetch security status", e);
4309+
}
4310+
}
4311+
4312+
async function securityConfig() {
4313+
const response = await fetch("http://" + host + "/security", {
4314+
method: "POST",
4315+
headers: {
4316+
"Content-Type": "application/json"
4317+
},
4318+
body: JSON.stringify({
4319+
password: document.getElementById('wwwPassword').value
4320+
})
4321+
});
4322+
if (response.ok) {
4323+
toaster.success(i18next.t("toast.success"));
4324+
document.getElementById('wwwPassword').value = '';
4325+
refreshSecurityStatus();
4326+
} else {
4327+
toaster.error(response.statusText);
4328+
}
4329+
}
4330+
42844331
var batteryLowWarned = false;
42854332
function updateBatteryIndicator(battery) {
42864333
const indicator = document.getElementById('batteryIndicator');
@@ -4423,6 +4470,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
44234470
$(document).ready(function () {
44244471
connect();
44254472
buildFileSystemTree("/");
4473+
refreshSecurityStatus();
44264474
// insert MQTT topics preview if not present
44274475
if ($('#mqttTopicsPreview').length === 0) {
44284476
var preview = $('<fieldset class="mb-3"><legend data-i18n="mqtt.topics.preview"></legend><pre id="mqttTopicsPreview" style="white-space:pre-wrap;"></pre></fieldset>');
@@ -4474,6 +4522,12 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
44744522
anchor.click();
44754523
URL.revokeObjectURL(anchor.href);
44764524
})
4525+
$('#menuLogout').on('click', async function () {
4526+
await fetch("http://" + host + "/logout", {
4527+
method: "POST"
4528+
});
4529+
location.reload();
4530+
});
44774531
$('.openPopupRestart').on('click', function () {
44784532
$('#modalInfoRefresh').hide();
44794533
restartDevice();

‎src/Web.cpp‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@
3232
#include <Update.h>
3333
#include <WiFi.h>
3434
#include <atomic>
35+
#include <esp_random.h>
3536
#include <esp_task_wdt.h>
37+
#include <mbedtls/sha256.h>
3638
#include <nvs.h>
3739

3840
typedef struct {
@@ -377,6 +379,134 @@ void Web_Exit(void) {
377379
webserverStarted = false;
378380
}
379381
}
382+
// ************************** Webinterface password protection ***************************
383+
// A single shared password (no username). The session cookie carries a token derived from
384+
// password + a stored salt, so all devices stay logged in for the cookie's lifetime (90
385+
// days). Changing or clearing the password rotates the salt and invalidates all sessions.
386+
static String wwwSessionToken = "";
387+
static uint8_t wwwFailedLogins = 0;
388+
static uint32_t wwwLoginLockedUntil = 0;
389+
390+
static const char wwwLoginPage[] PROGMEM = R"login(<!DOCTYPE html>
391+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
392+
<title>ESPuino</title><style>
393+
:root{--c:#00f0ff;--m:#ff2a6d;--bg:#05070d;--p:#0a101e;--b:#1e2c4a;--t:#c5d4f2}
394+
body{background:var(--bg);color:var(--t);font-family:"Lucida Console",Monaco,monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
395+
form{background:var(--p);border:1px solid var(--b);padding:40px 30px;max-width:280px;width:100%;text-align:center;clip-path:polygon(0 0,calc(100% - 18px) 0,100% 18px,100% 100%,0 100%)}
396+
h1{font-size:16px;text-transform:uppercase;letter-spacing:.2em;color:var(--c);text-shadow:0 0 12px rgba(0,240,255,.6)}
397+
h1::before{content:"// ";color:var(--m)}
398+
input{width:100%;box-sizing:border-box;height:44px;background:#0e1626;border:1px solid var(--b);color:var(--t);padding:0 12px;font-family:inherit;margin:14px 0}
399+
input:focus{outline:none;border-color:var(--c);box-shadow:0 0 12px rgba(0,240,255,.25)}
400+
button{width:100%;height:44px;background:rgba(0,240,255,.08);border:1px solid rgba(0,240,255,.55);color:var(--c);cursor:pointer;font-family:inherit;text-transform:uppercase;letter-spacing:.08em;clip-path:polygon(6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%,0 6px)}
401+
button:hover{background:var(--c);color:#021019}
402+
#e{color:var(--m);min-height:1.4em;margin-top:12px;font-size:13px}
403+
</style></head><body>
404+
<form id="f"><h1>Access</h1>
405+
<input type="password" id="p" placeholder="Passwort / Password" autofocus>
406+
<button type="submit">Login</button><div id="e"></div></form>
407+
<script>
408+
document.getElementById('f').onsubmit = async (ev) => {
409+
ev.preventDefault();
410+
const r = await fetch('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: document.getElementById('p').value }) });
411+
if (r.ok) { location.href = '/'; } else { document.getElementById('e').textContent = (r.status == 429) ? 'Zu viele Versuche / too many attempts' : 'Falsches Passwort / wrong password'; }
412+
};
413+
</script></body></html>)login";
414+
415+
static String Web_BuildSessionToken(const String &password, const String &salt) {
416+
uint8_t hash[32];
417+
String input = password + ":" + salt;
418+
mbedtls_sha256((const unsigned char *) input.c_str(), input.length(), hash, 0);
419+
String hex;
420+
hex.reserve(64);
421+
for (uint8_t i = 0; i < sizeof(hash); i++) {
422+
char buf[3];
423+
snprintf(buf, sizeof(buf), "%02x", hash[i]);
424+
hex += buf;
425+
}
426+
return hex;
427+
}
428+
429+
static void Web_RefreshSessionToken(void) {
430+
String password = gPrefsSettings.getString("wwwPassword", "");
431+
if (password.length() == 0) {
432+
wwwSessionToken = "";
433+
return;
434+
}
435+
String salt = gPrefsSettings.getString("wwwSalt", "");
436+
if (salt.length() == 0) {
437+
char saltBuf[17];
438+
snprintf(saltBuf, sizeof(saltBuf), "%08lx%08lx", (unsigned long) esp_random(), (unsigned long) esp_random());
439+
salt = saltBuf;
440+
gPrefsSettings.putString("wwwSalt", salt);
441+
}
442+
wwwSessionToken = Web_BuildSessionToken(password, salt);
443+
}
444+
445+
static bool Web_IsAuthenticated(AsyncWebServerRequest *request) {
446+
if (wwwSessionToken.length() == 0) {
447+
return true;
448+
}
449+
if (!request->hasHeader("Cookie")) {
450+
return false;
451+
}
452+
const String &cookies = request->header("Cookie");
453+
int idx = cookies.indexOf("ESPUINO_SESSION=");
454+
if (idx < 0) {
455+
return false;
456+
}
457+
int start = idx + 16; // length of "ESPUINO_SESSION="
458+
int end = cookies.indexOf(';', start);
459+
String token = (end < 0) ? cookies.substring(start) : cookies.substring(start, end);
460+
token.trim();
461+
return token.equals(wwwSessionToken);
462+
}
463+
464+
static void Web_HandlePostLogin(AsyncWebServerRequest *request, JsonVariant &json) {
465+
if (wwwLoginLockedUntil && (millis() < wwwLoginLockedUntil)) {
466+
request->send(429);
467+
return;
468+
}
469+
wwwLoginLockedUntil = 0;
470+
const char *password = json["password"];
471+
if ((wwwSessionToken.length() > 0) && password && gPrefsSettings.getString("wwwPassword", "").equals(password)) {
472+
wwwFailedLogins = 0;
473+
AsyncWebServerResponse *response = request->beginResponse(200, "application/json", "{\"status\":\"ok\"}");
474+
response->addHeader("Set-Cookie", "ESPUINO_SESSION=" + wwwSessionToken + "; Max-Age=7776000; Path=/; SameSite=Lax");
475+
request->send(response);
476+
Log_Println("webinterface login successful", LOGLEVEL_NOTICE);
477+
} else {
478+
if (++wwwFailedLogins >= 5) { // crude brute-force protection
479+
wwwFailedLogins = 0;
480+
wwwLoginLockedUntil = millis() + 60000;
481+
}
482+
Log_Println("webinterface login failed", LOGLEVEL_ERROR);
483+
request->send(401);
484+
}
485+
}
486+
487+
static void Web_HandlePostSecurity(AsyncWebServerRequest *request, JsonVariant &json) {
488+
String password = json["password"] | "";
489+
if (password.length() == 0) {
490+
gPrefsSettings.remove("wwwPassword");
491+
gPrefsSettings.remove("wwwSalt");
492+
wwwSessionToken = "";
493+
Log_Println("webinterface password protection disabled", LOGLEVEL_NOTICE);
494+
request->send(200, "application/json", "{\"enabled\":false}");
495+
return;
496+
}
497+
gPrefsSettings.putString("wwwPassword", password);
498+
// rotate the salt: all existing sessions become invalid
499+
char saltBuf[17];
500+
snprintf(saltBuf, sizeof(saltBuf), "%08lx%08lx", (unsigned long) esp_random(), (unsigned long) esp_random());
501+
gPrefsSettings.putString("wwwSalt", saltBuf);
502+
Web_RefreshSessionToken();
503+
Log_Println("webinterface password protection enabled", LOGLEVEL_NOTICE);
504+
// keep the client that changed the password logged in
505+
AsyncWebServerResponse *response = request->beginResponse(200, "application/json", "{\"enabled\":true}");
506+
response->addHeader("Set-Cookie", "ESPUINO_SESSION=" + wwwSessionToken + "; Max-Age=7776000; Path=/; SameSite=Lax");
507+
request->send(response);
508+
}
509+
380510
// handle not found
381511
void notFound(AsyncWebServerRequest *request) {
382512
Log_Printf(LOGLEVEL_ERROR, "%s not found, redirect to startpage", request->url().c_str());
@@ -388,6 +518,43 @@ void notFound(AsyncWebServerRequest *request) {
388518

389519
void webserverStart(void) {
390520
if (!webserverStarted && (Wlan_IsConnected() || (WiFi.getMode() == WIFI_AP))) {
521+
// password protection (no-op when no password is set or in accesspoint-mode)
522+
Web_RefreshSessionToken();
523+
wServer.addMiddleware([](AsyncWebServerRequest *request, ArMiddlewareNext next) {
524+
if ((wwwSessionToken.length() == 0) || (WiFi.getMode() == WIFI_AP)) {
525+
return next();
526+
}
527+
const String &url = request->url();
528+
if (url.equals("/login") || url.equals("/logo") || url.equals("/favicon.ico")) {
529+
return next();
530+
}
531+
if (Web_IsAuthenticated(request)) {
532+
return next();
533+
}
534+
if ((request->method() == HTTP_GET) && (url.equals("/") || url.endsWith(".html"))) {
535+
request->redirect("/login");
536+
} else {
537+
request->send(401);
538+
}
539+
});
540+
wServer.on("/login", HTTP_GET, [](AsyncWebServerRequest *request) {
541+
if ((wwwSessionToken.length() == 0) || Web_IsAuthenticated(request)) {
542+
request->redirect("/");
543+
return;
544+
}
545+
request->send(200, "text/html", wwwLoginPage);
546+
});
547+
wServer.addHandler(new AsyncCallbackJsonWebHandler("/login", Web_HandlePostLogin));
548+
wServer.on("/logout", HTTP_POST, [](AsyncWebServerRequest *request) {
549+
AsyncWebServerResponse *response = request->beginResponse(200, "application/json", "{\"status\":\"ok\"}");
550+
response->addHeader("Set-Cookie", "ESPUINO_SESSION=; Max-Age=0; Path=/; SameSite=Lax");
551+
request->send(response);
552+
});
553+
wServer.on("/security", HTTP_GET, [](AsyncWebServerRequest *request) {
554+
request->send(200, "application/json", (wwwSessionToken.length() > 0) ? "{\"enabled\":true}" : "{\"enabled\":false}");
555+
});
556+
wServer.addHandler(new AsyncCallbackJsonWebHandler("/security", Web_HandlePostSecurity));
557+
391558
// attach AsyncWebSocket for Mgmt-Interface
392559
ws.onEvent(onWebsocketEvent);
393560
wServer.addHandler(&ws);

0 commit comments

Comments
 (0)