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,50 @@
|
||||
"""What the request looked like before the reverse proxy.
|
||||
|
||||
Steward serves plain HTTP; TLS ends at the operator's proxy (Traefik, often
|
||||
with Cloudflare in front). The proxy reports the original scheme and client
|
||||
in X-Forwarded-Proto / X-Forwarded-For, but anyone can send those headers,
|
||||
so they are believed only as far as the configured number of trusted proxy
|
||||
hops (Settings → General, default 1). 0 means no proxy is trusted.
|
||||
|
||||
Each proxy appends to the header, so the value the nearest trusted proxy
|
||||
wrote is the hops-th from the right. HTTPS-only behaviour (the session
|
||||
cookie's Secure flag, HSTS) turns on per request from is_https(); nothing
|
||||
here ever redirects to https.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from quart import current_app, request
|
||||
|
||||
|
||||
def trusted_value(header_values: list[str], hops: int) -> str | None:
|
||||
"""The value written by the outermost trusted proxy, or None when there
|
||||
are fewer values than trusted hops (or hops is 0)."""
|
||||
if hops <= 0:
|
||||
return None
|
||||
values = [v.strip() for line in header_values for v in line.split(",") if v.strip()]
|
||||
if len(values) < hops:
|
||||
return None
|
||||
return values[-hops]
|
||||
|
||||
|
||||
def _hops() -> int:
|
||||
try:
|
||||
return max(0, int(current_app.config.get("TRUSTED_PROXY_HOPS", 1)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def is_https() -> bool:
|
||||
"""True when this request reached Steward over HTTPS, directly or as
|
||||
reported by a trusted proxy."""
|
||||
if request.scheme == "https":
|
||||
return True
|
||||
proto = trusted_value(request.headers.getlist("X-Forwarded-Proto"), _hops())
|
||||
return (proto or "").lower() == "https"
|
||||
|
||||
|
||||
def client_ip() -> str:
|
||||
"""The client's address as the outermost trusted proxy saw it, else the
|
||||
socket peer."""
|
||||
forwarded = trusted_value(request.headers.getlist("X-Forwarded-For"), _hops())
|
||||
return forwarded or (request.remote_addr or "")
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user