Replace Web Basic Auth With Cookie Sessions
Add bounded login challenges, CSRF/origin enforcement, logout, and session-bound WebSocket admission. Isolate private HTTPD access behind a version-guarded adapter and add focused host coverage. Also let empty admin SSH input reach the normal console handler.
This commit is contained in:
+73
-240
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* TLS-only HTTP server with bounded Basic authentication and status output. */
|
||||
/* TLS-only HTTP server with bounded cookie authentication and status output. */
|
||||
|
||||
#include "web_server.h"
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "mbedtls/md.h"
|
||||
#include "secure_random.h"
|
||||
#include "serial_config.h"
|
||||
#include "serial_service.h"
|
||||
@@ -26,28 +24,13 @@
|
||||
#include "web_security.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_session_store.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_ui.h"
|
||||
#include "wifi_manager.h"
|
||||
|
||||
#define WEB_SERVER_PORT 443U
|
||||
#define WEB_SERVER_MAX_BASIC_DECODED \
|
||||
(USER_DATABASE_USERNAME_CAPACITY + 1U + USER_DATABASE_PASSWORD_CAPACITY)
|
||||
#define WEB_SERVER_MAX_BASIC_ENCODED \
|
||||
(((WEB_SERVER_MAX_BASIC_DECODED + 2U) / 3U) * 4U)
|
||||
#define WEB_SERVER_MAX_AUTHORIZATION \
|
||||
((sizeof("Basic ") - 1U) + WEB_SERVER_MAX_BASIC_ENCODED + 1U)
|
||||
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
|
||||
#define WEB_SERVER_AUTH_CACHE_ENTRIES 4U
|
||||
#define WEB_SERVER_AUTH_CACHE_KEY_LENGTH 32U
|
||||
#define WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH 32U
|
||||
#define WEB_SERVER_AUTH_CACHE_TTL_US 300000000LL
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
int64_t expires_at_us;
|
||||
uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH];
|
||||
user_principal_t principal;
|
||||
} web_server_auth_cache_entry_t;
|
||||
|
||||
static SemaphoreHandle_t s_server_mutex;
|
||||
static httpd_handle_t s_server;
|
||||
@@ -59,10 +42,6 @@ static bool s_serial_transport_attached;
|
||||
static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
|
||||
static esp_err_t s_serial_transport_error = ESP_ERR_INVALID_STATE;
|
||||
static web_server_counters_t s_counters;
|
||||
static bool s_auth_cache_ready;
|
||||
static uint8_t s_auth_cache_key[WEB_SERVER_AUTH_CACHE_KEY_LENGTH];
|
||||
static web_server_auth_cache_entry_t
|
||||
s_auth_cache[WEB_SERVER_AUTH_CACHE_ENTRIES];
|
||||
|
||||
static esp_err_t ensure_mutex(void)
|
||||
{
|
||||
@@ -112,188 +91,19 @@ static esp_err_t send_plain_error(httpd_req_t *request,
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t send_authentication_required(httpd_req_t *request)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_hdr(
|
||||
request, "WWW-Authenticate",
|
||||
"Basic realm=\"ESP32-SAK\", charset=\"UTF-8\"");
|
||||
if (error != ESP_OK) {
|
||||
increment_counter(&s_counters.response_errors);
|
||||
return error;
|
||||
}
|
||||
return send_plain_error(request, "401 Unauthorized", "Authentication required.\n");
|
||||
}
|
||||
|
||||
static bool constant_time_equal(const uint8_t *left, const uint8_t *right,
|
||||
size_t length)
|
||||
{
|
||||
uint8_t difference = 0U;
|
||||
for (size_t index = 0U; index < length; ++index) {
|
||||
difference |= left[index] ^ right[index];
|
||||
}
|
||||
return difference == 0U;
|
||||
}
|
||||
|
||||
static esp_err_t calculate_auth_cache_digest(
|
||||
const char *authorization, size_t authorization_length,
|
||||
uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH])
|
||||
{
|
||||
if (!s_auth_cache_ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
if (info == NULL ||
|
||||
mbedtls_md_hmac(info, s_auth_cache_key, sizeof(s_auth_cache_key),
|
||||
(const uint8_t *)authorization, authorization_length,
|
||||
digest) != 0) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static bool authenticate_from_cache(
|
||||
const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH],
|
||||
user_principal_t *principal)
|
||||
{
|
||||
int64_t now = esp_timer_get_time();
|
||||
for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) {
|
||||
web_server_auth_cache_entry_t *entry = &s_auth_cache[index];
|
||||
if (!entry->active || entry->expires_at_us <= now ||
|
||||
!constant_time_equal(entry->digest, digest, sizeof(entry->digest))) {
|
||||
if (entry->active && entry->expires_at_us <= now) {
|
||||
secure_wipe(entry, sizeof(*entry));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
bool current = false;
|
||||
if (user_database_principal_is_current(&entry->principal, ¤t) == ESP_OK &&
|
||||
current) {
|
||||
*principal = entry->principal;
|
||||
entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US;
|
||||
return true;
|
||||
}
|
||||
secure_wipe(entry, sizeof(*entry));
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void store_authenticated_request(
|
||||
const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH],
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
int64_t now = esp_timer_get_time();
|
||||
size_t selected = 0U;
|
||||
int64_t earliest_expiry = INT64_MAX;
|
||||
for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) {
|
||||
web_server_auth_cache_entry_t *entry = &s_auth_cache[index];
|
||||
if (entry->active &&
|
||||
constant_time_equal(entry->digest, digest, sizeof(entry->digest))) {
|
||||
selected = index;
|
||||
break;
|
||||
}
|
||||
if (!entry->active || entry->expires_at_us <= now) {
|
||||
selected = index;
|
||||
earliest_expiry = INT64_MIN;
|
||||
} else if (earliest_expiry != INT64_MIN &&
|
||||
entry->expires_at_us < earliest_expiry) {
|
||||
selected = index;
|
||||
earliest_expiry = entry->expires_at_us;
|
||||
}
|
||||
}
|
||||
|
||||
web_server_auth_cache_entry_t *entry = &s_auth_cache[selected];
|
||||
secure_wipe(entry, sizeof(*entry));
|
||||
entry->active = true;
|
||||
entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US;
|
||||
memcpy(entry->digest, digest, sizeof(entry->digest));
|
||||
entry->principal = *principal;
|
||||
}
|
||||
|
||||
static esp_err_t authenticate_request(httpd_req_t *request,
|
||||
user_principal_t *principal,
|
||||
bool *authenticated)
|
||||
{
|
||||
char authorization[WEB_SERVER_MAX_AUTHORIZATION] = {0};
|
||||
uint8_t decoded[WEB_SERVER_MAX_BASIC_DECODED] = {0};
|
||||
size_t decoded_length = 0U;
|
||||
uint8_t cache_digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH] = {0};
|
||||
bool cache_digest_valid = false;
|
||||
esp_err_t result = ESP_OK;
|
||||
memset(principal, 0, sizeof(*principal));
|
||||
*authenticated = false;
|
||||
|
||||
increment_counter(&s_counters.requests);
|
||||
size_t header_length = httpd_req_get_hdr_value_len(request, "Authorization");
|
||||
if (header_length == 0U || header_length >= sizeof(authorization)) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (httpd_req_get_hdr_value_str(request, "Authorization",
|
||||
authorization, sizeof(authorization)) != ESP_OK ||
|
||||
header_length < 7U || strncasecmp(authorization, "Basic ", 6U) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
result = calculate_auth_cache_digest(authorization, header_length, cache_digest);
|
||||
if (result != ESP_OK) {
|
||||
goto cleanup;
|
||||
}
|
||||
cache_digest_valid = true;
|
||||
if (authenticate_from_cache(cache_digest, principal)) {
|
||||
*authenticated = true;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
int decode_result = mbedtls_base64_decode(
|
||||
decoded, sizeof(decoded), &decoded_length,
|
||||
(const unsigned char *)authorization + 6U, header_length - 6U);
|
||||
if (decode_result != 0 || decoded_length == 0U) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
uint8_t *separator = memchr(decoded, ':', decoded_length);
|
||||
if (separator == NULL) {
|
||||
goto cleanup;
|
||||
}
|
||||
size_t username_length = (size_t)(separator - decoded);
|
||||
size_t password_length = decoded_length - username_length - 1U;
|
||||
result = user_database_authenticate_password(
|
||||
decoded, username_length, separator + 1U, password_length,
|
||||
principal, authenticated);
|
||||
if (result == ESP_OK && *authenticated && cache_digest_valid) {
|
||||
store_authenticated_request(cache_digest, principal);
|
||||
}
|
||||
|
||||
cleanup:
|
||||
secure_wipe(authorization, sizeof(authorization));
|
||||
secure_wipe(decoded, sizeof(decoded));
|
||||
secure_wipe(cache_digest, sizeof(cache_digest));
|
||||
if (result != ESP_OK) {
|
||||
memset(principal, 0, sizeof(*principal));
|
||||
return result;
|
||||
}
|
||||
if (*authenticated) {
|
||||
increment_counter(&s_counters.authenticated_requests);
|
||||
} else {
|
||||
memset(principal, 0, sizeof(*principal));
|
||||
increment_counter(&s_counters.authentication_failures);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t authorize_or_respond(httpd_req_t *request,
|
||||
user_principal_t *principal,
|
||||
bool *authorized)
|
||||
{
|
||||
*authorized = false;
|
||||
esp_err_t error = authenticate_request(request, principal, authorized);
|
||||
if (error != ESP_OK) {
|
||||
*authorized = false;
|
||||
return send_plain_error(request, "503 Service Unavailable",
|
||||
"Authentication service unavailable.\n");
|
||||
}
|
||||
return *authorized ? ESP_OK : send_authentication_required(request);
|
||||
web_session_view_t view = {0};
|
||||
increment_counter(&s_counters.requests);
|
||||
esp_err_t error = web_cookie_auth_require(request, false, false, &view, authorized);
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
*principal = view.principal;
|
||||
secure_wipe(&view, sizeof(view));
|
||||
increment_counter(*authorized ? &s_counters.authenticated_requests :
|
||||
&s_counters.authentication_failures);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t send_authenticated_ui(httpd_req_t *request,
|
||||
@@ -331,23 +141,21 @@ static esp_err_t asset_handler(httpd_req_t *request)
|
||||
|
||||
static esp_err_t ticket_handler(httpd_req_t *request)
|
||||
{
|
||||
user_principal_t principal = {0};
|
||||
web_session_view_t view = {0};
|
||||
bool authorized = false;
|
||||
esp_err_t error = authorize_or_respond(request, &principal, &authorized);
|
||||
increment_counter(&s_counters.requests);
|
||||
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &authorized);
|
||||
increment_counter(authorized ? &s_counters.authenticated_requests : &s_counters.authentication_failures);
|
||||
if (error != ESP_OK || !authorized) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
increment_counter(&s_counters.ticket_requests);
|
||||
if (request->content_len != 0U) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
return send_plain_error(request, "400 Bad Request",
|
||||
"Ticket requests must have an empty body.\n");
|
||||
}
|
||||
|
||||
error = web_serial_transport_handle_authenticated_ticket_request(
|
||||
request, &principal, 0U);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
request, &view.principal, view.id);
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
if (error == ESP_OK) {
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -355,9 +163,15 @@ static esp_err_t ticket_handler(httpd_req_t *request)
|
||||
return send_plain_error(request, "400 Bad Request",
|
||||
"Invalid web-terminal ticket request.\n");
|
||||
}
|
||||
if (error == ESP_ERR_NO_MEM &&
|
||||
httpd_resp_set_hdr(request, "Retry-After", "5") == ESP_OK) {
|
||||
return send_plain_error(request, "503 Service Unavailable", "{\"error\":\"capacity\"}");
|
||||
}
|
||||
increment_counter(&s_counters.response_errors);
|
||||
return send_plain_error(request, "503 Service Unavailable",
|
||||
"Web terminal transport unavailable.\n");
|
||||
if (error == ESP_ERR_INVALID_STATE)
|
||||
return send_plain_error(request, "503 Service Unavailable",
|
||||
"Web terminal transport unavailable.\n");
|
||||
return error; /* A failed/partial send must close, not send a second response. */
|
||||
}
|
||||
|
||||
static const char *safe_string(const char *value)
|
||||
@@ -533,12 +347,24 @@ static const httpd_uri_t s_ticket_uri = {
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
|
||||
static esp_err_t websocket_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed);
|
||||
if (allowed) error = web_serial_transport_session_ws_handler(request, view.id);
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
static const httpd_uri_t s_websocket_uri = {
|
||||
.uri = WEB_SERIAL_TRANSPORT_WS_URI,
|
||||
.method = HTTP_GET,
|
||||
.handler = web_serial_transport_ws_handler,
|
||||
.handler = websocket_handler,
|
||||
.user_ctx = NULL,
|
||||
.is_websocket = true,
|
||||
/* Authorize and admit before the adapter sends 101, not IDF's pre-handler path. */
|
||||
.is_websocket = false,
|
||||
.handle_ws_control_frames = false,
|
||||
};
|
||||
|
||||
@@ -589,6 +415,22 @@ static const httpd_uri_t *const s_uri_handlers[] = {
|
||||
&s_logo_uri,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_auth_uris[] = {
|
||||
{.uri = "/login", .method = HTTP_GET, .handler = web_cookie_auth_handler},
|
||||
{.uri = "/api/login-challenge", .method = HTTP_GET, .handler = web_cookie_auth_handler},
|
||||
{.uri = "/api/login", .method = HTTP_POST, .handler = web_cookie_auth_handler},
|
||||
{.uri = "/api/session", .method = HTTP_GET, .handler = web_cookie_auth_handler},
|
||||
{.uri = "/api/logout", .method = HTTP_POST, .handler = web_cookie_auth_handler},
|
||||
};
|
||||
|
||||
static esp_err_t route_error_handler(httpd_req_t *request, httpd_err_code_t code)
|
||||
{
|
||||
(void)send_plain_error(request,
|
||||
code == HTTPD_405_METHOD_NOT_ALLOWED ? "405 Method Not Allowed" : "404 Not Found",
|
||||
"Unsupported route or method.\n");
|
||||
return ESP_FAIL; /* Do not drain a rejected request body on keepalive. */
|
||||
}
|
||||
|
||||
esp_err_t web_server_init(void)
|
||||
{
|
||||
esp_err_t error = ensure_mutex();
|
||||
@@ -596,19 +438,6 @@ esp_err_t web_server_init(void)
|
||||
return error;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
if (!s_auth_cache_ready) {
|
||||
error = secure_random_fill(s_auth_cache_key, sizeof(s_auth_cache_key));
|
||||
if (error == ESP_OK) {
|
||||
secure_wipe(s_auth_cache, sizeof(s_auth_cache));
|
||||
s_auth_cache_ready = true;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
bool initialize_serial_transport = false;
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
if (!s_serial_transport_init_attempted) {
|
||||
@@ -654,12 +483,8 @@ esp_err_t web_server_start(void)
|
||||
serial_transport_ready = s_serial_transport_initialized;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
/* Initialize only after admission: a rejected start must not undo stop.
|
||||
* Dormant Phase 8D primitives do not gate the existing Basic-auth service. */
|
||||
esp_err_t session_error = web_session_store_init();
|
||||
if (session_error != ESP_OK) {
|
||||
ESP_LOGW("web_server", "Session store unavailable: %s", esp_err_to_name(session_error));
|
||||
}
|
||||
/* Initialize only after lifecycle admission; failure gates all HTTPS auth. */
|
||||
error = web_cookie_auth_start();
|
||||
|
||||
uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0};
|
||||
uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0};
|
||||
@@ -667,7 +492,7 @@ esp_err_t web_server_start(void)
|
||||
size_t private_key_length = 0U;
|
||||
httpd_handle_t server = NULL;
|
||||
|
||||
error = web_security_copy_tls_material(
|
||||
if (error == ESP_OK) error = web_security_copy_tls_material(
|
||||
certificate, sizeof(certificate), &certificate_length,
|
||||
private_key, sizeof(private_key), &private_key_length);
|
||||
if (error == ESP_OK) {
|
||||
@@ -675,7 +500,8 @@ esp_err_t web_server_start(void)
|
||||
/* Two browser terminals retain room for parallel assets and status fetches. */
|
||||
config.httpd.max_open_sockets = 6;
|
||||
config.httpd.max_uri_handlers =
|
||||
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]);
|
||||
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]);
|
||||
config.httpd.lru_purge_enable = true;
|
||||
config.httpd.recv_wait_timeout = 1;
|
||||
config.httpd.send_wait_timeout = 1;
|
||||
@@ -698,13 +524,19 @@ esp_err_t web_server_start(void)
|
||||
}
|
||||
|
||||
bool serial_transport_attached = false;
|
||||
for (size_t i = 0; error == ESP_OK && i < sizeof(s_auth_uris) / sizeof(s_auth_uris[0]); ++i)
|
||||
error = httpd_register_uri_handler(server, &s_auth_uris[i]);
|
||||
if (error == ESP_OK)
|
||||
error = httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, route_error_handler);
|
||||
if (error == ESP_OK)
|
||||
error = httpd_register_err_handler(server, HTTPD_405_METHOD_NOT_ALLOWED, route_error_handler);
|
||||
esp_err_t attach_error = s_serial_transport_error;
|
||||
if (error == ESP_OK && serial_transport_ready) {
|
||||
attach_error = web_serial_transport_attach_server(server);
|
||||
serial_transport_attached = attach_error == ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
web_session_store_stop();
|
||||
web_cookie_auth_stop();
|
||||
}
|
||||
if (error != ESP_OK && server != NULL) {
|
||||
esp_err_t cleanup_error = httpd_ssl_stop(server);
|
||||
@@ -750,7 +582,7 @@ esp_err_t web_server_stop(void)
|
||||
s_transitioning = true;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
web_session_store_stop();
|
||||
web_cookie_auth_stop();
|
||||
if (serial_transport_attached) {
|
||||
esp_err_t detach_error = web_serial_transport_detach_server(server);
|
||||
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
|
||||
@@ -813,5 +645,6 @@ esp_err_t web_server_clear_counters(void)
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
memset(&s_counters, 0, sizeof(s_counters));
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
web_cookie_auth_clear_counters();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user