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>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user