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
+2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import os
import re
import time
import uuid
from datetime import datetime, timedelta, timezone
@@ -133,6 +134,7 @@ def test_routes_create_list_and_revoke(app):
sess["user_role"] = "operator"
sess["username"] = "route-test"
sess["_csrf"] = "test-token"
sess["login_at"] = time.time()
csrf = {"X-CSRF-Token": "test-token"}
resp = await client.get("/ai-access/")
+9 -1
View File
@@ -11,6 +11,7 @@ import asyncio
import importlib
import json
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
@@ -253,9 +254,16 @@ def test_docker_and_agent_tools_over_seeded_rows(app):
assert not any(subject == host_name for _, subject in agent_found)
# ── the web pages say the same ─────────────────────────────────────
# A real viewer: every request re-reads the session's user (refresh_session_user).
from steward.models.users import User, UserRole
viewer_id = str(uuid.uuid4())
async with app.db_sessionmaker() as s, s.begin():
s.add(User(id=viewer_id, username=f"viewer-{viewer_id[:8]}", email=f"{viewer_id}@test",
password_hash="x", role=UserRole.viewer, is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess["user_id"], sess["user_role"], sess["username"] = "u", "viewer", "viewer"
sess["user_id"], sess["user_role"], sess["username"] = viewer_id, "viewer", "viewer"
sess["login_at"] = time.time()
topo = await (await client.get("/plugins/docker/swarm")).get_data(as_text=True)
assert svc in topo and "1 task failed in the last 24h" in topo
assert "task: non-zero exit (1)" in topo