feat: throttle failed logins per client IP

PR C of SECURITY_PLAN.md: after 5 failed /login attempts an IP is refused
for 30 s, and each failure after that refuses it for twice as long, up
to 15 min. A success resets it, as does an hour without a failure.

- LoginThrottle.h holds the schedule and 8 IPs, with the time passed in
  so native tests cover it. While all 8 hold recent failures, every
  other IP shares one more slot, so many addresses don't each get five
  free tries.
- A refused IP gets 429 with Retry-After before its credentials are
  checked, so a right guess during a lockout reveals nothing. Home
  Assistant retries a 429 instead of asking for new credentials.
- The web UI's login panel shows a 401's or 429's message, where it
  popped a service error.
- test/e2e --lockout checks it on a Secured device and waits it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cjkas 2026-10-26 22:08:30 +01:00
parent 1612fd1587
commit bab83313ee
8 changed files with 326 additions and 1 deletions

View file

@ -20,6 +20,7 @@
#include "MqttConnection.h"
#include "ESPNetwork.h"
#include "Timing.h"
#include "LoginThrottle.h"
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <AsyncJson.h>
@ -49,6 +50,9 @@ const char _encoding_json[] = "application/json";
static const char *TAG = "Web";
// Failed logins per client IP, on both web servers.
static LoginThrottle loginThrottle;
// Hands an item to loop(), which owns the shades. When it can't be the
// request is answered with the reason and false is returned.
bool queueLoopItem(AsyncWebServerRequest *request, const loop_item_t &item) {
@ -335,6 +339,19 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
request->send(200, _encoding_json, g_async_content);
return;
}
// A refused IP isn't told whether these credentials were right.
IPAddress ip = request->client()->remoteIP();
uint32_t wait = loginThrottle.retryAfter((uint32_t)ip, millis());
if(wait) {
uint32_t secs = (wait + 999) / 1000;
snprintf(g_async_content, sizeof(g_async_content),
"{\"type\":%u,\"msg\":\"Too many failed logins. Try again in %lu s.\",\"success\":false,\"retryAfter\":%lu}",
static_cast<uint8_t>(settings.Security.type), (unsigned long)secs, (unsigned long)secs);
AsyncWebServerResponse *response = request->beginResponse(429, _encoding_json, g_async_content);
response->addHeader("Retry-After", String(secs));
request->send(response);
return;
}
char username[33] = "";
char password[33] = "";
char pin[5] = "";
@ -363,12 +380,15 @@ void Web::handleLogin(AsyncWebServerRequest *request, JsonVariant &json) {
if(String(ptok) == String(token)) success = true;
}
if(success) {
loginThrottle.succeeded((uint32_t)ip);
snprintf(g_async_content, sizeof(g_async_content),
"{\"type\":%u,\"apiKey\":\"%s\",\"msg\":\"Success\",\"success\":true}",
static_cast<uint8_t>(settings.Security.type), token);
request->send(200, _encoding_json, g_async_content);
}
else {
uint32_t lockout = loginThrottle.failed((uint32_t)ip, millis());
if(lockout) ESP_LOGW(TAG, "Failed login from %s: refused for %lu s", ip.toString().c_str(), (unsigned long)(lockout / 1000));
snprintf(g_async_content, sizeof(g_async_content),
"{\"type\":%u,\"msg\":\"Invalid credentials\",\"success\":false}",
static_cast<uint8_t>(settings.Security.type));