feat(auth): rate-limit failed logins with per-(user, IP) lockout
Ten wrong passwords from the same (username, client-IP) tuple within 15 minutes now return 429 with Retry-After for the next 15 minutes; authenticate() isn't even called while locked, so the 429 response is identical whether the password would have been correct — no oracle. Tuple keying prevents an attacker from one IP from locking the real admin out of their own box: a different IP (or an ISP reconnect) keeps them in. The client IP comes from the rightmost X-Forwarded-For entry, which is what Caddy appends and thus trustworthy (no upstream proxy in front of Caddy). First-run setup bypasses the lockout — otherwise a clumsy operator could lock themselves out before an admin exists. State is in-memory (parallel to SessionStore), so `systemctl restart furtka` clears a stuck lockout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e68ed279cc
commit
1cff22658b
4 changed files with 315 additions and 8 deletions
|
|
@ -1177,6 +1177,16 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
f"{auth.COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
|
f"{auth.COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _client_ip(self) -> str:
|
||||||
|
# Caddy's reverse_proxy appends the real TCP peer to X-Forwarded-For;
|
||||||
|
# the rightmost entry is the one Caddy added, so it's trustworthy
|
||||||
|
# even if a client spoofed an XFF of their own. Caddy is the edge —
|
||||||
|
# no upstream proxy in front of it.
|
||||||
|
xff = self.headers.get("X-Forwarded-For")
|
||||||
|
if xff:
|
||||||
|
return xff.rsplit(",", 1)[-1].strip()
|
||||||
|
return self.client_address[0]
|
||||||
|
|
||||||
def _handle_login(self, payload):
|
def _handle_login(self, payload):
|
||||||
username = payload.get("username") if isinstance(payload, dict) else None
|
username = payload.get("username") if isinstance(payload, dict) else None
|
||||||
password = payload.get("password") if isinstance(payload, dict) else None
|
password = payload.get("password") if isinstance(payload, dict) else None
|
||||||
|
|
@ -1200,12 +1210,26 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
)
|
)
|
||||||
auth.create_admin(username, password)
|
auth.create_admin(username, password)
|
||||||
else:
|
else:
|
||||||
|
# Tuple-keyed lockout: a flood from one IP can't lock the
|
||||||
|
# admin out from a different IP. When locked we return the
|
||||||
|
# same 429 regardless of whether the password is correct —
|
||||||
|
# no oracle, no timing leak via "would have worked."
|
||||||
|
lockout_key = (username, self._client_ip())
|
||||||
|
retry = auth.LOCKOUT.retry_after_seconds(lockout_key)
|
||||||
|
if retry > 0:
|
||||||
|
return self._json(
|
||||||
|
429,
|
||||||
|
{"error": "too many failed attempts, try again later"},
|
||||||
|
extra_headers=[("Retry-After", str(retry))],
|
||||||
|
)
|
||||||
if not auth.authenticate(username, password):
|
if not auth.authenticate(username, password):
|
||||||
# Cheap brute-force speed bump. werkzeug's PBKDF2 is
|
# Register before the sleep so concurrent threads see a
|
||||||
# already slow per attempt, but a fixed sleep makes
|
# consistent count; keep the sleep so timing can't
|
||||||
# "try 1000 passwords over the LAN" even less fun.
|
# distinguish "locked" from "wrong password."
|
||||||
|
auth.LOCKOUT.register_failure(lockout_key)
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
return self._json(401, {"error": "invalid username or password"})
|
return self._json(401, {"error": "invalid username or password"})
|
||||||
|
auth.LOCKOUT.clear(lockout_key)
|
||||||
|
|
||||||
session = auth.SESSIONS.create(username)
|
session = auth.SESSIONS.create(username)
|
||||||
cookie = self._session_cookie_header(session.token, auth.COOKIE_TTL_SECONDS)
|
cookie = self._session_cookie_header(session.token, auth.COOKIE_TTL_SECONDS)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ One admin, one password. Passwords are PBKDF2-SHA256 hashed via
|
||||||
``furtka.passwd`` (stdlib-only — hashlib.pbkdf2_hmac / hashlib.scrypt),
|
``furtka.passwd`` (stdlib-only — hashlib.pbkdf2_hmac / hashlib.scrypt),
|
||||||
stored in /var/lib/furtka/users.json with mode 0600. Sessions live in
|
stored in /var/lib/furtka/users.json with mode 0600. Sessions live in
|
||||||
memory — a systemctl restart logs everyone out again, which is fine
|
memory — a systemctl restart logs everyone out again, which is fine
|
||||||
for an alpha single-user box.
|
for an alpha single-user box. The ``LoginAttempts`` store in this
|
||||||
|
module rate-limits failed logins per (username, IP) and is also
|
||||||
|
in-memory; a restart clears a stuck lockout.
|
||||||
|
|
||||||
On upgrade from pre-auth Furtka the users.json file does not exist
|
On upgrade from pre-auth Furtka the users.json file does not exist
|
||||||
yet; the api's GET /login detects this via ``setup_needed()`` and
|
yet; the api's GET /login detects this via ``setup_needed()`` and
|
||||||
|
|
@ -20,6 +22,7 @@ forward without re-setup; see ``furtka.passwd`` for the scrypt reader.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -176,5 +179,82 @@ class SessionStore:
|
||||||
self._by_token.clear()
|
self._by_token.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class LoginAttempts:
|
||||||
|
"""In-memory rate-limiter for failed logins, keyed by (username, ip).
|
||||||
|
|
||||||
|
Parallels SessionStore: thread-safe, uses ``datetime.now(UTC)`` so the
|
||||||
|
same ``_FakeDatetime`` test shim works, lives only in memory so a
|
||||||
|
``systemctl restart furtka`` wipes a stuck lockout. Tuple keying means
|
||||||
|
a flood from one source IP can't lock the admin out from elsewhere
|
||||||
|
(different IP → different key) — the trade-off is that an attacker
|
||||||
|
can keep probing forever by rotating IPs, but they still eat the
|
||||||
|
PBKDF2 cost per attempt.
|
||||||
|
|
||||||
|
Stored data is a dict[key → list[datetime]] of recent failure
|
||||||
|
timestamps. Every call prunes entries older than ``WINDOW_SECONDS``,
|
||||||
|
so memory per active key is bounded by ``MAX_FAILURES``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MAX_FAILURES = 10
|
||||||
|
WINDOW_SECONDS = 15 * 60
|
||||||
|
LOCKOUT_SECONDS = 15 * 60
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_failures: int = MAX_FAILURES,
|
||||||
|
window_seconds: int = WINDOW_SECONDS,
|
||||||
|
lockout_seconds: int = LOCKOUT_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
self._max = max_failures
|
||||||
|
self._window = timedelta(seconds=window_seconds)
|
||||||
|
self._lockout = timedelta(seconds=lockout_seconds)
|
||||||
|
self._fails: dict[tuple[str, str], list[datetime]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _prune_locked(self, key: tuple[str, str], now: datetime) -> list[datetime]:
|
||||||
|
"""Drop timestamps older than the window; caller holds self._lock."""
|
||||||
|
cutoff = now - self._window
|
||||||
|
kept = [ts for ts in self._fails.get(key, ()) if ts >= cutoff]
|
||||||
|
if kept:
|
||||||
|
self._fails[key] = kept
|
||||||
|
else:
|
||||||
|
self._fails.pop(key, None)
|
||||||
|
return kept
|
||||||
|
|
||||||
|
def register_failure(self, key: tuple[str, str]) -> None:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with self._lock:
|
||||||
|
self._prune_locked(key, now)
|
||||||
|
self._fails.setdefault(key, []).append(now)
|
||||||
|
|
||||||
|
def is_locked(self, key: tuple[str, str]) -> bool:
|
||||||
|
return self.retry_after_seconds(key) > 0
|
||||||
|
|
||||||
|
def retry_after_seconds(self, key: tuple[str, str]) -> int:
|
||||||
|
"""Seconds remaining on an active lockout, or 0 if not locked."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
with self._lock:
|
||||||
|
kept = self._prune_locked(key, now)
|
||||||
|
if len(kept) < self._max:
|
||||||
|
return 0
|
||||||
|
# Lockout runs from the oldest retained failure; once it
|
||||||
|
# falls off the window the key is effectively released.
|
||||||
|
unlock_at = kept[0] + self._lockout
|
||||||
|
remaining = (unlock_at - now).total_seconds()
|
||||||
|
if remaining <= 0:
|
||||||
|
return 0
|
||||||
|
return max(1, math.ceil(remaining))
|
||||||
|
|
||||||
|
def clear(self, key: tuple[str, str]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._fails.pop(key, None)
|
||||||
|
|
||||||
|
def clear_all(self) -> None:
|
||||||
|
"""Test helper — wipe all failure state."""
|
||||||
|
with self._lock:
|
||||||
|
self._fails.clear()
|
||||||
|
|
||||||
|
|
||||||
# Module-level singleton used by the HTTP handler.
|
# Module-level singleton used by the HTTP handler.
|
||||||
SESSIONS = SessionStore()
|
SESSIONS = SessionStore()
|
||||||
|
LOCKOUT = LoginAttempts()
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,10 @@ def fake_dirs(tmp_path, monkeypatch):
|
||||||
from furtka import install_runner
|
from furtka import install_runner
|
||||||
|
|
||||||
importlib.reload(install_runner)
|
importlib.reload(install_runner)
|
||||||
# Scrub any sessions that leaked from a prior test — the SESSIONS
|
# Scrub any sessions or lockout counters that leaked from a prior
|
||||||
# store is module-level.
|
# test — both stores are module-level.
|
||||||
auth.SESSIONS.clear()
|
auth.SESSIONS.clear()
|
||||||
|
auth.LOCKOUT.clear_all()
|
||||||
return apps, bundled
|
return apps, bundled
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -600,6 +601,130 @@ def test_post_login_rejects_wrong_password(fake_dirs):
|
||||||
server.server_close()
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def _post_wrong_login(port, username="daniel", password="nope"):
|
||||||
|
req = _request(
|
||||||
|
port,
|
||||||
|
"/login",
|
||||||
|
method="POST",
|
||||||
|
body={"username": username, "password": password},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
raise AssertionError("expected HTTPError")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_login_locks_out_after_repeated_failures(fake_dirs, monkeypatch):
|
||||||
|
auth.create_admin("daniel", "hunter2-pw")
|
||||||
|
# Flatten the 0.5s speed-bump so the test doesn't take 5 seconds.
|
||||||
|
monkeypatch.setattr(api.time, "sleep", lambda _s: None)
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
for _ in range(auth.LoginAttempts.MAX_FAILURES):
|
||||||
|
err = _post_wrong_login(port)
|
||||||
|
assert err.code == 401
|
||||||
|
err = _post_wrong_login(port)
|
||||||
|
assert err.code == 429
|
||||||
|
assert err.headers.get("Retry-After") is not None
|
||||||
|
assert int(err.headers["Retry-After"]) > 0
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_login_429_masks_correctness(fake_dirs, monkeypatch):
|
||||||
|
"""Once locked, the correct password must also get 429 — no oracle."""
|
||||||
|
auth.create_admin("daniel", "hunter2-pw")
|
||||||
|
monkeypatch.setattr(api.time, "sleep", lambda _s: None)
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
for _ in range(auth.LoginAttempts.MAX_FAILURES):
|
||||||
|
_post_wrong_login(port)
|
||||||
|
req = _request(
|
||||||
|
port,
|
||||||
|
"/login",
|
||||||
|
method="POST",
|
||||||
|
body={"username": "daniel", "password": "hunter2-pw"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
raise AssertionError("expected 429")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
assert e.code == 429
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_login_success_clears_lockout_counter(fake_dirs, monkeypatch):
|
||||||
|
auth.create_admin("daniel", "hunter2-pw")
|
||||||
|
monkeypatch.setattr(api.time, "sleep", lambda _s: None)
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
# Get close to the threshold, then log in successfully.
|
||||||
|
for _ in range(auth.LoginAttempts.MAX_FAILURES - 1):
|
||||||
|
_post_wrong_login(port)
|
||||||
|
req = _request(
|
||||||
|
port,
|
||||||
|
"/login",
|
||||||
|
method="POST",
|
||||||
|
body={"username": "daniel", "password": "hunter2-pw"},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
assert r.status == 200
|
||||||
|
# Counter must have been cleared: another full MAX_FAILURES-1
|
||||||
|
# fails shouldn't trigger 429.
|
||||||
|
for _ in range(auth.LoginAttempts.MAX_FAILURES - 1):
|
||||||
|
err = _post_wrong_login(port)
|
||||||
|
assert err.code == 401
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_login_setup_not_rate_limited(fake_dirs, monkeypatch):
|
||||||
|
"""First-run setup is never auth-ed against a hash, so the lockout
|
||||||
|
must not apply — otherwise a clumsy admin could lock themselves out
|
||||||
|
of a box that has no admin yet."""
|
||||||
|
monkeypatch.setattr(api.time, "sleep", lambda _s: None)
|
||||||
|
server, port = _start_server()
|
||||||
|
try:
|
||||||
|
# Many mismatched setup submissions (400s) — no 429 should appear.
|
||||||
|
for _ in range(auth.LoginAttempts.MAX_FAILURES + 3):
|
||||||
|
req = _request(
|
||||||
|
port,
|
||||||
|
"/login",
|
||||||
|
method="POST",
|
||||||
|
body={
|
||||||
|
"username": "daniel",
|
||||||
|
"password": "longenough",
|
||||||
|
"password2": "different",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req)
|
||||||
|
raise AssertionError("expected 400")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
assert e.code == 400
|
||||||
|
# Then a good setup still succeeds.
|
||||||
|
req = _request(
|
||||||
|
port,
|
||||||
|
"/login",
|
||||||
|
method="POST",
|
||||||
|
body={
|
||||||
|
"username": "daniel",
|
||||||
|
"password": "longenough",
|
||||||
|
"password2": "longenough",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as r:
|
||||||
|
assert r.status == 200
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
def test_post_logout_revokes_session(fake_dirs, admin_session):
|
def test_post_logout_revokes_session(fake_dirs, admin_session):
|
||||||
server, port = _start_server()
|
server, port = _start_server()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,11 @@ from furtka import auth
|
||||||
def tmp_users_file(tmp_path, monkeypatch):
|
def tmp_users_file(tmp_path, monkeypatch):
|
||||||
path = tmp_path / "users.json"
|
path = tmp_path / "users.json"
|
||||||
monkeypatch.setenv("FURTKA_USERS_FILE", str(path))
|
monkeypatch.setenv("FURTKA_USERS_FILE", str(path))
|
||||||
# Sessions are module-level; wipe between tests so one doesn't leak a
|
# Sessions and lockout state are module-level; wipe between tests so
|
||||||
# valid token into the next.
|
# one doesn't leak a valid token (or a stale failure counter) into
|
||||||
|
# the next.
|
||||||
auth.SESSIONS.clear()
|
auth.SESSIONS.clear()
|
||||||
|
auth.LOCKOUT.clear_all()
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -150,3 +152,79 @@ class _FakeDatetime:
|
||||||
if tz is None:
|
if tz is None:
|
||||||
return self._fixed.replace(tzinfo=None)
|
return self._fixed.replace(tzinfo=None)
|
||||||
return self._fixed.astimezone(tz)
|
return self._fixed.astimezone(tz)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Login attempts / lockout ----------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_lockout_under_threshold_still_allowed(tmp_users_file):
|
||||||
|
store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60)
|
||||||
|
key = ("daniel", "10.0.0.1")
|
||||||
|
for _ in range(2):
|
||||||
|
store.register_failure(key)
|
||||||
|
assert store.is_locked(key) is False
|
||||||
|
assert store.retry_after_seconds(key) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_lockout_triggers_at_threshold(tmp_users_file):
|
||||||
|
store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60)
|
||||||
|
key = ("daniel", "10.0.0.1")
|
||||||
|
for _ in range(3):
|
||||||
|
store.register_failure(key)
|
||||||
|
assert store.is_locked(key) is True
|
||||||
|
assert store.retry_after_seconds(key) > 0
|
||||||
|
assert store.retry_after_seconds(key) <= 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_lockout_window_decay(tmp_users_file, monkeypatch):
|
||||||
|
store = auth.LoginAttempts(max_failures=3, window_seconds=60, lockout_seconds=60)
|
||||||
|
key = ("daniel", "10.0.0.1")
|
||||||
|
for _ in range(3):
|
||||||
|
store.register_failure(key)
|
||||||
|
assert store.is_locked(key) is True
|
||||||
|
# Jump 2 minutes ahead — all failures are older than the window
|
||||||
|
# and should be pruned on the next check.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
auth,
|
||||||
|
"datetime",
|
||||||
|
_FakeDatetime(datetime.now(UTC) + timedelta(seconds=121)),
|
||||||
|
)
|
||||||
|
assert store.is_locked(key) is False
|
||||||
|
assert store.retry_after_seconds(key) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_lockout_clear_resets(tmp_users_file):
|
||||||
|
store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60)
|
||||||
|
key = ("daniel", "10.0.0.1")
|
||||||
|
store.register_failure(key)
|
||||||
|
store.register_failure(key)
|
||||||
|
assert store.is_locked(key) is True
|
||||||
|
store.clear(key)
|
||||||
|
assert store.is_locked(key) is False
|
||||||
|
assert store.retry_after_seconds(key) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_lockout_keys_are_independent(tmp_users_file):
|
||||||
|
store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60)
|
||||||
|
locked = ("daniel", "1.1.1.1")
|
||||||
|
other_ip = ("daniel", "2.2.2.2")
|
||||||
|
other_user = ("robert", "1.1.1.1")
|
||||||
|
store.register_failure(locked)
|
||||||
|
store.register_failure(locked)
|
||||||
|
assert store.is_locked(locked) is True
|
||||||
|
assert store.is_locked(other_ip) is False
|
||||||
|
assert store.is_locked(other_user) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_lockout_clear_all_wipes_every_key(tmp_users_file):
|
||||||
|
store = auth.LoginAttempts(max_failures=2, window_seconds=60, lockout_seconds=60)
|
||||||
|
a = ("daniel", "1.1.1.1")
|
||||||
|
b = ("robert", "2.2.2.2")
|
||||||
|
store.register_failure(a)
|
||||||
|
store.register_failure(a)
|
||||||
|
store.register_failure(b)
|
||||||
|
store.register_failure(b)
|
||||||
|
assert store.is_locked(a) and store.is_locked(b)
|
||||||
|
store.clear_all()
|
||||||
|
assert not store.is_locked(a)
|
||||||
|
assert not store.is_locked(b)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue