diff --git a/include/Configuration.h b/include/Configuration.h index 75c6c702..c034a30d 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -31,6 +31,8 @@ #define POWERMETER_MAX_PHASES 3 #define POWERMETER_MAX_HTTP_URL_STRLEN 1024 +#define POWERMETER_MAX_USERNAME_STRLEN 64 +#define POWERMETER_MAX_PASSWORD_STRLEN 64 #define POWERMETER_MAX_HTTP_HEADER_KEY_STRLEN 64 #define POWERMETER_MAX_HTTP_HEADER_VALUE_STRLEN 256 #define POWERMETER_MAX_HTTP_JSON_PATH_STRLEN 256 @@ -55,9 +57,13 @@ struct INVERTER_CONFIG_T { CHANNEL_CONFIG_T channel[INV_MAX_CHAN_COUNT]; }; +enum Auth { none, basic, digest }; struct POWERMETER_HTTP_PHASE_CONFIG_T { bool Enabled; char Url[POWERMETER_MAX_HTTP_URL_STRLEN + 1]; + Auth AuthType; + char Username[POWERMETER_MAX_USERNAME_STRLEN +1]; + char Password[POWERMETER_MAX_USERNAME_STRLEN +1]; char HeaderKey[POWERMETER_MAX_HTTP_HEADER_KEY_STRLEN + 1]; char HeaderValue[POWERMETER_MAX_HTTP_HEADER_VALUE_STRLEN + 1]; uint16_t Timeout; diff --git a/include/HttpPowerMeter.h b/include/HttpPowerMeter.h index d8010154..fde68f6b 100644 --- a/include/HttpPowerMeter.h +++ b/include/HttpPowerMeter.h @@ -8,12 +8,14 @@ public: void init(); bool updateValues(); float getPower(int8_t phase); - bool httpRequest(const char* url, const char* httpHeader, const char* httpValue, uint32_t timeout, + bool httpRequest(const char* url, Auth authType, const char* username, const char* password, const char* httpHeader, const char* httpValue, uint32_t timeout, char* response, size_t responseSize, char* error, size_t errorSize); float getFloatValueByJsonPath(const char* jsonString, const char* jsonPath, float &value); private: float power[POWERMETER_MAX_PHASES]; + void extractUrlComponents(const String& url, String& protocol, String& hostname, String& uri); + String sha256(const String& data); }; extern HttpPowerMeterClass HttpPowerMeter; diff --git a/platformio.ini b/platformio.ini index 2ad3a0bc..10e99c6b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -40,6 +40,7 @@ lib_deps = https://github.com/coryjfowler/MCP_CAN_lib plerup/EspSoftwareSerial@^8.0.1 mobizt/FirebaseJson @ ^3.0.6 + rweather/Crypto@^0.4.0 extra_scripts = pre:pio-scripts/auto_firmware_version.py diff --git a/src/Configuration.cpp b/src/Configuration.cpp index e078e749..4f04f990 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -140,6 +140,9 @@ bool ConfigurationClass::write() powermeter_phase["enabled"] = config.Powermeter_Http_Phase[i].Enabled; powermeter_phase["url"] = config.Powermeter_Http_Phase[i].Url; + powermeter_phase["auth_type"] = config.Powermeter_Http_Phase[i].AuthType; + powermeter_phase["username"] = config.Powermeter_Http_Phase[i].Username; + powermeter_phase["password"] = config.Powermeter_Http_Phase[i].Password; powermeter_phase["header_key"] = config.Powermeter_Http_Phase[i].HeaderKey; powermeter_phase["header_value"] = config.Powermeter_Http_Phase[i].HeaderValue; powermeter_phase["timeout"] = config.Powermeter_Http_Phase[i].Timeout; @@ -346,6 +349,9 @@ bool ConfigurationClass::read() config.Powermeter_Http_Phase[i].Enabled = powermeter_phase["enabled"] | (i == 0); strlcpy(config.Powermeter_Http_Phase[i].Url, powermeter_phase["url"] | "", sizeof(config.Powermeter_Http_Phase[i].Url)); + config.Powermeter_Http_Phase[i].AuthType = powermeter_phase["auth_type"] | Auth::none; + strlcpy(config.Powermeter_Http_Phase[i].Username, powermeter_phase["username"] | "", sizeof(config.Powermeter_Http_Phase[i].Username)); + strlcpy(config.Powermeter_Http_Phase[i].Password, powermeter_phase["password"] | "", sizeof(config.Powermeter_Http_Phase[i].Password)); strlcpy(config.Powermeter_Http_Phase[i].HeaderKey, powermeter_phase["header_key"] | "", sizeof(config.Powermeter_Http_Phase[i].HeaderKey)); strlcpy(config.Powermeter_Http_Phase[i].HeaderValue, powermeter_phase["header_value"] | "", sizeof(config.Powermeter_Http_Phase[i].HeaderValue)); config.Powermeter_Http_Phase[i].Timeout = powermeter_phase["timeout"] | POWERMETER_HTTP_TIMEOUT; diff --git a/src/HttpPowerMeter.cpp b/src/HttpPowerMeter.cpp index 5bd28d3b..a8972627 100644 --- a/src/HttpPowerMeter.cpp +++ b/src/HttpPowerMeter.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include void HttpPowerMeterClass::init() { @@ -32,7 +34,7 @@ bool HttpPowerMeterClass::updateValues() } if (i == 0 || config.PowerMeter_HttpIndividualRequests) { - if (!httpRequest(phaseConfig.Url, phaseConfig.HeaderKey, phaseConfig.HeaderValue, phaseConfig.Timeout, + if (!httpRequest(phaseConfig.Url, phaseConfig.AuthType, phaseConfig.Username, phaseConfig.Password, phaseConfig.HeaderKey, phaseConfig.HeaderValue, phaseConfig.Timeout, response, sizeof(response), errorMessage, sizeof(errorMessage))) { MessageOutput.printf("[HttpPowerMeter] Getting the power of phase %d failed. Error: %s\r\n", i + 1, errorMessage); @@ -49,24 +51,41 @@ bool HttpPowerMeterClass::updateValues() return success; } -bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, const char* httpValue, uint32_t timeout, +bool HttpPowerMeterClass::httpRequest(const char* url, Auth authType, const char* username, const char* password, const char* httpHeader, const char* httpValue, uint32_t timeout, char* response, size_t responseSize, char* error, size_t errorSize) { WiFiClient* wifiClient = NULL; HTTPClient httpClient; + String newUrl = url; + String urlProtocol; + String urlHostname; + String urlUri; + extractUrlComponents(url, urlProtocol, urlHostname, urlUri); + response[0] = '\0'; error[0] = '\0'; - if (String(url).substring(0, 6) == "https:") { + if (authType == Auth::basic) { + newUrl = urlProtocol; + newUrl += "://"; + newUrl += username; + newUrl += ":"; + newUrl += password; + newUrl += "@"; + newUrl += urlHostname; + newUrl += urlUri; + } + + if (urlProtocol == "https") { wifiClient = new WiFiClientSecure; reinterpret_cast(wifiClient)->setInsecure(); } else { wifiClient = new WiFiClient; } - if (!httpClient.begin(*wifiClient, url)) { - snprintf_P(error, errorSize, "httpClient.begin failed"); + if (!httpClient.begin(*wifiClient, newUrl)) { + snprintf_P(error, errorSize, "httpClient.begin(%s) failed", newUrl.c_str()); delete wifiClient; return false; } @@ -82,8 +101,67 @@ bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, c httpClient.addHeader(httpHeader, httpValue); } + if (authType == Auth::digest) { + const char *headers[1] = {"WWW-Authenticate"}; + httpClient.collectHeaders(headers, 1); + } + int httpCode = httpClient.GET(); + if (httpCode == HTTP_CODE_UNAUTHORIZED && authType == Auth::digest) { + // Handle authentication challenge + char realm[256]; // Buffer to store the realm received from the server + char nonce[256]; // Buffer to store the nonce received from the server + if (httpClient.hasHeader("WWW-Authenticate")) { + String authHeader = httpClient.header("WWW-Authenticate"); + if (authHeader.indexOf("Digest") != -1) { + int realmIndex = authHeader.indexOf("realm=\""); + int nonceIndex = authHeader.indexOf("nonce=\""); + if (realmIndex != -1 && nonceIndex != -1) { + int realmEndIndex = authHeader.indexOf("\"", realmIndex + 7); + int nonceEndIndex = authHeader.indexOf("\"", nonceIndex + 7); + if (realmEndIndex != -1 && nonceEndIndex != -1) { + authHeader.substring(realmIndex + 7, realmEndIndex).toCharArray(realm, sizeof(realm)); + authHeader.substring(nonceIndex + 7, nonceEndIndex).toCharArray(nonce, sizeof(nonce)); + } + } + String cnonce = String(random(1000)); // Generate client nonce + String str = username; + str += ":"; + str += realm; + str += ":"; + str += password; + String ha1 = sha256(str); + str = "GET:"; + str += urlUri; + String ha2 = sha256(str); + str = ha1; + str += ":"; + str += nonce; + str += ":00000001:"; + str += cnonce; + str += ":auth:"; + str += ha2; + String response = sha256(str); + + String authorization = "Digest username=\""; + authorization += username; + authorization += "\", realm=\""; + authorization += realm; + authorization += "\", nonce=\""; + authorization += nonce; + authorization += "\", uri=\""; + authorization += urlUri; + authorization += "\", cnonce=\""; + authorization += cnonce; + authorization += "\", nc=00000001, qop=auth, response=\""; + authorization += response; + authorization += "\", algorithm=SHA-256"; + httpClient.addHeader("Authorization", authorization); + httpCode = httpClient.GET(); + } + } + } if (httpCode == HTTP_CODE_OK) { String responseBody = httpClient.getString(); @@ -95,7 +173,7 @@ bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, c snprintf(response, responseSize, responseBody.c_str()); } } else if (httpCode <= 0) { - snprintf_P(error, errorSize, "Error: %s", httpClient.errorToString(httpCode).c_str()); + snprintf_P(error, errorSize, "Error(%s): %s", newUrl.c_str(), httpClient.errorToString(httpCode).c_str()); } else if (httpCode != HTTP_CODE_OK) { snprintf_P(error, errorSize, "Bad HTTP code: %d", httpCode); } @@ -127,4 +205,54 @@ float HttpPowerMeterClass::getFloatValueByJsonPath(const char* jsonString, const return true; } + void HttpPowerMeterClass::extractUrlComponents(const String& url, String& protocol, String& hostname, String& uri) { + // Find protocol delimiter + int protocolEndIndex = url.indexOf(":"); + if (protocolEndIndex != -1) { + protocol = url.substring(0, protocolEndIndex); + + // Find double slash delimiter + int doubleSlashIndex = url.indexOf("//", protocolEndIndex); + if (doubleSlashIndex != -1) { + // Find slash after double slash delimiter + int slashIndex = url.indexOf("/", doubleSlashIndex + 2); + if (slashIndex != -1) { + // Extract hostname and uri + hostname = url.substring(doubleSlashIndex + 2, slashIndex); + uri = url.substring(slashIndex); + } else { + // No slash after double slash delimiter, so the whole remaining part is the hostname + hostname = url.substring(doubleSlashIndex + 2); + uri = "/"; + } + } + } + + // Remove username:password if present in the hostname + int atIndex = hostname.indexOf("@"); + if (atIndex != -1) { + hostname = hostname.substring(atIndex + 1); + } +} + +String HttpPowerMeterClass::sha256(const String& data) { + SHA256 sha256; + uint8_t hash[sha256.HASH_SIZE]; + + sha256.reset(); + sha256.update(data.c_str(), data.length()); + sha256.finalize(hash, sha256.HASH_SIZE); + + String hashStr = ""; + for (int i = 0; i < sha256.HASH_SIZE; i++) { + String hex = String(hash[i], HEX); + if (hex.length() == 1) { + hashStr += "0"; + } + hashStr += hex; + } + + return hashStr; +} + HttpPowerMeterClass HttpPowerMeter; diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 864ceda8..f2c0a674 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -56,6 +56,9 @@ void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request) phaseObject[F("index")] = i + 1; phaseObject[F("enabled")] = config.Powermeter_Http_Phase[i].Enabled; phaseObject[F("url")] = String(config.Powermeter_Http_Phase[i].Url); + phaseObject[F("auth_type")]= config.Powermeter_Http_Phase[i].AuthType; + phaseObject[F("username")] = String(config.Powermeter_Http_Phase[i].Username); + phaseObject[F("password")] = String(config.Powermeter_Http_Phase[i].Password); phaseObject[F("header_key")] = String(config.Powermeter_Http_Phase[i].HeaderKey); phaseObject[F("header_value")] = String(config.Powermeter_Http_Phase[i].HeaderValue); phaseObject[F("json_path")] = String(config.Powermeter_Http_Phase[i].JsonPath); @@ -137,6 +140,14 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) return; } + if ((phase[F("auth_type")].as() != Auth::none) + && ( phase[F("username")].as().length() == 0 || phase[F("password")].as().length() == 0)) { + retMsg[F("message")] = F("Username or password must not be empty!"); + response->setLength(); + request->send(response); + return; + } + if (!phase.containsKey("timeout") || phase[F("timeout")].as() <= 0) { retMsg[F("message")] = F("Timeout must be greater than 0 ms!"); @@ -173,6 +184,9 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) config.Powermeter_Http_Phase[i].Enabled = (i == 0 ? true : phase[F("enabled")].as()); strlcpy(config.Powermeter_Http_Phase[i].Url, phase[F("url")].as().c_str(), sizeof(config.Powermeter_Http_Phase[i].Url)); + config.Powermeter_Http_Phase[i].AuthType = phase[F("auth_type")].as(); + strlcpy(config.Powermeter_Http_Phase[i].Username, phase[F("username")].as().c_str(), sizeof(config.Powermeter_Http_Phase[i].Username)); + strlcpy(config.Powermeter_Http_Phase[i].Password, phase[F("password")].as().c_str(), sizeof(config.Powermeter_Http_Phase[i].Password)); strlcpy(config.Powermeter_Http_Phase[i].HeaderKey, phase[F("header_key")].as().c_str(), sizeof(config.Powermeter_Http_Phase[i].HeaderKey)); strlcpy(config.Powermeter_Http_Phase[i].HeaderValue, phase[F("header_value")].as().c_str(), sizeof(config.Powermeter_Http_Phase[i].HeaderValue)); config.Powermeter_Http_Phase[i].Timeout = phase[F("timeout")].as(); @@ -229,7 +243,8 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) return; } - if (!root.containsKey("url") || !root.containsKey("header_key") || !root.containsKey("header_value") + if (!root.containsKey("url") || !root.containsKey("auth_type") || !root.containsKey("username") || !root.containsKey("password") + || !root.containsKey("header_key") || !root.containsKey("header_value") || !root.containsKey("timeout") || !root.containsKey("json_path")) { retMsg[F("message")] = F("Missing fields!"); asyncJsonResponse->setLength(); @@ -241,8 +256,9 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) errorMessage[256]; char response[200]; - if (HttpPowerMeter.httpRequest(root[F("url")].as().c_str(), root[F("header_key")].as().c_str(), - root[F("header_value")].as().c_str(), root[F("timeout")].as(), + if (HttpPowerMeter.httpRequest(root[F("url")].as().c_str(), + root[F("auth_type")].as(), root[F("username")].as().c_str(), root[F("password")].as().c_str(), + root[F("header_key")].as().c_str(), root[F("header_value")].as().c_str(), root[F("timeout")].as(), powerMeterResponse, sizeof(powerMeterResponse), errorMessage, sizeof(errorMessage))) { float power; diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 7404f285..7bcb6442 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -519,6 +519,9 @@ "httpEnabled": "Phase enabled", "httpUrl": "URL", "httpUrlDescription": "URL must start with http:// or https://. Some characters like spaces and = have to be encoded with URL encoding (%xx). Warning: SSL server certificate check is not implemented (MITM attacks are possible)! See below for some examples.", + "httpAuthorization": "Authorization Type", + "httpUsername": "Username", + "httpPassword": "Password", "httpHeaderKey": "Optional: HTTP request header - Key", "httpHeaderKeyDescription": "A custom HTTP request header can be defined. Might be useful if you have to send something like a custom Authorization header.", "httpHeaderValue": "Optional: HTTP request header - Value", diff --git a/webapp/src/types/PowerMeterConfig.ts b/webapp/src/types/PowerMeterConfig.ts index 612c8432..30e6087b 100644 --- a/webapp/src/types/PowerMeterConfig.ts +++ b/webapp/src/types/PowerMeterConfig.ts @@ -2,11 +2,14 @@ export interface PowerMeterHttpPhaseConfig { index: number; enabled: boolean; url: string; + auth_type: number; + username: string; + password: string; header_key: string; header_value: string; json_path: string; timeout: number; -}; +} export interface PowerMeterConfig { enabled: boolean; diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index 1e4326b3..3d4c4938 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -120,6 +120,26 @@ placeholder="http://admin:supersecret@mypowermeter.home/status" prefix="GET " :tooltip="$t('powermeteradmin.httpUrlDescription')" /> + +
+ +
+ +
+
+
+ + + +