diff --git a/lib_standalone/SecurityManager.h b/lib_standalone/SecurityManager.h index 72fa9d6ce..d1c84743d 100644 --- a/lib_standalone/SecurityManager.h +++ b/lib_standalone/SecurityManager.h @@ -5,10 +5,10 @@ #include "ESPAsyncWebServer.h" #include "AsyncJson.h" -#include +#include #define FACTORY_JWT_SECRET "ems-esp" -#define ACCESS_TOKEN_PARAMATER "access_token" +#define ACCESS_TOKEN_PARAMETER "access_token" #define AUTHORIZATION_HEADER "Authorization" #define AUTHORIZATION_HEADER_PREFIX "Bearer " #define AUTHORIZATION_HEADER_PREFIX_LEN 7 @@ -31,21 +31,15 @@ class User { class Authentication { public: - User * user; - boolean authenticated; + std::unique_ptr user; + boolean authenticated = false; public: - Authentication(User & user) - : user(new User(user)) + explicit Authentication(const User & u) + : user(std::make_unique(u)) , authenticated(true) { } - Authentication() - : user(nullptr) - , authenticated(false) { - } - ~Authentication() { - delete (user); - } + Authentication() = default; }; typedef std::function AuthenticationPredicate; @@ -65,11 +59,9 @@ class AuthenticationPredicates { class SecurityManager { public: - virtual Authentication authenticateRequest(AsyncWebServerRequest * request) = 0; - virtual ArRequestFilterFunction filterRequest(AuthenticationPredicate predicate) = 0; - virtual ArRequestHandlerFunction wrapRequest(ArRequestHandlerFunction onRequest, AuthenticationPredicate predicate) = 0; - virtual ArJsonRequestHandlerFunction wrapCallback(ArJsonRequestHandlerFunction onRequest, AuthenticationPredicate predicate) = 0; + virtual Authentication authenticateRequest(AsyncWebServerRequest * request) = 0; + // Json endpoints - default POST. Registered with the shared dispatcher. void addEndpoint(AsyncWebServer * server, const String & path, AuthenticationPredicate predicate, diff --git a/lib_standalone/SecuritySettingsService.cpp b/lib_standalone/SecuritySettingsService.cpp index f94b7046c..7d072c4bc 100644 --- a/lib_standalone/SecuritySettingsService.cpp +++ b/lib_standalone/SecuritySettingsService.cpp @@ -10,22 +10,9 @@ SecuritySettingsService::SecuritySettingsService(AsyncWebServer * server, FS * f SecuritySettingsService::~SecuritySettingsService() { } -ArRequestFilterFunction SecuritySettingsService::filterRequest(AuthenticationPredicate predicate) { - return [predicate](AsyncWebServerRequest * request) { return true; }; -} - -// Return the admin user on all request - disabling security features +// Return the admin user on all requests - disabling security features Authentication SecuritySettingsService::authenticateRequest(AsyncWebServerRequest * request) { return Authentication(ADMIN_USER); } -// Return the function unwrapped -ArRequestHandlerFunction SecuritySettingsService::wrapRequest(ArRequestHandlerFunction onRequest, AuthenticationPredicate predicate) { - return onRequest; -} - -ArJsonRequestHandlerFunction SecuritySettingsService::wrapCallback(ArJsonRequestHandlerFunction onRequest, AuthenticationPredicate predicate) { - return onRequest; -} - #endif \ No newline at end of file diff --git a/lib_standalone/SecuritySettingsService.h b/lib_standalone/SecuritySettingsService.h index 79d7e5adf..c355a8ec1 100644 --- a/lib_standalone/SecuritySettingsService.h +++ b/lib_standalone/SecuritySettingsService.h @@ -30,10 +30,7 @@ class SecuritySettingsService : public SecurityManager { ~SecuritySettingsService(); // minimal set of functions to support framework with security settings disabled - Authentication authenticateRequest(AsyncWebServerRequest * request); - ArRequestFilterFunction filterRequest(AuthenticationPredicate predicate); - ArRequestHandlerFunction wrapRequest(ArRequestHandlerFunction onRequest, AuthenticationPredicate predicate); - ArJsonRequestHandlerFunction wrapCallback(ArJsonRequestHandlerFunction onRequest, AuthenticationPredicate predicate); + Authentication authenticateRequest(AsyncWebServerRequest * request); }; #endif diff --git a/src/ESP32React/AuthenticationService.cpp b/src/ESP32React/AuthenticationService.cpp index c4c02ec6b..01eb34793 100644 --- a/src/ESP32React/AuthenticationService.cpp +++ b/src/ESP32React/AuthenticationService.cpp @@ -4,11 +4,13 @@ AuthenticationService::AuthenticationService(AsyncWebServer * server, SecurityManager * securityManager) : _securityManager(securityManager) { - // none of these need authentication - server->on(VERIFY_AUTHORIZATION_PATH, HTTP_GET, [this](AsyncWebServerRequest * request) { verifyAuthorization(request); }); - auto * handler = new AsyncCallbackJsonWebHandler(SIGN_IN_PATH); - handler->onRequest([this](AsyncWebServerRequest * request, JsonVariant json) { signIn(request, json); }); - server->addHandler(handler); + // None of these need authentication: verifyAuthorization checks the JWT itself, and signIn IS the authentication flow. + securityManager->addEndpoint(server, VERIFY_AUTHORIZATION_PATH, AuthenticationPredicates::NONE_REQUIRED, [this](AsyncWebServerRequest * request) { + verifyAuthorization(request); + }); + securityManager->addEndpoint(server, SIGN_IN_PATH, AuthenticationPredicates::NONE_REQUIRED, [this](AsyncWebServerRequest * request, JsonVariant json) { + signIn(request, json); + }); } // Verifies that the request supplied a valid JWT. @@ -24,10 +26,9 @@ void AuthenticationService::signIn(AsyncWebServerRequest * request, JsonVariant String password = json["password"]; Authentication authentication = _securityManager->authenticate(username, password); if (authentication.authenticated) { - User * user = authentication.user; auto * response = new emsesp::PsramAsyncJsonResponse(false); JsonObject jsonObject = response->getRoot(); - jsonObject["access_token"] = _securityManager->generateJWT(user); + jsonObject["access_token"] = _securityManager->generateJWT(authentication.user.get()); response->setLength(); request->send(response); return; diff --git a/src/ESP32React/SecurityManager.cpp b/src/ESP32React/SecurityManager.cpp new file mode 100644 index 000000000..d190cecf0 --- /dev/null +++ b/src/ESP32React/SecurityManager.cpp @@ -0,0 +1,126 @@ +#include "SecurityManager.h" + +void SecurityManager::addEndpoint(AsyncWebServer * server, + const String & path, + AuthenticationPredicate predicate, + ArJsonRequestHandlerFunction function, + WebRequestMethodComposite method) { + ensureRestDispatcher(server); + const bool requiresAuth = !isPublicPredicate(predicate); + _restRoutes.push_back({AsyncURIMatcher(path), method, std::move(predicate), {}, std::move(function), true, requiresAuth}); +} + +void SecurityManager::addEndpoint(AsyncWebServer * server, + const String & path, + AuthenticationPredicate predicate, + ArRequestHandlerFunction function, + WebRequestMethodComposite method) { + ensureRestDispatcher(server); + const bool requiresAuth = !isPublicPredicate(predicate); + _restRoutes.push_back({AsyncURIMatcher(path), method, std::move(predicate), std::move(function), {}, false, requiresAuth}); +} + +// Detects routes registered with AuthenticationPredicates::NONE_REQUIRED so we can +// skip the JWT parse in dispatchRest. Relies on std::function::target returning the +// raw function pointer when the predicate was assigned directly from the static fn; +// if a caller wraps NONE_REQUIRED in a lambda this falls back to "requires auth" +// which is correctness-preserving (just no optimization). +bool SecurityManager::isPublicPredicate(const AuthenticationPredicate & predicate) { + using Fn = bool (*)(const Authentication &); + auto * fn = predicate.target(); + return fn != nullptr && *fn == &AuthenticationPredicates::NONE_REQUIRED; +} + +// Lazily attach the single catch-all handler. Idempotent. +void SecurityManager::ensureRestDispatcher(AsyncWebServer * server) { + if (_restDispatcherInstalled || server == nullptr) { + return; + } + _restDispatcherInstalled = true; + server->addHandler(new RestCatchAllHandler(this)); +} + +void SecurityManager::dispatchRest(AsyncWebServerRequest * request, JsonVariant json) { + WebRequestMethod method = request->method(); + + for (auto & route : _restRoutes) { + if (!route.method.matches(method)) { + continue; + } + if (!route.uri.matches(request)) { + continue; + } + + if (route.requiresAuth) { + Authentication authentication = authenticateRequest(request); + if (!route.predicate(authentication)) { + request->send(401); + return; + } + } + + if (route.isJson) { + route.jsonHandler(request, json); + } else { + route.plainHandler(request); + } + return; + } + + // canHandle returned true so some route matched the URI+method; + // a mismatch here means the request slipped between the two checks. + request->send(404); +} + +// ---- RestCatchAllHandler ---- + +bool SecurityManager::RestCatchAllHandler::canHandle(AsyncWebServerRequest * request) const { + if (!request->isHTTP()) { + return false; + } + for (const auto & route : _owner->_restRoutes) { + if (route.method.matches(request->method()) && route.uri.matches(request)) { + return true; + } + } + return false; +} + +// Returning false ensures the server invokes handleBody() so we can buffer JSON bodies. +bool SecurityManager::RestCatchAllHandler::isRequestHandlerTrivial() const { + return false; +} + +void SecurityManager::RestCatchAllHandler::handleBody(AsyncWebServerRequest * request, uint8_t * data, size_t len, size_t index, size_t total) { + // Only buffer JSON bodies; everything else is routed with an empty JsonVariant. + if (total == 0 || total > kMaxBodySize || !isJsonContent(request)) { + return; + } + if (index == 0 && request->_tempObject == nullptr) { + request->_tempObject = calloc(total + 1, sizeof(uint8_t)); // freed by request destructor + if (request->_tempObject == nullptr) { + request->abort(); + return; + } + } + if (request->_tempObject != nullptr) { + memcpy(static_cast(request->_tempObject) + index, data, len); + } +} + +void SecurityManager::RestCatchAllHandler::handleRequest(AsyncWebServerRequest * request) { + JsonDocument doc; + JsonVariant json; + if (request->_tempObject != nullptr) { + if (deserializeJson(doc, static_cast(request->_tempObject))) { + request->send(400); + return; + } + json = doc.as(); + } + _owner->dispatchRest(request, json); +} + +bool SecurityManager::RestCatchAllHandler::isJsonContent(AsyncWebServerRequest * request) { + return request->contentType().equalsIgnoreCase("application/json"); +} diff --git a/src/ESP32React/SecurityManager.h b/src/ESP32React/SecurityManager.h index b4dc89bb8..7c8efc831 100644 --- a/src/ESP32React/SecurityManager.h +++ b/src/ESP32React/SecurityManager.h @@ -6,9 +6,10 @@ #include #include -#include +#include +#include -#define ACCESS_TOKEN_PARAMATER "access_token" +#define ACCESS_TOKEN_PARAMETER "access_token" #define AUTHORIZATION_HEADER "Authorization" #define AUTHORIZATION_HEADER_PREFIX "Bearer " #define AUTHORIZATION_HEADER_PREFIX_LEN 7 @@ -29,20 +30,16 @@ class User { class Authentication { public: - User * user = nullptr; - boolean authenticated = false; + std::unique_ptr user; + boolean authenticated = false; public: - explicit Authentication(const User & user) - : user(new User(user)) + explicit Authentication(const User & u) + : user(std::make_unique(u)) , authenticated(true) { } Authentication() = default; - - ~Authentication() { - delete user; - } }; typedef std::function AuthenticationPredicate; @@ -63,6 +60,8 @@ class AuthenticationPredicates { class SecurityManager { public: + virtual ~SecurityManager() = default; + // Authenticate, returning the user if found virtual Authentication authenticate(const String & username, const String & password) = 0; @@ -72,40 +71,70 @@ class SecurityManager { // Check the request header for the Authorization token virtual Authentication authenticateRequest(AsyncWebServerRequest * request) = 0; - // Filter a request with the provided predicate, only returning true if the predicate matches. - virtual ArRequestFilterFunction filterRequest(AuthenticationPredicate predicate) = 0; - - // Wrap the provided request to provide validation against an AuthenticationPredicate. - virtual ArRequestHandlerFunction wrapRequest(ArRequestHandlerFunction onRequest, AuthenticationPredicate predicate) = 0; - - // Wrap the provided json request callback to provide validation against an AuthenticationPredicate. - virtual ArJsonRequestHandlerFunction wrapCallback(ArJsonRequestHandlerFunction onRequest, AuthenticationPredicate predicate) = 0; - - // Json endpoints - default POST + // Json endpoints - default POST. Registered with the shared dispatcher. void addEndpoint(AsyncWebServer * server, const String & path, AuthenticationPredicate predicate, ArJsonRequestHandlerFunction function, - WebRequestMethodComposite method = HTTP_POST) { - auto handler = new AsyncCallbackJsonWebHandler(path); - handler->onRequest( - wrapCallback([this, function](AsyncWebServerRequest * request, JsonVariant json) { function(request, json); }, AuthenticationPredicates::IS_ADMIN)); - handler->setMethod(method); - server->addHandler(handler); - } + WebRequestMethodComposite method = HTTP_POST); - // non-Json endpoints - default GET + // non-Json endpoints - default GET. Registered with the shared dispatcher. void addEndpoint(AsyncWebServer * server, const String & path, AuthenticationPredicate predicate, ArRequestHandlerFunction function, - WebRequestMethodComposite method = HTTP_GET) { - auto * handler = new AsyncCallbackWebHandler(); - handler->onRequest(wrapRequest([this, function](AsyncWebServerRequest * request) { function(request); }, predicate)); - handler->setUri(path); - handler->setMethod(method); - server->addHandler(handler); - } + WebRequestMethodComposite method = HTTP_GET); + + private: + // Single internal route record. Each route holds either a plain or JSON handler. + // The URI matcher uses backward-compatible mode by default (constructed from a + // plain String), which preserves the original library handler's matching semantics + // (e.g. /api also matches /api/boiler/heating). + struct RestRoute { + AsyncURIMatcher uri; + WebRequestMethodComposite method; + AuthenticationPredicate predicate; + ArRequestHandlerFunction plainHandler; + ArJsonRequestHandlerFunction jsonHandler; + bool isJson; + bool requiresAuth; // false when predicate is NONE_REQUIRED, lets dispatchRest skip the JWT parse + }; + + // Single catch-all handler. canHandle() claims a request only if some registered + // route matches its URI + method, so non-matching URLs (static files, websockets, + // etc.) still fall through to other handlers. handleBody buffers JSON bodies, then + // handleRequest hands off to SecurityManager::dispatchRest for routing + auth. + // + // We can't reuse AsyncCallbackJsonWebHandler here because its canHandle() rejects + // POST/PUT/PATCH without an application/json content-type (so a bodyless POST like + // /rest/resetCustomizations would fall through to a 404), and both canHandle and + // handleRequest are marked final on that class. + class RestCatchAllHandler : public AsyncWebHandler { + public: + explicit RestCatchAllHandler(SecurityManager * owner) + : _owner(owner) { + } + + bool canHandle(AsyncWebServerRequest * request) const override; + bool isRequestHandlerTrivial() const override; + void handleBody(AsyncWebServerRequest * request, uint8_t * data, size_t len, size_t index, size_t total) override; + void handleRequest(AsyncWebServerRequest * request) override; + + private: + static constexpr size_t kMaxBodySize = 16384; + + static bool isJsonContent(AsyncWebServerRequest * request); + + SecurityManager * _owner; + }; + + static bool isPublicPredicate(const AuthenticationPredicate & predicate); + + void ensureRestDispatcher(AsyncWebServer * server); + void dispatchRest(AsyncWebServerRequest * request, JsonVariant json); + + std::vector _restRoutes; + bool _restDispatcherInstalled = false; }; #endif diff --git a/src/ESP32React/SecuritySettingsService.cpp b/src/ESP32React/SecuritySettingsService.cpp index c88b8e88c..6250b7ad2 100644 --- a/src/ESP32React/SecuritySettingsService.cpp +++ b/src/ESP32React/SecuritySettingsService.cpp @@ -8,9 +8,7 @@ SecuritySettingsService::SecuritySettingsService(AsyncWebServer * server, FS * f , _jwtHandler(FACTORY_JWT_SECRET) { addUpdateHandler([this] { configureJWTHandler(); }, false); - server->on(GENERATE_TOKEN_PATH, - HTTP_GET, - SecuritySettingsService::wrapRequest([this](AsyncWebServerRequest * request) { generateToken(request); }, AuthenticationPredicates::IS_ADMIN)); + addEndpoint(server, GENERATE_TOKEN_PATH, AuthenticationPredicates::IS_ADMIN, [this](AsyncWebServerRequest * request) { generateToken(request); }); } void SecuritySettingsService::begin() { @@ -26,8 +24,8 @@ Authentication SecuritySettingsService::authenticateRequest(AsyncWebServerReques value = value.substring(AUTHORIZATION_HEADER_PREFIX_LEN); return authenticateJWT(value); } - } else if (request->hasParam(ACCESS_TOKEN_PARAMATER)) { - auto tokenParamater = request->getParam(ACCESS_TOKEN_PARAMATER); + } else if (request->hasParam(ACCESS_TOKEN_PARAMETER)) { + auto tokenParamater = request->getParam(ACCESS_TOKEN_PARAMETER); String value = tokenParamater->value(); return authenticateJWT(value); } @@ -81,35 +79,6 @@ String SecuritySettingsService::generateJWT(const User * user) { return _jwtHandler.buildJWT(payload); } -ArRequestFilterFunction SecuritySettingsService::filterRequest(AuthenticationPredicate predicate) { - return [this, predicate](AsyncWebServerRequest * request) { - Authentication authentication = authenticateRequest(request); - return predicate(authentication); - }; -} - -ArRequestHandlerFunction SecuritySettingsService::wrapRequest(ArRequestHandlerFunction onRequest, AuthenticationPredicate predicate) { - return [this, onRequest, predicate](AsyncWebServerRequest * request) { - Authentication authentication = authenticateRequest(request); - if (!predicate(authentication)) { - request->send(401); - return; - } - onRequest(request); - }; -} - -ArJsonRequestHandlerFunction SecuritySettingsService::wrapCallback(ArJsonRequestHandlerFunction onRequest, AuthenticationPredicate predicate) { - return [this, onRequest, predicate](AsyncWebServerRequest * request, JsonVariant json) { - Authentication authentication = authenticateRequest(request); - if (!predicate(authentication)) { - request->send(401); - return; - } - onRequest(request, json); - }; -} - void SecuritySettingsService::generateToken(AsyncWebServerRequest * request) { auto usernameParam = request->getParam("username"); for (const User & _user : _state.users) { diff --git a/src/ESP32React/SecuritySettingsService.h b/src/ESP32React/SecuritySettingsService.h index c356e5d61..8455a93c9 100644 --- a/src/ESP32React/SecuritySettingsService.h +++ b/src/ESP32React/SecuritySettingsService.h @@ -72,12 +72,9 @@ class SecuritySettingsService final : public StatefulService, void begin(); - Authentication authenticate(const String & username, const String & password) override; - Authentication authenticateRequest(AsyncWebServerRequest * request) override; - String generateJWT(const User * user) override; - ArRequestFilterFunction filterRequest(AuthenticationPredicate predicate) override; - ArRequestHandlerFunction wrapRequest(ArRequestHandlerFunction onRequest, AuthenticationPredicate predicate) override; - ArJsonRequestHandlerFunction wrapCallback(ArJsonRequestHandlerFunction callback, AuthenticationPredicate predicate) override; + Authentication authenticate(const String & username, const String & password) override; + Authentication authenticateRequest(AsyncWebServerRequest * request) override; + String generateJWT(const User * user) override; private: HttpEndpoint _httpEndpoint; diff --git a/src/web/WebAPIService.cpp b/src/web/WebAPIService.cpp index 4ffbf5d3b..e9373e3c4 100644 --- a/src/web/WebAPIService.cpp +++ b/src/web/WebAPIService.cpp @@ -25,10 +25,14 @@ uint16_t WebAPIService::api_fails_ = 0; WebAPIService::WebAPIService(AsyncWebServer * server, SecurityManager * securityManager) : _securityManager(securityManager) { - AsyncCallbackJsonWebHandler * jsonHandler = new AsyncCallbackJsonWebHandler(EMSESP_API_SERVICE_PATH); - jsonHandler->setMethod(HTTP_POST | HTTP_GET); - jsonHandler->onRequest([this](AsyncWebServerRequest * request, JsonVariant json) { webAPIService(request, json); }); - server->addHandler(jsonHandler); + // parse() does its own per-request admin check (with notoken_api), so no predicate. + // /api also matches /api// via the route's backward-compatible URI matcher. + securityManager->addEndpoint( + server, + EMSESP_API_SERVICE_PATH, + AuthenticationPredicates::NONE_REQUIRED, + [this](AsyncWebServerRequest * request, JsonVariant json) { webAPIService(request, json); }, + HTTP_POST | HTTP_GET); } // POST|GET api/ diff --git a/src/web/WebLogService.cpp b/src/web/WebLogService.cpp index 310e744e6..c61db310e 100644 --- a/src/web/WebLogService.cpp +++ b/src/web/WebLogService.cpp @@ -32,9 +32,6 @@ WebLogService::WebLogService(AsyncWebServer * server, SecurityManager * security [this](AsyncWebServerRequest * request, JsonVariant json) { getSetValues(request, json); }, HTTP_ANY); - // Add authentication filter to EventSource - // EventSource (SSE) cannot use custom headers, so authentication is done via URL parameter - // events_.setFilter(securityManager->filterRequest(AuthenticationPredicates::IS_AUTHENTICATED)); server->addHandler(&events_); } @@ -211,6 +208,7 @@ void WebLogService::getSetValues(AsyncWebServerRequest * request, JsonVariant js return; } + // POST - write the settings level_ = json["level"]; maximum_log_messages_ = json["max_messages"]; @@ -234,6 +232,7 @@ void WebLogService::getSetValues(AsyncWebServerRequest * request, JsonVariant js settings.weblog_buffer = maximum_log_messages_; return StateUpdateResult::CHANGED; }); + request->send(200); // OK }