[web_server] Add HTTP digest authentication with selectable scheme (#17541)

This commit is contained in:
Jesse Hills
2026-07-14 10:33:44 +12:00
committed by GitHub
parent 5e3e2f82c9
commit 65d6c028ce
14 changed files with 370 additions and 18 deletions
+40 -12
View File
@@ -25,6 +25,7 @@ from esphome.const import (
CONF_OTA,
CONF_PASSWORD,
CONF_PORT,
CONF_TYPE,
CONF_USERNAME,
CONF_VERSION,
CONF_WEB_SERVER,
@@ -44,6 +45,9 @@ _LOGGER = logging.getLogger(__name__)
AUTO_LOAD = ["json", "web_server_base"]
AUTH_TYPE_BASIC = "basic"
AUTH_TYPE_DIGEST = "digest"
CONF_SORTING_GROUP_ID = "sorting_group_id"
CONF_SORTING_GROUPS = "sorting_groups"
CONF_SORTING_WEIGHT = "sorting_weight"
@@ -85,6 +89,19 @@ def validate_version_deprecated(config: ConfigType) -> ConfigType:
return config
def validate_auth_type_deprecated(auth: ConfigType) -> ConfigType:
# Remove before 2027.1.0: the default auth scheme changes from basic to digest.
if CONF_TYPE not in auth:
_LOGGER.warning(
"The 'web_server' 'auth' scheme currently defaults to 'basic', which sends the "
"password over the network in an easily reversible form. The default will change "
"to 'digest' in ESPHome 2027.1.0. To keep using basic authentication, set "
"'type: basic' under 'auth:' explicitly; otherwise set 'type: digest' now to "
"adopt the more secure scheme."
)
return auth
def validate_local(config: ConfigType) -> ConfigType:
if CONF_LOCAL in config and config[CONF_VERSION] == 1:
raise cv.Invalid("'local' is not supported in version 1")
@@ -242,15 +259,21 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ALLOWED_ORIGINS): cv.All(
cv.ensure_list(validate_origin), cv.Length(min=1)
),
cv.Optional(CONF_AUTH): cv.Schema(
{
cv.Required(CONF_USERNAME): cv.All(
cv.string_strict, cv.Length(min=1)
),
cv.Required(CONF_PASSWORD): cv.sensitive(
cv.All(cv.string_strict, cv.Length(min=1))
),
}
cv.Optional(CONF_AUTH): cv.All(
cv.Schema(
{
cv.Required(CONF_USERNAME): cv.All(
cv.string_strict, cv.Length(min=1)
),
cv.Required(CONF_PASSWORD): cv.sensitive(
cv.All(cv.string_strict, cv.Length(min=1))
),
cv.Optional(CONF_TYPE): cv.one_of(
AUTH_TYPE_BASIC, AUTH_TYPE_DIGEST, lower=True
),
}
),
validate_auth_type_deprecated,
),
cv.GenerateID(CONF_WEB_SERVER_BASE_ID): cv.use_id(
web_server_base.WebServerBase
@@ -378,10 +401,15 @@ async def to_code(config):
if (allowed_origins := config.get(CONF_ALLOWED_ORIGINS)) is not None:
cg.add_define("USE_WEBSERVER_ALLOWED_ORIGINS")
cg.add(var.set_allowed_origins(allowed_origins))
if CONF_AUTH in config:
if (auth := config.get(CONF_AUTH)) is not None:
cg.add_define("USE_WEBSERVER_AUTH")
cg.add(paren.set_auth_username(config[CONF_AUTH][CONF_USERNAME]))
cg.add(paren.set_auth_password(config[CONF_AUTH][CONF_PASSWORD]))
# The scheme is fixed at build time so the unused Basic/Digest code path is compiled
# out. Basic is the current default (the absence of this define); an explicit
# 'type: digest' opts in early. Default changes to digest in 2027.1.0.
if auth.get(CONF_TYPE) == AUTH_TYPE_DIGEST:
cg.add_define("USE_WEBSERVER_AUTH_DIGEST")
cg.add(paren.set_auth_username(auth[CONF_USERNAME]))
cg.add(paren.set_auth_password(auth[CONF_PASSWORD]))
if CONF_CSS_INCLUDE in config:
cg.add_define("USE_WEBSERVER_CSS_INCLUDE")
path = CORE.relative_config_path(config[CONF_CSS_INCLUDE])
@@ -59,7 +59,15 @@ class AuthMiddlewareHandler : public MiddlewareHandler {
bool check_auth(AsyncWebServerRequest *request) {
bool success = request->authenticate(credentials_->username.c_str(), credentials_->password.c_str());
if (!success) {
// The scheme is chosen at build time (USE_WEBSERVER_AUTH_DIGEST); the unused path is
// compiled out. On ESP32 our own server picks the scheme internally.
#if USE_ESP32
request->requestAuthentication();
#elif defined(USE_WEBSERVER_AUTH_DIGEST)
request->requestAuthentication(nullptr, true);
#else
request->requestAuthentication(nullptr, false);
#endif
}
return success;
}
@@ -16,6 +16,11 @@
#include "utils.h"
#include "web_server_idf.h"
#ifdef USE_WEBSERVER_AUTH_DIGEST
#include <esp_random.h>
#include <esp_rom_md5.h>
#endif
#ifdef USE_WEBSERVER_OTA
#include <multipart_parser.h>
#include "multipart.h" // For parse_multipart_boundary and other utils
@@ -372,6 +377,135 @@ void AsyncWebServerRequest::init_response_(AsyncWebServerResponse *rsp, int code
}
#ifdef USE_WEBSERVER_AUTH
#ifdef USE_WEBSERVER_AUTH_DIGEST
namespace {
// Hex-encode `len` bytes into `out`, which must hold at least 2 * len + 1 bytes. Null-terminated.
void bytes_to_hex(const uint8_t *data, size_t len, char *out) {
static const char HEX[] = "0123456789abcdef";
for (size_t i = 0; i < len; i++) {
out[i * 2] = HEX[data[i] >> 4];
out[i * 2 + 1] = HEX[data[i] & 0x0f];
}
out[len * 2] = '\0';
}
// Extract the value of a Digest auth parameter (e.g. "nonce") from the comma-separated
// parameter list. Values may be quoted or bare. Returns an empty ref when the key is absent.
// Only whole parameter names match, so "nc" does not match inside "cnonce".
StringRef digest_param(StringRef params, const char *key) {
size_t key_len = strlen(key);
const char *base = params.c_str();
size_t n = params.size();
size_t i = 0;
while (i < n) {
while (i < n && (base[i] == ' ' || base[i] == ','))
i++;
size_t name_start = i;
while (i < n && base[i] != '=' && base[i] != ',')
i++;
if (i >= n || base[i] == ',')
continue; // token without a '=', skip it
size_t name_len = i - name_start;
while (name_len > 0 && base[name_start + name_len - 1] == ' ')
name_len--;
i++; // consume '='
const char *val_start;
size_t val_len;
if (i < n && base[i] == '"') {
i++;
val_start = base + i;
while (i < n && base[i] != '"')
i++;
val_len = (base + i) - val_start;
if (i < n)
i++; // consume closing quote
} else {
val_start = base + i;
while (i < n && base[i] != ',')
i++;
val_len = (base + i) - val_start;
}
if (name_len == key_len && memcmp(base + name_start, key, key_len) == 0)
return StringRef(val_start, val_len);
while (i < n && base[i] != ',')
i++;
}
return StringRef();
}
// Verify an RFC 2617 Digest response. Stateless (the nonce we issued is not tracked), which
// matches the ESPAsyncWebServer backend used on the Arduino platforms.
bool check_digest_auth(const char *username, const char *password, const std::string &header, const char *method) {
const size_t prefix_len = sizeof("Digest ") - 1;
StringRef params(header.c_str() + prefix_len, header.size() - prefix_len);
if (digest_param(params, "username") != username)
return false;
StringRef realm = digest_param(params, "realm");
StringRef nonce = digest_param(params, "nonce");
StringRef uri = digest_param(params, "uri");
StringRef qop = digest_param(params, "qop");
StringRef nc = digest_param(params, "nc");
StringRef cnonce = digest_param(params, "cnonce");
StringRef response = digest_param(params, "response");
if (response.size() != 32)
return false;
// Compute the three MD5 hashes by streaming the pieces straight into the ROM MD5 engine, so
// nothing is concatenated on the heap. Each hash is emitted as 32 lowercase hex characters.
md5_context_t ctx;
uint8_t digest[16];
// HA1 = MD5(username:realm:password) -- uses the realm the client echoed back.
char ha1[33];
esp_rom_md5_init(&ctx);
esp_rom_md5_update(&ctx, username, strlen(username));
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, realm.c_str(), realm.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, password, strlen(password));
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), ha1);
// HA2 = MD5(method:uri) -- uses the uri the client echoed back.
char ha2[33];
esp_rom_md5_init(&ctx);
esp_rom_md5_update(&ctx, method, strlen(method));
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, uri.c_str(), uri.size());
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), ha2);
// expected = MD5(HA1:nonce:nc:cnonce:qop:HA2)
char expected[33];
esp_rom_md5_init(&ctx);
esp_rom_md5_update(&ctx, ha1, 32);
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, nonce.c_str(), nonce.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, nc.c_str(), nc.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, cnonce.c_str(), cnonce.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, qop.c_str(), qop.size());
esp_rom_md5_update(&ctx, ":", 1);
esp_rom_md5_update(&ctx, ha2, 32);
esp_rom_md5_final(digest, &ctx);
bytes_to_hex(digest, sizeof(digest), expected);
// Constant-time comparison of the two 32-char hex digests.
uint8_t result = 0;
for (size_t i = 0; i < 32; i++)
result |= static_cast<uint8_t>(expected[i] ^ response[i]);
return result == 0;
}
} // namespace
#endif // USE_WEBSERVER_AUTH_DIGEST
bool AsyncWebServerRequest::authenticate(const char *username, const char *password) const {
if (username == nullptr || password == nullptr || *username == 0) {
return true;
@@ -383,9 +517,18 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
auto *auth_str = auth.value().c_str();
#ifdef USE_WEBSERVER_AUTH_DIGEST
// The build fixed the scheme to Digest, so the Basic path is compiled out entirely.
const auto auth_prefix_len = sizeof("Digest ") - 1;
if (strncmp("Digest ", auth_str, auth_prefix_len) != 0) {
ESP_LOGW(TAG, "Only Digest authorization supported");
return false;
}
return check_digest_auth(username, password, auth.value(), http_method_str(this->method()));
#else
const auto auth_prefix_len = sizeof("Basic ") - 1;
if (strncmp("Basic ", auth_str, auth_prefix_len) != 0) {
ESP_LOGW(TAG, "Only Basic authorization supported yet");
ESP_LOGW(TAG, "Only Basic authorization supported");
return false;
}
@@ -434,16 +577,33 @@ bool AsyncWebServerRequest::authenticate(const char *username, const char *passw
result |= static_cast<uint8_t>(digest[i] ^ provided_ch);
}
return result == 0;
#endif // USE_WEBSERVER_AUTH_DIGEST
}
void AsyncWebServerRequest::requestAuthentication(const char *realm) const {
void AsyncWebServerRequest::requestAuthentication() const {
httpd_resp_set_hdr(*this, "Connection", "keep-alive");
// Note: realm is never configured in ESPHome, always nullptr -> "Login Required"
(void) realm; // Unused - always use default
#ifdef USE_WEBSERVER_AUTH_DIGEST
// Issue a fresh random nonce and opaque. The nonce is not stored, so this is stateless and
// does not defend against replay -- its purpose is to keep the password off the wire.
// The header value must stay alive until httpd_resp_send_err() below sends it, so the buffer
// lives on this stack frame (httpd_resp_set_hdr stores the pointer, it does not copy).
uint8_t random_bytes[16];
char nonce[33];
char opaque[33];
char header[160];
esp_fill_random(random_bytes, sizeof(random_bytes));
bytes_to_hex(random_bytes, sizeof(random_bytes), nonce);
esp_fill_random(random_bytes, sizeof(random_bytes));
bytes_to_hex(random_bytes, sizeof(random_bytes), opaque);
snprintf(header, sizeof(header), R"(Digest realm="Login Required", qop="auth", nonce="%s", opaque="%s")", nonce,
opaque);
httpd_resp_set_hdr(*this, "WWW-Authenticate", header);
#else
httpd_resp_set_hdr(*this, "WWW-Authenticate", "Basic realm=\"Login Required\"");
#endif // USE_WEBSERVER_AUTH_DIGEST
httpd_resp_send_err(*this, HTTPD_401_UNAUTHORIZED, nullptr);
}
#endif
#endif // USE_WEBSERVER_AUTH
AsyncWebParameter *AsyncWebServerRequest::getParam(const char *name) {
// Check cache first - only successful lookups are cached
@@ -129,7 +129,7 @@ class AsyncWebServerRequest {
#ifdef USE_WEBSERVER_AUTH
bool authenticate(const char *username, const char *password) const;
// NOLINTNEXTLINE(readability-identifier-naming)
void requestAuthentication(const char *realm = nullptr) const;
void requestAuthentication() const;
#endif
void redirect(const std::string &url);
+3
View File
@@ -298,6 +298,7 @@
#define USE_VOICE_ASSISTANT
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_OTA
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_WEBSERVER_GZIP
@@ -408,6 +409,7 @@
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_PORT 80 // NOLINT
#endif
@@ -438,6 +440,7 @@
#define USE_LWIP_FAST_SELECT
#define USE_WEBSERVER
#define USE_WEBSERVER_AUTH
#define USE_WEBSERVER_AUTH_DIGEST
#define USE_WEBSERVER_PORT 80 // NOLINT
#define USE_ESPHOME_TASK_LOG_BUFFER
#define ESPHOME_TASK_LOG_BUFFER_SIZE 768
@@ -0,0 +1,65 @@
"""Tests for web_server authentication codegen."""
from collections.abc import Callable
import pytest
from esphome.core import CORE
_DEFAULT_CHANGE_WARNING = "default will change to 'digest' in ESPHome 2027.1.0"
def _has_define(name: str) -> bool:
return any(d.name == name for d in CORE.defines)
def test_web_server_auth_default_is_basic_with_deprecation_warning(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth without an explicit type builds Basic and warns about the upcoming default change."""
main_cpp = generate_main(
"tests/component_tests/web_server/web_server_auth_default.yaml"
)
assert '->set_auth_username("admin");' in main_cpp
assert '->set_auth_password("password");' in main_cpp
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING in caplog.text
def test_web_server_auth_explicit_basic_no_warning(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth type basic builds Basic and does not warn."""
generate_main("tests/component_tests/web_server/web_server_auth_basic.yaml")
assert _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
def test_web_server_auth_explicit_digest(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Auth type digest builds Digest and does not warn."""
generate_main("tests/component_tests/web_server/web_server_auth_digest.yaml")
assert _has_define("USE_WEBSERVER_AUTH")
assert _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
def test_web_server_without_auth(
generate_main: Callable[[str], str],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Without an auth block, no auth is compiled in and no warning is emitted."""
generate_main("tests/component_tests/web_server/web_server_no_auth.yaml")
assert not _has_define("USE_WEBSERVER_AUTH")
assert not _has_define("USE_WEBSERVER_AUTH_DIGEST")
assert _DEFAULT_CHANGE_WARNING not in caplog.text
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: basic
@@ -0,0 +1,17 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
@@ -0,0 +1,18 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
auth:
username: admin
password: password
type: digest
@@ -0,0 +1,14 @@
---
esphome:
name: test
esp32:
board: nodemcu-32s
framework:
type: esp-idf
wifi:
ssid: MySSID
password: password1
web_server:
@@ -5,3 +5,4 @@ web_server:
auth:
username: admin
password: password
type: digest
@@ -1,2 +1,8 @@
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: digest
@@ -1,2 +1,8 @@
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: basic
@@ -0,0 +1,8 @@
packages:
web_server: !include common_v2.yaml
web_server:
auth:
username: admin
password: password
type: basic