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
+11 -4
View File
@@ -11,7 +11,7 @@ from steward.core.settings import (
get_all_settings, set_setting, delete_setting,
get_stored_plugin_names, find_orphaned_plugins,
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg,
to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg, to_access_cfg, DEFAULTS,
)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
@@ -62,8 +62,8 @@ async def _reload_app_config() -> None:
"""Reload app.config from DB so settings take effect without restart."""
async with current_app.db_sessionmaker() as db:
fresh = await get_all_settings(db)
current_app.config.update(to_access_cfg(fresh))
current_app.config.update(
SESSION_LIFETIME_HOURS=fresh.get("session.lifetime_hours", 8),
DATA_RETENTION_DAYS=fresh.get("data.retention_days", 90),
MONITORS_POLL_INTERVAL=fresh.get("monitors.poll_interval_seconds", 60),
PUBLIC_BASE_URL=fresh.get("general.public_base_url", ""),
@@ -99,12 +99,19 @@ async def general():
@require_role(UserRole.admin)
async def save_general():
form = await request.form
whole: dict[str, int] = {}
for key in ("session.lifetime_hours", "general.trusted_proxy_hops",
"login.max_failures", "login.lockout_minutes"):
try:
whole[key] = int(form.get(key, DEFAULTS[key]))
except (TypeError, ValueError):
return f"{key} must be a whole number", 400
async with current_app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "general.public_base_url",
(form.get("general.public_base_url", "") or "").strip())
await set_setting(db, "session.lifetime_hours",
int(form.get("session.lifetime_hours", 8)))
for key, value in whole.items():
await set_setting(db, key, value)
await set_setting(db, "data.retention_days",
int(form.get("data.retention_days", 90)))
await set_setting(db, "monitors.poll_interval_seconds",