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
3840typedef 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
381511void 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
389519void 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