#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>
49 lines
1.7 KiB
Python
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()
|