Files
FabledSteward/steward/auth/throttle.py
T
bvandeusenandClaude Opus 5.5 7a3f7869fe
CI / lint (push) Failing after 2s
CI / integration (push) Failing after 47s
CI / unit (push) Successful in 1m5s
CI / publish (push) Skipped
feat(auth): sessions end when they should; logins are throttled; Secure behind HTTPS (#5035)
#5035 "Sessions: lifetime applied, role and is_active re-read per request,
logout is a POST, failed logins throttled".

- steward/core/proxy.py: trusted_value / is_https / client_ip read
  X-Forwarded-Proto and -For only as far as the trusted proxy hops
  (Settings → General, default 1, 0 = trust none).
- The session cookie is Secure, and HSTS is sent, only on requests that
  arrived over HTTPS by that rule; plain-HTTP and LAN use are unchanged
  and nothing redirects.
- A login lasts Settings → General's session lifetime from when it began
  (session.permanent + a login_at stamp checked every request — the
  cookie's own age slides on every response). A deleted or deactivated
  user is logged out on their next request; a role change applies at once.
- Logout is a POST (a form in the sidebar); GET /logout is gone.
- Failed logins pause an address after login.max_failures within
  login.lockout_minutes (Settings → General; defaults 5 in 15 min),
  keyed by address so nobody can lock a user out by name. Failures are
  audited as auth.login_failed with the address.
- to_access_cfg projects the new settings into app.config at startup and
  on save, beside the existing projections.
- Integration tests seed real users and a login_at, since every request
  now re-reads the session's user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:02:27 -04:00

49 lines
1.7 KiB
Python

"""Pause password guessing: after too many failed logins from one address,
that address's logins are refused for a while.
Keyed by client address (steward/core/proxy.py: behind a trusted proxy, the
address it reports), not by username, so nobody can lock a user out by
failing logins in their name. Held in memory: a restart forgets it, which
costs an attacker nothing they couldn't get by waiting, and keeps it free of
the database. The limits are Settings → General (login.max_failures,
login.lockout_minutes).
"""
from __future__ import annotations
import time
from collections import deque
class LoginThrottle:
def __init__(self) -> None:
self._failures: dict[str, deque[float]] = {}
def _recent(self, key: str, window_s: float, now: float) -> deque[float]:
times = self._failures.get(key)
if times is None:
return deque()
while times and times[0] <= now - window_s:
times.popleft()
if not times:
del self._failures[key]
return times
def retry_after(self, key: str, max_failures: int, window_s: float,
now: float | None = None) -> int:
"""Seconds until `key` may try again; 0 when it may try now."""
now = time.monotonic() if now is None else now
times = self._recent(key, window_s, now)
if len(times) < max_failures:
return 0
return max(1, int(times[-max_failures] + window_s - now) + 1)
def failed(self, key: str, now: float | None = None) -> None:
now = time.monotonic() if now is None else now
self._failures.setdefault(key, deque()).append(now)
def succeeded(self, key: str) -> None:
self._failures.pop(key, None)
login_throttle = LoginThrottle()