mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 22:02:17 +01:00
feat!: the token on every route, Secure Config Only goes (step 4b)
Under Secured every HTTP route but the public ones needs the token (docs/adr/0006-two-security-modes-eleven-http-routes.md): - isPublicRoute, isWebAsset and originMatchesHost in src/HttpAccess.h, with native tests. - One middleware checks the token. It runs once the body is in, so /restore, /updateFirmware and /updateApplication check it on their first chunk and drop a refused upload. - Static serving answers the web UI's files only: shades.cfg and shades.tmp are no longer served. - The wildcard CORS headers go; WEB_CORS turns them back on for UI work from file://. - The socket refuses a handshake whose Origin doesn't name its Host. - "Secure Config Only" goes: a device set to it is Secured, and /loginContext drops permissions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
13ec38823b
commit
ecb0260f61
16 changed files with 392 additions and 54 deletions
|
|
@ -11,6 +11,7 @@
|
|||
#include "Web.h"
|
||||
#include "WebRoutes.h"
|
||||
#include "MqttConnection.h"
|
||||
#include "HttpAccess.h"
|
||||
#include <AsyncTCP.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <AsyncJson.h>
|
||||
|
|
@ -44,12 +45,18 @@ static void onUploadDisconnect() {
|
|||
rebootDelay.reboot = true;
|
||||
rebootDelay.rebootTime = millis() + 500;
|
||||
}
|
||||
// The token check (requireToken in Web.cpp) runs once the body is in, after
|
||||
// every chunk was handled, so an upload checks the token on its first chunk
|
||||
// and drops every chunk of a refused one.
|
||||
static bool uploadAllowed(AsyncWebServerRequest *request, size_t index) {
|
||||
if(index == 0) request->setAttribute("uploadAllowed", webServer.isAuthenticated(request));
|
||||
return request->getAttribute("uploadAllowed", false);
|
||||
}
|
||||
void registerHttpRoutes(AsyncWebServer &server) {
|
||||
server.on("/loginContext", HTTP_GET, [](AsyncWebServerRequest *request) {
|
||||
AsyncJsonResponse *response = new AsyncJsonResponse();
|
||||
JsonObject root = response->getRoot().to<JsonObject>();
|
||||
root["type"] = static_cast<uint8_t>(settings.Security.type);
|
||||
root["permissions"] = settings.Security.permissions;
|
||||
root["serverId"] = settings.serverId;
|
||||
root["version"] = settings.fwVersion.name;
|
||||
root["model"] = "ESPSomfyRTS";
|
||||
|
|
@ -122,6 +129,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
|
|||
else request->send(400, _encoding_json, F("{\"status\":\"ERROR\",\"desc\":\"The upload didn't complete, so nothing was restored.\"}"));
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
if(!uploadAllowed(request, index)) return;
|
||||
resetTaskWdt();
|
||||
if(index == 0) {
|
||||
webServer.uploadSuccess = false;
|
||||
|
|
@ -150,6 +158,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
|
|||
rebootDelay.rebootTime = millis() + 500;
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
if(!uploadAllowed(request, index)) return;
|
||||
if(index == 0) {
|
||||
webServer.uploadSuccess = false;
|
||||
ESP_LOGI(TAG, "Update: %s", filename.c_str());
|
||||
|
|
@ -191,6 +200,7 @@ void registerHttpRoutes(AsyncWebServer &server) {
|
|||
rebootDelay.rebootTime = millis() + 500;
|
||||
},
|
||||
[](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) {
|
||||
if(!uploadAllowed(request, index)) return;
|
||||
if(index == 0) {
|
||||
webServer.uploadSuccess = false;
|
||||
ESP_LOGI(TAG, "Update: %s", filename.c_str());
|
||||
|
|
@ -223,12 +233,10 @@ void registerHttpRoutes(AsyncWebServer &server) {
|
|||
resetTaskWdt();
|
||||
});
|
||||
|
||||
server.onNotFound([](AsyncWebServerRequest *r) {
|
||||
if(r->method() == HTTP_OPTIONS) { r->send(200); return; }
|
||||
webServer.handleNotFound(r);
|
||||
});
|
||||
server.onNotFound([](AsyncWebServerRequest *r) { webServer.handleNotFound(r); });
|
||||
|
||||
// serveStatic MUST be registered AFTER all route handlers so it doesn't shadow them
|
||||
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
|
||||
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html")
|
||||
.setFilter([](AsyncWebServerRequest *r) { return isWebAsset(r->url().c_str()); });
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
#include "outputs/ProtoSocket.h"
|
||||
#include "Timing.h"
|
||||
#include "LoginThrottle.h"
|
||||
#include "HttpAccess.h"
|
||||
#include <AsyncTCP.h>
|
||||
#include <ESPAsyncWebServer.h>
|
||||
#include <AsyncJson.h>
|
||||
|
|
@ -92,27 +93,16 @@ void Web::startup() {
|
|||
void Web::loop() {
|
||||
delay(1);
|
||||
}
|
||||
bool Web::isAuthenticated(AsyncWebServerRequest *request, bool cfg) {
|
||||
ESP_LOGD(TAG, "Checking async authentication");
|
||||
bool Web::tokenAccepted(AsyncWebServerRequest *request, const char *given) {
|
||||
if(settings.Security.type == security_types::None) return true;
|
||||
else if(!cfg && (settings.Security.permissions & static_cast<uint8_t>(security_permissions::ConfigOnly)) == 0x01) return true;
|
||||
else if(request->hasHeader("apikey")) {
|
||||
ESP_LOGD(TAG, "Checking API Key...");
|
||||
char token[API_TOKEN_SIZE];
|
||||
memset(token, 0x00, sizeof(token));
|
||||
this->createAPIToken(request->client()->remoteIP(), token);
|
||||
if(!tokenMatches(token, request->getHeader("apikey")->value().c_str())) {
|
||||
request->send(401, _encoding_text, "Unauthorized API Key");
|
||||
return false;
|
||||
}
|
||||
// Key is valid
|
||||
}
|
||||
else {
|
||||
ESP_LOGE(TAG, "Not authenticated...");
|
||||
request->send(401, _encoding_text, "Unauthorized API Key");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
if(!given || !request->client()) return false;
|
||||
char token[API_TOKEN_SIZE];
|
||||
this->createAPIToken(request->client()->remoteIP(), token);
|
||||
return tokenMatches(token, given);
|
||||
}
|
||||
bool Web::isAuthenticated(AsyncWebServerRequest *request) {
|
||||
const AsyncWebHeader *key = request->getHeader("apikey");
|
||||
return this->tokenAccepted(request, key ? key->value().c_str() : nullptr);
|
||||
}
|
||||
bool Web::createAPIPinToken(const IPAddress ipAddress, const char *pin, char *token) {
|
||||
return this->createAPIToken((String(pin) + ":" + ipAddress.toString()).c_str(), token);
|
||||
|
|
@ -240,7 +230,6 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
|
|||
}
|
||||
void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
shade_request_t req;
|
||||
req.type = shade_request_type_t::shade_command;
|
||||
// Try query params
|
||||
|
|
@ -279,7 +268,6 @@ void Web::handleShadeCommand(AsyncWebServerRequest *request, JsonVariant &json)
|
|||
}
|
||||
void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
shade_request_t req;
|
||||
req.type = shade_request_type_t::group_command;
|
||||
if(asyncHasParam(request, "groupId")) {
|
||||
|
|
@ -315,7 +303,6 @@ void Web::handleGroupCommand(AsyncWebServerRequest *request, JsonVariant &json)
|
|||
}
|
||||
void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
|
||||
if(request->method() == HTTP_OPTIONS) { request->send(200); return; }
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
shade_request_t req;
|
||||
req.type = shade_request_type_t::tilt_command;
|
||||
if(asyncHasParam(request, "shadeId")) {
|
||||
|
|
@ -348,7 +335,6 @@ void Web::handleTiltCommand(AsyncWebServerRequest *request, JsonVariant &json) {
|
|||
}
|
||||
}
|
||||
void Web::handleBackup(AsyncWebServerRequest *request) {
|
||||
if(!this->isAuthenticated(request)) return;
|
||||
bool attach = false;
|
||||
if(asyncHasParam(request, "attach")) attach = toBoolean(asyncParam(request, "attach").c_str(), false);
|
||||
ESP_LOGI(TAG, "Async saving current shade information");
|
||||
|
|
@ -368,7 +354,9 @@ void Web::handleBackup(AsyncWebServerRequest *request) {
|
|||
snprintf(filename, sizeof(filename), "attachment; filename=\"ESPSomfyRTS %s.backup\"", iso);
|
||||
AsyncWebServerResponse *response = request->beginResponse(200, _encoding_text, somfy.backupData);
|
||||
response->addHeader("Content-Disposition", filename);
|
||||
#ifdef WEB_CORS
|
||||
response->addHeader("Access-Control-Expose-Headers", "Content-Disposition");
|
||||
#endif
|
||||
request->send(response);
|
||||
}
|
||||
else {
|
||||
|
|
@ -404,13 +392,29 @@ static void timeRequest(AsyncWebServerRequest *request, ArMiddlewareNext next) {
|
|||
next();
|
||||
logRequestTime(request, ip, "handler", timer.us());
|
||||
}
|
||||
// Under Secured every route but the public ones needs the token. This runs
|
||||
// once the body is in, after an upload's chunks, so the uploads check the
|
||||
// token themselves as well (uploadAllowed in HttpRoutes.cpp).
|
||||
static void requireToken(AsyncWebServerRequest *request, ArMiddlewareNext next) {
|
||||
if(isPublicRoute(request->url().c_str(), request->method() == HTTP_OPTIONS) || webServer.isAuthenticated(request)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
request->send(401, _encoding_text, "Unauthorized API Key");
|
||||
}
|
||||
|
||||
void Web::begin() {
|
||||
ESP_LOGI(TAG, "Creating Web MicroServices...");
|
||||
asyncServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { timeRequest(r, next); });
|
||||
asyncServer.addMiddleware([](AsyncWebServerRequest *r, ArMiddlewareNext next) { requireToken(r, next); });
|
||||
#ifdef WEB_CORS
|
||||
// For work on the UI from a file:// page (baseUrl in index.js). A release
|
||||
// leaves them out: the UI comes from the device, and HA and scripts aren't
|
||||
// browsers.
|
||||
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
|
||||
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Methods", "PUT,POST,GET,OPTIONS");
|
||||
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "*");
|
||||
#endif
|
||||
// Before the routes, so serveStatic never answers /ws.
|
||||
protoSocket.begin(asyncServer);
|
||||
registerHttpRoutes(asyncServer);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ class Web {
|
|||
bool createAPIToken(const char *payload, char *token);
|
||||
bool createAPIPinToken(const IPAddress ipAddress, const char *pin, char *token);
|
||||
bool createAPIPasswordToken(const IPAddress ipAddress, const char *username, const char *password, char *token);
|
||||
bool isAuthenticated(AsyncWebServerRequest *request, bool cfg = false);
|
||||
// True under None, or when given is the token for the request's client.
|
||||
bool tokenAccepted(AsyncWebServerRequest *request, const char *given);
|
||||
// tokenAccepted with the apikey header, which is where HTTP sends it.
|
||||
bool isAuthenticated(AsyncWebServerRequest *request);
|
||||
|
||||
// HTTP handlers
|
||||
void handleLogin(AsyncWebServerRequest *request, JsonVariant &json);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue