feat(auth): sessions end when they should; logins are throttled; Secure behind HTTPS (#5035)
CI / lint (push) Failing after 2s
CI / integration (push) Failing after 47s
CI / unit (push) Successful in 1m5s
CI / publish (push) Skipped

#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:
2026-10-06 11:02:27 -04:00
co-authored by Claude Opus 5.5
parent 8dad79f17a
commit 7a3f7869fe
12 changed files with 378 additions and 14 deletions
+26
View File
@@ -40,7 +40,13 @@ DEFAULTS: dict[str, Any] = {
# General — external URL for install scripts, share links, alert deep-links.
# Empty = fall back to the current request's Host header.
"general.public_base_url": "",
# Reverse proxies in front of Steward whose X-Forwarded-* headers are
# believed (steward/core/proxy.py). 0 = trust none.
"general.trusted_proxy_hops": 1,
"session.lifetime_hours": 8,
# Failed logins from one address before it is refused for the window.
"login.max_failures": 5,
"login.lockout_minutes": 15,
"data.retention_days": 90,
"monitors.poll_interval_seconds": 60,
"smtp.host": "",
@@ -366,6 +372,26 @@ def to_ldap_cfg(settings: dict[str, Any]) -> dict:
return {k[len("ldap."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("ldap.")}
def _int(settings: dict[str, Any], key: str) -> int:
try:
return int(settings.get(key, DEFAULTS[key]))
except (TypeError, ValueError):
return int(DEFAULTS[key])
def to_access_cfg(settings: dict[str, Any]) -> dict:
"""app.config keys for sessions, logins and the trusted proxy."""
from datetime import timedelta
hours = max(1, _int(settings, "session.lifetime_hours"))
return {
"SESSION_LIFETIME_HOURS": hours,
"PERMANENT_SESSION_LIFETIME": timedelta(hours=hours),
"TRUSTED_PROXY_HOPS": max(0, _int(settings, "general.trusted_proxy_hops")),
"LOGIN_MAX_FAILURES": max(1, _int(settings, "login.max_failures")),
"LOGIN_LOCKOUT_MINUTES": max(1, _int(settings, "login.lockout_minutes")),
}
def to_thresholds_cfg(settings: dict[str, Any]) -> dict:
"""Per-metric (warn, crit, direction) policy for degraded-value coloring.