mirror of
https://github.com/rstrouse/ESPSomfy-RTS.git
synced 2026-10-31 13:52:15 +01:00
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:
parent
1612fd1587
commit
bab83313ee
8 changed files with 326 additions and 1 deletions
|
|
@ -3,6 +3,11 @@
|
|||
The suite never changes the device's security itself, so it tests the mode it
|
||||
finds: pass --password or --pin for a Secured device.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from proto_client import ProtoClient
|
||||
|
|
@ -36,3 +41,43 @@ def test_secured_accepts_the_token(host, secured, token):
|
|||
c = ProtoClient(host, params={"apikey": token})
|
||||
assert c.status == 101
|
||||
c.close()
|
||||
|
||||
|
||||
def login_status(host, body):
|
||||
"""/login's status and reply, refusals included."""
|
||||
req = urllib.request.Request(
|
||||
f"http://{host}/login",
|
||||
data=json.dumps(body).encode(),
|
||||
method="PUT",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
return r.status, json.loads(r.read()), r.headers
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, json.loads(e.read() or b"{}"), e.headers
|
||||
|
||||
|
||||
def test_failed_logins_lock_this_pc_out(request, host, secured, token):
|
||||
"""Five wrong logins refuse this IP for 30 s, even with the right ones (SECURITY_PLAN.md, PR C)."""
|
||||
if not request.config.getoption("--lockout"):
|
||||
pytest.skip("locks this PC out of /login for 30 s: pass --lockout")
|
||||
if not secured:
|
||||
pytest.skip("the device's security is None")
|
||||
password = request.config.getoption("--password")
|
||||
pin = request.config.getoption("--pin")
|
||||
right = {"username": request.config.getoption("--username"), "password": password or "", "pin": pin or ""}
|
||||
wrong = {**right, "password": (password or "") + "x", "pin": "0000" if pin != "0000" else "1111"}
|
||||
# The token fixture's login started this PC with a clean count.
|
||||
for _ in range(5):
|
||||
status, reply, _ = login_status(host, wrong)
|
||||
assert status == 401 and reply["success"] is False
|
||||
status, reply, headers = login_status(host, right)
|
||||
assert status == 429, "the right credentials got through a lockout"
|
||||
wait = int(headers["Retry-After"])
|
||||
assert 25 <= wait <= 30
|
||||
assert reply["retryAfter"] == wait
|
||||
time.sleep(wait + 1)
|
||||
# Over: the right credentials work, and reset the count.
|
||||
status, reply, _ = login_status(host, right)
|
||||
assert status == 200 and reply["apiKey"] == token
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue