Skip to content

Commit

Permalink
feature: add digest auth on power meter
Browse files Browse the repository at this point in the history
Power Meter -> HTTP(S) + Jason configuration allows now basic and digest authentication (all Shelly Gen2 devices)
  • Loading branch information
helgeerbe committed Jul 4, 2023
1 parent 9a4eb75 commit 006f63e
Show file tree
Hide file tree
Showing 10 changed files with 201 additions and 11 deletions.
6 changes: 6 additions & 0 deletions include/Configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion include/HttpPowerMeter.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ class HttpPowerMeterClass {
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;
1 change: 1 addition & 0 deletions platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/Configuration.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
140 changes: 134 additions & 6 deletions src/HttpPowerMeter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <FirebaseJson.h>
#include <Crypto.h>
#include <SHA256.h>

void HttpPowerMeterClass::init()
{
Expand Down Expand Up @@ -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);
Expand All @@ -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<WiFiClientSecure*>(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;
}
Expand All @@ -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();
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
22 changes: 19 additions & 3 deletions src/WebApi_powermeter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -137,6 +140,14 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request)
return;
}

if ((phase[F("auth_type")].as<Auth>() != Auth::none)
&& ( phase[F("username")].as<String>().length() == 0 || phase[F("password")].as<String>().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<uint16_t>() <= 0) {
retMsg[F("message")] = F("Timeout must be greater than 0 ms!");
Expand Down Expand Up @@ -173,6 +184,9 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request)

config.Powermeter_Http_Phase[i].Enabled = (i == 0 ? true : phase[F("enabled")].as<bool>());
strlcpy(config.Powermeter_Http_Phase[i].Url, phase[F("url")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].Url));
config.Powermeter_Http_Phase[i].AuthType = phase[F("auth_type")].as<Auth>();
strlcpy(config.Powermeter_Http_Phase[i].Username, phase[F("username")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].Username));
strlcpy(config.Powermeter_Http_Phase[i].Password, phase[F("password")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].Password));
strlcpy(config.Powermeter_Http_Phase[i].HeaderKey, phase[F("header_key")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].HeaderKey));
strlcpy(config.Powermeter_Http_Phase[i].HeaderValue, phase[F("header_value")].as<String>().c_str(), sizeof(config.Powermeter_Http_Phase[i].HeaderValue));
config.Powermeter_Http_Phase[i].Timeout = phase[F("timeout")].as<uint16_t>();
Expand Down Expand Up @@ -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();
Expand All @@ -241,8 +256,9 @@ void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request)
errorMessage[256];
char response[200];

if (HttpPowerMeter.httpRequest(root[F("url")].as<String>().c_str(), root[F("header_key")].as<String>().c_str(),
root[F("header_value")].as<String>().c_str(), root[F("timeout")].as<uint16_t>(),
if (HttpPowerMeter.httpRequest(root[F("url")].as<String>().c_str(),
root[F("auth_type")].as<Auth>(), root[F("username")].as<String>().c_str(), root[F("password")].as<String>().c_str(),
root[F("header_key")].as<String>().c_str(), root[F("header_value")].as<String>().c_str(), root[F("timeout")].as<uint16_t>(),
powerMeterResponse, sizeof(powerMeterResponse), errorMessage, sizeof(errorMessage))) {
float power;

Expand Down
3 changes: 3 additions & 0 deletions webapp/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion webapp/src/types/PowerMeterConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 25 additions & 0 deletions webapp/src/views/PowerMeterAdminView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@
placeholder="http://admin:supersecret@mypowermeter.home/status"
prefix="GET "
:tooltip="$t('powermeteradmin.httpUrlDescription')" />

<div class="row mb-3">
<label for="inputTimezone" class="col-sm-2 col-form-label">{{ $t('powermeteradmin.httpAuthorization') }}</label>
<div class="col-sm-10">
<select class="form-select" v-model="http_phase.auth_type">
<option v-for="source in powerMeterAuthList" :key="source.key" :value="source.key">
{{ source.value }}
</option>
</select>
</div>
</div>
<div v-if="http_phase.auth_type != 0">
<InputElement :label="$t('powermeteradmin.httpUsername')"
v-model="http_phase.username"
type="text" maxlength="64"/>

<InputElement :label="$t('powermeteradmin.httpPassword')"
v-model="http_phase.password"
type="password" maxlength="64"/>
</div>

<InputElement :label="$t('powermeteradmin.httpHeaderKey')"
v-model="http_phase.header_key"
Expand Down Expand Up @@ -209,6 +229,11 @@ export default defineComponent({
{ key: 3, value: this.$t('powermeteradmin.typeHTTP') },
{ key: 4, value: this.$t('powermeteradmin.typeSML') },
],
powerMeterAuthList: [
{ key: 0, value: "None" },
{ key: 1, value: "Basic" },
{ key: 2, value: "Digest" },
],
alertMessage: "",
alertType: "info",
showAlert: false,
Expand Down
Binary file modified webapp_dist/js/app.js.gz
Binary file not shown.

0 comments on commit 006f63e

Please sign in to comment.