Add Standalone Login Page Renderer

Add a hash-bound, no-store login document with focused C and Node host
tests. Keep rendering inert until the 8D.3 authentication cutover.
This commit is contained in:
2026-09-05 18:45:12 +02:00
parent 00f226dc59
commit 4435a7fddd
9 changed files with 542 additions and 4 deletions
+1
View File
@@ -36,6 +36,7 @@ idf_component_register(
"web_server.c"
"web_session_store.c"
"web_auth_parse.c"
"web_login_ui.c"
"web_console.c"
"wifi_config.c"
"wifi_manager.c"
+160
View File
@@ -0,0 +1,160 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_login_ui.h"
#include <stddef.h>
/* Authored standalone page; no dependency on protected or generated assets. */
static const char s_login_html[] =
"<!doctype html>\n<html lang=\"en\">\n<head>\n"
"<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
"<title>Sign in - ESP32 Serial Console</title>\n"
"<style>\n"
":root{color-scheme:dark;font:16px/1.5 system-ui,sans-serif;background:#090d14;color:#e8eef8}\n"
"*{box-sizing:border-box}body{margin:0;padding:2rem 1rem}main{max-width:28rem;margin:3vh auto;"
"padding:1.5rem;background:#111824;border:1px solid #29364a;border-radius:14px}\n"
"h1{font-size:1.5rem}label{display:block;margin-top:1rem}input,button{font:inherit;"
"width:100%;padding:.65rem;border:1px solid #91a0b5;border-radius:6px}"
"input{background:#090d14;color:inherit}button{margin-top:1.25rem;background:#55c2ff;"
"color:#090d14;cursor:pointer}button:disabled{opacity:.6;cursor:wait}"
"a{color:#55c2ff}:focus-visible{outline:3px solid #ffc857;outline-offset:3px}"
"#message{min-height:3em}small{display:block;color:#b6c2d4}\n"
"</style>\n</head>\n<body>\n<main>\n"
"<h1>ESP32 Serial Console</h1>\n"
"<p>Sign in to access the serial terminal.</p>\n"
"<form id=\"login\" method=\"post\" action=\"/api/login\">\n"
"<label for=\"username\">Username</label>\n"
"<input id=\"username\" name=\"username\" autocomplete=\"username\" "
"autocapitalize=\"none\" spellcheck=\"false\" maxlength=\"16\" required>\n"
"<label for=\"password\">Password</label>\n"
"<input id=\"password\" name=\"password\" type=\"password\" "
"autocomplete=\"current-password\" maxlength=\"64\" required>\n"
"<button id=\"submit\" type=\"submit\" disabled>Sign in</button>\n"
"</form>\n"
"<p id=\"message\" role=\"status\" aria-live=\"polite\">Ready to sign in.</p>\n"
"<noscript><p>JavaScript is required to sign in securely.</p></noscript>\n"
"<p><a href=\"/\">Return to console</a></p>\n"
"<small>Sessions expire after one hour, including active serial connections. "
"To switch accounts, return to the console and sign out first.</small>\n"
"</main>\n<script>\n"
"(() => {\n"
" 'use strict';\n"
" const form = document.getElementById('login');\n"
" const username = document.getElementById('username');\n"
" const password = document.getElementById('password');\n"
" const submit = document.getElementById('submit');\n"
" const message = document.getElementById('message');\n"
" const encoder = new TextEncoder();\n"
" let busy = false, generation = 0, controller = null, retryAt = 0;\n"
" function reset() {\n"
" ++generation;\n"
" if (controller) controller.abort();\n"
" controller = null; busy = false; password.value = '';\n"
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
" }\n"
" window.addEventListener('pagehide', reset);\n"
" window.addEventListener('pageshow', event => {\n"
" if (event.persisted) { reset(); message.textContent = 'Ready to sign in.'; }\n"
" });\n"
" async function readJSON(response) {\n"
" if (!response.body) throw new Error('response');\n"
" const reader = response.body.getReader();\n"
" const bytes = new Uint8Array(512);\n"
" let length = 0;\n"
" try {\n"
" for (;;) {\n"
" const part = await reader.read();\n"
" if (part.done) break;\n"
" if (part.value.length > bytes.length - length) throw new Error('response');\n"
" bytes.set(part.value, length); length += part.value.length;\n"
" }\n"
" return JSON.parse(new TextDecoder('utf-8', {fatal:true}).decode(bytes.subarray(0, length)));\n"
" } finally { await reader.cancel(); reader.releaseLock(); }\n"
" }\n"
" function report(response, stage) {\n"
" if (response.status === 429 || response.status === 503) {\n"
" const raw = response.headers.get('Retry-After') || '';\n"
" const seconds = /^[0-9]{1,3}$/.test(raw) ? Math.max(1, Math.min(120, Number(raw))) : 5;\n"
" retryAt = Date.now() + seconds * 1000;\n"
" message.textContent = (response.status === 429 ? 'Too many sign-in attempts.' : 'Sign-in capacity is busy.') +\n"
" ' Wait ' + seconds + ' seconds, then try again.';\n"
" } else if (response.status === 401 && stage === 'login') {\n"
" message.textContent = 'Username or password is incorrect. Please try again.';\n"
" } else if (response.status === 403) {\n"
" message.textContent = 'The sign-in challenge expired or the request was rejected. Please try again.';\n"
" } else if (response.status === 409) {\n"
" message.textContent = 'Already signed in. Return to the console; sign out there to switch accounts.';\n"
" } else if ([400, 413, 415].includes(response.status)) {\n"
" message.textContent = 'The sign-in request was not accepted. Check your input and try again.';\n"
" } else {\n"
" message.textContent = 'The device could not complete sign-in. Please try again.';\n"
" }\n"
" }\n"
" form.addEventListener('submit', async event => {\n"
" event.preventDefault();\n"
" if (busy) { password.value = ''; return; }\n"
" if (Date.now() < retryAt) {\n"
" password.value = '';\n"
" message.textContent = 'Please wait ' + Math.ceil((retryAt - Date.now()) / 1000) + ' seconds before retrying.';\n"
" return;\n"
" }\n"
" let body = JSON.stringify({username:username.value, password:password.value});\n"
" const valid = username.value.length && password.value.length &&\n"
" encoder.encode(username.value).length <= 16 && encoder.encode(password.value).length <= 64 &&\n"
" !username.value.includes('\\0') && !password.value.includes('\\0') && encoder.encode(body).length <= 512;\n"
" password.value = '';\n"
" if (!valid) { body = ''; message.textContent = 'Enter a username (up to 16 UTF-8 bytes) and password (up to 64 UTF-8 bytes).'; return; }\n"
" busy = true; submit.disabled = true; username.disabled = true; password.disabled = true; form.setAttribute('aria-busy', 'true');\n"
" message.textContent = 'Signing in...';\n"
" const current = ++generation;\n"
" const abort = new AbortController(); controller = abort;\n"
" const timeout = setTimeout(() => abort.abort(), 15000);\n"
" let csrf = '';\n"
" const options = {credentials:'same-origin', mode:'same-origin', cache:'no-store', redirect:'error', signal:abort.signal};\n"
" try {\n"
" const challenge = await fetch('/api/login-challenge', {...options, headers:{'X-Login-Bootstrap':'1'}});\n"
" if (current !== generation) return;\n"
" if (challenge.status !== 200) { report(challenge, 'challenge'); return; }\n"
" const data = await readJSON(challenge);\n"
" if (current !== generation) return;\n"
" if (!data || typeof data.csrf !== 'string' || !/^[0-9a-f]{64}$/.test(data.csrf) ||\n"
" !Number.isInteger(data.expires_in) || data.expires_in < 1 || data.expires_in > 120) throw new Error('challenge');\n"
" csrf = data.csrf;\n"
" const response = await fetch('/api/login', {...options, method:'POST',\n"
" headers:{'Content-Type':'application/json', 'X-CSRF-Token':csrf}, body});\n"
" body = ''; csrf = '';\n"
" if (current !== generation) return;\n"
" if (response.status !== 200) { report(response, 'login'); return; }\n"
" const result = await readJSON(response);\n"
" if (current !== generation) return;\n"
" if (!result || result.authenticated !== true) throw new Error('login');\n"
" window.location.replace('/');\n"
" } catch (_) {\n"
" if (current === generation) message.textContent = 'Could not confirm sign-in. Check the connection, then return to the console or try again.';\n"
" } finally {\n"
" abort.abort(); clearTimeout(timeout); body = ''; csrf = '';\n"
" if (current === generation) {\n"
" controller = null; busy = false; password.value = '';\n"
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
" }\n"
" }\n"
" });\n"
" submit.disabled = false;\n"
"})();\n"
"</script>\n</body>\n</html>\n";
esp_err_t web_login_ui_send_response(httpd_req_t *request)
{
if (request == NULL) return ESP_ERR_INVALID_ARG;
esp_err_t error = httpd_resp_set_type(request, "text/html; charset=utf-8");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Frame-Options", "DENY");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Content-Security-Policy",
"default-src 'none'; script-src 'sha256-x70ID2kbifGBVYfh/pePTt5v/AVHkT7JVAV0LjT1wCo='; "
"style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'none'");
if (error == ESP_OK) error = httpd_resp_send(request, s_login_html, sizeof(s_login_html) - 1U);
return error;
}
+10
View File
@@ -0,0 +1,10 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
/* Standalone public login document for the future atomic 8D.3 cutover.
* Rendering only: no authentication, URI registration, or session allocation.
* Do not expose this page until its protected API and application routes exist. */
esp_err_t web_login_ui_send_response(httpd_req_t *request);