diff --git a/code/html/custom.css b/code/html/custom.css index 9263f9da..0d544a6a 100644 --- a/code/html/custom.css +++ b/code/html/custom.css @@ -37,6 +37,10 @@ .button-reconnect { background: rgb(202, 60, 60); } +.button-apikey { + background: rgb(0, 202, 0); + margin-left: 5px; +} .pure-g { margin-bottom: 20px; } diff --git a/code/html/custom.js b/code/html/custom.js index 581a192e..486ce000 100644 --- a/code/html/custom.js +++ b/code/html/custom.js @@ -1,9 +1,8 @@ var websock; -var csrf; function doUpdate() { var data = $("#formSave").serializeArray(); - websock.send(JSON.stringify({'csrf': csrf, 'config': data})); + websock.send(JSON.stringify({'config': data})); $(".powExpected").val(0); return false; } @@ -11,19 +10,37 @@ function doUpdate() { function doReset() { var response = window.confirm("Are you sure you want to reset the device?"); if (response == false) return false; - websock.send(JSON.stringify({'csrf': csrf, 'action': 'reset'})); + websock.send(JSON.stringify({'action': 'reset'})); return false; } function doReconnect() { var response = window.confirm("Are you sure you want to disconnect from the current WIFI network?"); if (response == false) return false; - websock.send(JSON.stringify({'csrf': csrf, 'action': 'reconnect'})); + websock.send(JSON.stringify({'action': 'reconnect'})); return false; } function doToggle(element, value) { - websock.send(JSON.stringify({'csrf': csrf, 'action': value ? 'on' : 'off'})); + websock.send(JSON.stringify({'action': value ? 'on' : 'off'})); + return false; +} + +function randomString(length, chars) { + var mask = ''; + if (chars.indexOf('a') > -1) mask += 'abcdefghijklmnopqrstuvwxyz'; + if (chars.indexOf('A') > -1) mask += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (chars.indexOf('#') > -1) mask += '0123456789'; + if (chars.indexOf('@') > -1) mask += 'ABCDEF'; + if (chars.indexOf('!') > -1) mask += '~`!@#$%^&*()_+-={}[]:";\'<>?,./|\\'; + var result = ''; + for (var i = length; i > 0; --i) result += mask[Math.round(Math.random() * (mask.length - 1))]; + return result; +} + +function doGenerateAPIKey() { + var apikey = randomString(16, '@#'); + $("input[name=\"apiKey\"]").val(apikey); return false; } @@ -31,6 +48,7 @@ function showPanel() { $(".panel").hide(); $("#" + $(this).attr("data")).show(); if ($("#layout").hasClass('active')) toggleMenu(); + $("input[type='checkbox']").iphoneStyle("calculateDimensions").iphoneStyle("refresh"); }; function toggleMenu() { @@ -41,36 +59,6 @@ function toggleMenu() { function processData(data) { - // CSRF - if ("csrf" in data) { - csrf = data.csrf; - } - - // messages - if ("message" in data) { - window.alert(data.message); - } - - // pre-process - if ("network" in data) { - data.network = data.network.toUpperCase(); - } - if ("mqttStatus" in data) { - data.mqttStatus = data.mqttStatus ? "CONNECTED" : "NOT CONNECTED"; - } - - // relay - if ("relayStatus" in data) { - $("input[name='relayStatus']") - .prop("checked", data.relayStatus) - .iphoneStyle({ - checkedLabel: 'ON', - uncheckedLabel: 'OFF', - onChange: doToggle - }) - .iphoneStyle("refresh"); - } - // title if ("app" in data) { $(".pure-menu-heading").html(data.app); @@ -81,9 +69,27 @@ function processData(data) { document.title = title; } - // automatic assign Object.keys(data).forEach(function(key) { + // Wifi + if (key == "wifi") { + var groups = $("#panel-wifi .pure-g"); + for (var i in data.wifi) { + var wifi = data.wifi[i]; + Object.keys(wifi).forEach(function(key) { + var id = "input[name=" + key + "]"; + if ($(id, groups[i]).length) $(id, groups[i]).val(wifi[key]); + }); + }; + return; + } + + // Messages + if (key == "message") { + window.alert(data.message); + return; + } + // Enable options if (key.endsWith("Visible")) { var module = key.slice(0,-7); @@ -92,41 +98,40 @@ function processData(data) { return; } + // Pre-process + if (key == "network") { + data.network = data.network.toUpperCase(); + } + if (key == "mqttStatus") { + data.mqttStatus = data.mqttStatus ? "CONNECTED" : "NOT CONNECTED"; + } + // Look for INPUTs var element = $("input[name=" + key + "]"); if (element.length > 0) { if (element.attr('type') == 'checkbox') { element - .prop("checked", data[key] == 1) - .iphoneStyle({ - resizeContainer: false, - resizeHandle: false, - checkedLabel: 'ON', - uncheckedLabel: 'OFF' - }) + .prop("checked", data[key]) .iphoneStyle("refresh"); } else { element.val(data[key]); } + return; } // Look for SELECTs var element = $("select[name=" + key + "]"); if (element.length > 0) { element.val(data[key]); + return; } }); - // WIFI - var groups = $("#panel-wifi .pure-g"); - for (var i in data.wifi) { - var wifi = data.wifi[i]; - Object.keys(wifi).forEach(function(key) { - var id = "input[name=" + key + "]"; - if ($(id, groups[i]).length) $(id, groups[i]).val(wifi[key]); - }); - }; + // Auto generate an APIKey if none defined yet + if ($("input[name='apiKey']").val() == "") { + doGenerateAPIKey(); + } } @@ -138,16 +143,10 @@ function getJson(str) { } } -function init() { - - $("#menuLink").on('click', toggleMenu); - $(".button-update").on('click', doUpdate); - $(".button-reset").on('click', doReset); - $(".button-reconnect").on('click', doReconnect); - $(".pure-menu-link").on('click', showPanel); - - var host = window.location.hostname; - //host = '192.168.1.115'; +function initWebSocket(host) { + if (host === undefined) { + host = window.location.hostname; + } websock = new WebSocket('ws://' + host + '/ws'); websock.onopen = function(evt) {}; websock.onclose = function(evt) {}; @@ -156,6 +155,37 @@ function init() { var data = getJson(evt.data); if (data) processData(data); }; +} + +function init() { + + $("#menuLink").on('click', toggleMenu); + $(".button-update").on('click', doUpdate); + $(".button-reset").on('click', doReset); + $(".button-reconnect").on('click', doReconnect); + $(".button-apikey").on('click', doGenerateAPIKey); + $(".pure-menu-link").on('click', showPanel); + + $("input[name='relayStatus']") + .iphoneStyle({ + onChange: doToggle + }); + $("input[type='checkbox']") + .iphoneStyle({ + resizeContainer: true, + resizeHandle: true, + checkedLabel: 'ON', + uncheckedLabel: 'OFF' + }) + .iphoneStyle("refresh"); + + + $.ajax({ + 'method': 'GET', + 'url': '/auth' + }).done(function(data) { + initWebSocket(); + }); } diff --git a/code/html/fsversion b/code/html/fsversion index 7dea76ed..6d7de6e6 100644 --- a/code/html/fsversion +++ b/code/html/fsversion @@ -1 +1 @@ -1.0.1 +1.0.2 diff --git a/code/html/index.html b/code/html/index.html index fbbad511..0bad0b49 100644 --- a/code/html/index.html +++ b/code/html/index.html @@ -172,12 +172,25 @@
- +
 
The administrator password is used to access this web interface (user 'admin'), but also to connect to the device when in AP mode or to flash a new firmware over-the-air (OTA).
+
+
+
+
+ +
+ + +
+
 
+
This is the key you will have to pass with every HTTP request to the API, either to get or write values.
+
+ diff --git a/code/src/defaults.h b/code/src/defaults.h index 60be3d66..04e2567c 100644 --- a/code/src/defaults.h +++ b/code/src/defaults.h @@ -75,7 +75,8 @@ #define WIFI_MAX_NETWORKS 3 #define ADMIN_PASS "fibonacci" #define HTTP_USERNAME "admin" -#define CSRF_BUFFER_SIZE 5 +#define WS_BUFFER_SIZE 5 +#define WS_TIMEOUT 1800000 // ----------------------------------------------------------------------------- // OTA & NOFUSS diff --git a/code/src/dht.ino b/code/src/dht.ino index fe412fc2..b7fd2cf8 100644 --- a/code/src/dht.ino +++ b/code/src/dht.ino @@ -66,7 +66,7 @@ void dhtLoop() { // Update websocket clients char buffer[100]; sprintf_P(buffer, PSTR("{\"dhtVisible\": 1, \"dhtTmp\": %s, \"dhtHum\": %s}"), temperature, humidity); - webSocketSend(buffer); + wsSend(buffer); } diff --git a/code/src/emon.ino b/code/src/emon.ino index 31871a94..6202e996 100644 --- a/code/src/emon.ino +++ b/code/src/emon.ino @@ -95,7 +95,7 @@ void powerMonitorLoop() { // Update websocket clients char text[20]; sprintf_P(text, PSTR("{\"emonPower\": %d}"), int(current * mainsVoltage)); - webSocketSend(text); + wsSend(text); // Send MQTT messages averaged every EMON_MEASUREMENTS if (measurements == EMON_MEASUREMENTS) { diff --git a/code/src/mqtt.ino b/code/src/mqtt.ino index f595c744..73c53268 100644 --- a/code/src/mqtt.ino +++ b/code/src/mqtt.ino @@ -46,7 +46,7 @@ void _mqttOnConnect(bool sessionPresent) { DEBUG_MSG("[MQTT] Connected!\n"); // Send status via webSocket - webSocketSend((char *) "{\"mqttStatus\": true}"); + wsSend((char *) "{\"mqttStatus\": true}"); // Build MQTT topics buildTopics(); @@ -70,7 +70,7 @@ void _mqttOnConnect(bool sessionPresent) { void _mqttOnDisconnect(AsyncMqttClientDisconnectReason reason) { // Send status via webSocket - webSocketSend((char *) "{\"mqttStatus\": false}"); + wsSend((char *) "{\"mqttStatus\": false}"); } diff --git a/code/src/pow.ino b/code/src/pow.ino index 93260332..e631e17b 100644 --- a/code/src/pow.ino +++ b/code/src/pow.ino @@ -146,7 +146,7 @@ void powLoop() { char buffer[100]; sprintf_P(buffer, PSTR("{\"powVisible\": 1, \"powActivePower\": %d}"), power); - webSocketSend(buffer); + wsSend(buffer); if (--report_count == 0) { mqttSend((char *) getSetting("powPowerTopic", POW_POWER_TOPIC).c_str(), (char *) String(power).c_str()); diff --git a/code/src/relay.ino b/code/src/relay.ino index 6868adaa..e72efdaf 100644 --- a/code/src/relay.ino +++ b/code/src/relay.ino @@ -23,7 +23,7 @@ void _relayOn(unsigned char id) { mqttSend((char *) MQTT_STATUS_TOPIC, (char *) "1"); } - webSocketSend((char *) "{\"relayStatus\": true}"); + wsSend((char *) "{\"relayStatus\": true}"); } @@ -37,7 +37,7 @@ void _relayOff(unsigned char id) { mqttSend((char *) MQTT_STATUS_TOPIC, (char *) "0"); } - webSocketSend((char *) "{\"relayStatus\": false}"); + wsSend((char *) "{\"relayStatus\": false}"); } diff --git a/code/src/version.h b/code/src/version.h index afd3ce45..5e910f49 100644 --- a/code/src/version.h +++ b/code/src/version.h @@ -1,4 +1,4 @@ #define APP_NAME "Espurna" -#define APP_VERSION "1.0.1" +#define APP_VERSION "1.0.2" #define APP_AUTHOR "xose.perez@gmail.com" #define APP_WEBSITE "http://tinkerman.cat" diff --git a/code/src/web.ino b/code/src/web.ino index eb5bea78..4a9c7e96 100644 --- a/code/src/web.ino +++ b/code/src/web.ino @@ -18,23 +18,28 @@ Copyright (C) 2016 by Xose PĂ©rez AsyncWebServer server(80); AsyncWebSocket ws("/ws"); -unsigned long _csrf[CSRF_BUFFER_SIZE]; +typedef struct { + IPAddress ip; + unsigned long timestamp = 0; +} ws_ticket_t; + +ws_ticket_t _ticket[WS_BUFFER_SIZE]; // ----------------------------------------------------------------------------- // WEBSOCKETS // ----------------------------------------------------------------------------- -bool webSocketSend(char * payload) { +bool wsSend(char * payload) { //DEBUG_MSG("[WEBSOCKET] Broadcasting '%s'\n", payload); ws.textAll(payload); } -bool webSocketSend(uint32_t client_id, char * payload) { +bool wsSend(uint32_t client_id, char * payload) { //DEBUG_MSG("[WEBSOCKET] Sending '%s' to #%ld\n", payload, client_id); ws.text(client_id, payload); } -void webSocketParse(uint32_t client_id, uint8_t * payload, size_t length) { +void _wsParse(uint32_t client_id, uint8_t * payload, size_t length) { // Parse JSON input DynamicJsonBuffer jsonBuffer; @@ -45,16 +50,6 @@ void webSocketParse(uint32_t client_id, uint8_t * payload, size_t length) { return; } - // CSRF - unsigned long csrf = 0; - if (root.containsKey("csrf")) csrf = root["csrf"]; - if (csrf != _csrf[client_id % CSRF_BUFFER_SIZE]) { - DEBUG_MSG("[WEBSOCKET] CSRF check failed\n"); - ws.text(client_id, "{\"message\": \"Session expired, please reload page...\"}"); - return; - } - - // Check actions if (root.containsKey("action")) { @@ -76,6 +71,7 @@ void webSocketParse(uint32_t client_id, uint8_t * payload, size_t length) { bool dirty = false; bool dirtyMQTT = false; + bool apiEnabled = false; unsigned int network = 0; for (unsigned int i=0; iremoteIP(); + unsigned long now = millis(); + unsigned short index = 0; + + for (index = 0; index < WS_BUFFER_SIZE; index++) { + if ((_ticket[index].ip == ip) && (now - _ticket[index].timestamp < WS_TIMEOUT)) break; + } + + if (index == WS_BUFFER_SIZE) { + DEBUG_MSG("[WEBSOCKET] Validation check failed\n"); + ws.text(client->id(), "{\"message\": \"Session expired, please reload page...\"}"); + return false; + } + + return true; + +} + +void _wsEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsEventType type, void * arg, uint8_t *data, size_t len){ + + // Authorize + #ifndef NOWSAUTH + if (!_wsAuth(client)) return; + #endif + if (type == WS_EVT_CONNECT) { - #if DEBUG_PORT - { - IPAddress ip = server.remoteIP(client->id()); - DEBUG_MSG("[WEBSOCKET] #%u connected, ip: %d.%d.%d.%d, url: %s\n", client->id(), ip[0], ip[1], ip[2], ip[3], server->url()); - } - #endif - webSocketStart(client->id()); + IPAddress ip = client->remoteIP(); + DEBUG_MSG("[WEBSOCKET] #%u connected, ip: %d.%d.%d.%d, url: %s\n", client->id(), ip[0], ip[1], ip[2], ip[3], server->url()); + _wsStart(client->id()); } else if(type == WS_EVT_DISCONNECT) { DEBUG_MSG("[WEBSOCKET] #%u disconnected\n", client->id()); } else if(type == WS_EVT_ERROR) { @@ -233,8 +259,9 @@ void webSocketEvent(AsyncWebSocket * server, AsyncWebSocketClient * client, AwsE } else if(type == WS_EVT_PONG) { DEBUG_MSG("[WEBSOCKET] #%u pong(%u): %s\n", client->id(), len, len ? (char*) data : ""); } else if(type == WS_EVT_DATA) { - webSocketParse(client->id(), data, len); + _wsParse(client->id(), data, len); } + } // ----------------------------------------------------------------------------- @@ -245,16 +272,43 @@ void _logRequest(AsyncWebServerRequest *request) { DEBUG_MSG("[WEBSERVER] Request: %s %s\n", request->methodToString(), request->url().c_str()); } -void _onHome(AsyncWebServerRequest *request) { - - _logRequest(request); - +bool _authenticate(AsyncWebServerRequest *request) { String password = getSetting("adminPass", ADMIN_PASS); char httpPassword[password.length() + 1]; password.toCharArray(httpPassword, password.length() + 1); - if (!request->authenticate(HTTP_USERNAME, httpPassword)) { - return request->requestAuthentication(); + return request->authenticate(HTTP_USERNAME, httpPassword); +} + +void _onAuth(AsyncWebServerRequest *request) { + + _logRequest(request); + + if (!_authenticate(request)) return request->requestAuthentication(); + + IPAddress ip = request->client()->remoteIP(); + unsigned long now = millis(); + unsigned short index; + for (index = 0; index < WS_BUFFER_SIZE; index++) { + if (_ticket[index].ip == ip) break; + if (_ticket[index].timestamp == 0) break; + if (now - _ticket[index].timestamp > WS_TIMEOUT) break; } + if (index == WS_BUFFER_SIZE) { + request->send(423); + } else { + _ticket[index].ip = ip; + _ticket[index].timestamp = now; + request->send(204); + } + +} + +void _onHome(AsyncWebServerRequest *request) { + + _logRequest(request); + + if (!_authenticate(request)) return request->requestAuthentication(); + request->send(SPIFFS, "/index.html"); } @@ -276,16 +330,48 @@ void _onRelayOff(AsyncWebServerRequest *request) { }; -ArRequestHandlerFunction _onRelayStatusWrapper(bool relayID) { +bool _apiAuth(AsyncWebServerRequest *request) { + + if (getSetting("apiEnabled").toInt() == 0) { + DEBUG_MSG("[WEBSERVER] HTTP API is not enabled\n"); + request->send(403); + return false; + } + + if (!request->hasParam("apikey", (request->method() == HTTP_PUT))) { + DEBUG_MSG("[WEBSERVER] Missing apikey parameter\n"); + request->send(403); + return false; + } + + AsyncWebParameter* p = request->getParam("apikey", (request->method() == HTTP_PUT)); + if (!p->value().equals(getSetting("apiKey"))) { + DEBUG_MSG("[WEBSERVER] Wrong apikey parameter\n"); + request->send(403); + return false; + } + + return true; + +} + +ArRequestHandlerFunction _onRelayStatusWrapper(unsigned int relayID) { return [&](AsyncWebServerRequest *request) { _logRequest(request); + if (!_apiAuth(request)) return; + if (request->method() == HTTP_PUT) { if (request->hasParam("status", true)) { AsyncWebParameter* p = request->getParam("status", true); - relayStatus(relayID, p->value().toInt() == 1); + unsigned int value = p->value().toInt(); + if (value == 2) { + relayToggle(relayID); + } else { + relayStatus(relayID, value == 1); + } } } @@ -296,7 +382,7 @@ ArRequestHandlerFunction _onRelayStatusWrapper(bool relayID) { } if (asJson) { - char buffer[40]; + char buffer[20]; sprintf(buffer, "{\"status\": %d}", relayStatus(relayID) ? 1 : 0); request->send(200, "application/json", buffer); } else { @@ -310,12 +396,13 @@ ArRequestHandlerFunction _onRelayStatusWrapper(bool relayID) { void webSetup() { // Setup websocket plugin - ws.onEvent(webSocketEvent); + ws.onEvent(_wsEvent); server.addHandler(&ws); // Serve home (password protected) server.on("/", HTTP_GET, _onHome); server.on("/index.html", HTTP_GET, _onHome); + server.on("/auth", HTTP_GET, _onAuth); // API entry points (non protected) server.on("/relay/on", HTTP_GET, _onRelayOn);