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
+29 -4
View File
@@ -90,11 +90,11 @@ def create_app(
from .core.settings import (
load_settings_sync, to_smtp_cfg, to_webhook_cfg,
to_ansible_cfg, to_plugins_cfg, to_oidc_cfg, to_ldap_cfg,
to_thresholds_cfg,
to_thresholds_cfg, to_access_cfg,
)
_settings = load_settings_sync(app.config["DATABASE_URL"])
app.config.update(to_access_cfg(_settings))
app.config.update(
SESSION_LIFETIME_HOURS=_settings.get("session.lifetime_hours", 8),
DATA_RETENTION_DAYS=_settings.get("data.retention_days", 90),
MONITORS_POLL_INTERVAL=_settings.get("monitors.poll_interval_seconds", 60),
PUBLIC_BASE_URL=_settings.get("general.public_base_url", ""),
@@ -107,9 +107,9 @@ def create_app(
THRESHOLDS=to_thresholds_cfg(_settings),
)
else:
from .core.settings import to_thresholds_cfg
from .core.settings import to_access_cfg, to_thresholds_cfg
app.config.update(to_access_cfg({}))
app.config.update(
SESSION_LIFETIME_HOURS=8,
DATA_RETENTION_DAYS=90,
MONITORS_POLL_INTERVAL=60,
PUBLIC_BASE_URL="",
@@ -137,6 +137,26 @@ def create_app(
app.jinja_env.globals["csrf_token"] = _csrf.csrf_token
app.jinja_env.globals["csrf_field"] = _csrf.csrf_field
app.before_request(_csrf.check_request)
# The session cookie is Secure, and HSTS is sent, only on requests that
# arrived over HTTPS — directly or as a trusted proxy reports it
# (steward/core/proxy.py). Plain-HTTP and LAN use keep working.
from quart import has_request_context
from quart.sessions import SecureCookieSessionInterface
from .core.proxy import is_https
class _SessionInterface(SecureCookieSessionInterface):
def get_cookie_secure(self, app):
return bool(app.config.get("SESSION_COOKIE_SECURE")) or (
has_request_context() and is_https())
app.session_interface = _SessionInterface()
@app.after_request
async def _hsts(response):
if is_https():
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000")
return response
# Default-deny: a view with neither require_role nor public_route is refused.
from .auth.middleware import check_view_is_marked, public_route
app.before_request(check_view_is_marked)
@@ -289,6 +309,11 @@ def create_app(
async def server_error(_):
return await render_template("errors/500.html"), 500
# ── Session freshness: lifetime, deactivation and role changes apply per request
if not testing:
from .auth.middleware import refresh_session_user
app.before_request(refresh_session_user)
# ── MCP endpoint (/mcp) ───────────────────────────────────────────────────
# Mounted after plugins load so their register_mcp_tools() hooks are seen.
# It wraps app.asgi_app: /mcp never enters Quart's pipeline (see mcp/server.py).
+31
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import functools
import time
from urllib.parse import quote, urlsplit
from quart import session, redirect, url_for, abort, g, request, Response
from steward.models.users import UserRole
@@ -112,9 +113,39 @@ def login_user(user) -> None:
"""Write user identity into the session, with a fresh CSRF token."""
from steward.auth.csrf import rotate
rotate()
session.permanent = True
session["user_id"] = user.id
session["user_role"] = user.role.value
session["username"] = user.username
session["login_at"] = time.time()
async def refresh_session_user() -> None:
"""before_request: a session speaks for the user as they are now.
A login ends once Settings → General's session lifetime has passed since
it began (not since the last request: the cookie is re-signed on every
response, so the cookie's own age would let an active session live
forever). A user who has been deleted or deactivated is logged out on
their next request, and a role change applies at once.
"""
user_id = session.get("user_id")
if not user_id:
return
from quart import current_app
from steward.models.users import User
lifetime = current_app.config.get("PERMANENT_SESSION_LIFETIME")
started = session.get("login_at") or 0
if lifetime is not None and time.time() - started > lifetime.total_seconds():
session.clear()
return
async with current_app.db_sessionmaker() as db:
user = await db.get(User, user_id)
if user is None or not user.is_active:
session.clear()
return
if session.get("user_role") != user.role.value:
session["user_role"] = user.role.value
def logout_user() -> None:
+23 -1
View File
@@ -3,6 +3,8 @@ import bcrypt
from quart import Blueprint, render_template, request, redirect, url_for, session
from sqlalchemy import select, func
from steward.auth.middleware import login_user, logout_user, public_route, safe_next_url
from steward.auth.throttle import login_throttle
from steward.core.proxy import client_ip
from steward.models.users import User, UserRole
auth_bp = Blueprint("auth", __name__)
@@ -70,9 +72,25 @@ async def login_post():
password = password_str.encode()
dest = safe_next_url(form.get("next")) or url_for("dashboard.index")
# Too many recent failures from this address: refuse before checking anything.
ip = client_ip()
wait_s = login_throttle.retry_after(
ip, current_app.config.get("LOGIN_MAX_FAILURES", 5),
current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) * 60)
if wait_s:
oidc_cfg = current_app.config.get("OIDC", {})
return await render_template(
"auth/login.html",
error=f"Too many failed logins from this address. Try again in "
f"{max(1, round(wait_s / 60))} minute{'s' if wait_s > 90 else ''}.",
oidc_enabled=oidc_cfg.get("enabled", False),
next_url=safe_next_url(form.get("next")),
), 429
# Try local auth first
user = await get_user_by_username(current_app, username)
if user and user.is_active and bcrypt.checkpw(password, user.password_hash.encode()):
login_throttle.succeeded(ip)
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.login",
detail={"method": "local", "role": user.role.value})
@@ -87,11 +105,15 @@ async def login_post():
user = await _provision_external_user(
current_app, ldap_info["username"], ldap_info["email"], ldap_info["role"]
)
login_throttle.succeeded(ip)
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.login",
detail={"method": "ldap", "role": user.role.value})
return redirect(dest)
login_throttle.failed(ip)
await log_audit(current_app, None, username or "(blank)", "auth.login_failed",
detail={"address": ip})
oidc_cfg = current_app.config.get("OIDC", {})
return await render_template(
"auth/login.html",
@@ -182,7 +204,7 @@ async def login_oidc_callback():
return redirect(dest)
@auth_bp.get("/logout")
@auth_bp.post("/logout")
@public_route("ends the session")
async def logout():
logout_user()
+48
View File
@@ -0,0 +1,48 @@
"""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()
+50
View File
@@ -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 "")
+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.
+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",
+8 -4
View File
@@ -93,15 +93,17 @@ code { font-family: ui-monospace, monospace; font-size: 0.875em; background: var
background: color-mix(in srgb, var(--gold) 8%, transparent);
}
.side-user { border-top: 1px solid var(--border); padding: 0.7rem 1.25rem; }
.side-user a {
.side-user form { margin: 0; }
.side-user button {
color: var(--text-muted); font-size: 0.84rem; text-decoration: none;
display: flex; align-items: center; gap: 0.4rem;
background: none; border: 0; padding: 0; font-family: inherit; cursor: pointer;
}
.side-user a:hover { color: var(--text); }
.side-user button:hover { color: var(--text); }
/* Keyboard focus: a clear inset ring on every interactive sidebar element. */
.sidebar .brand:focus-visible,
.side-nav a:focus-visible,
.side-user a:focus-visible {
.side-user button:focus-visible {
outline: 2px solid var(--accent); outline-offset: -2px; color: var(--text);
}
.app-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@@ -332,7 +334,9 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
{% endif %}
</nav>
<div class="side-user">
<a href="/logout" title="Log out">⏻ {{ session.username }}</a>
<form method="post" action="/logout">{{ csrf_field() }}
<button type="submit" title="Log out">⏻ {{ session.username }}</button>
</form>
</div>
</aside>
{% endif %}
+25
View File
@@ -21,10 +21,35 @@
</div>
</div>
<div class="form-group">
<label>Trusted reverse proxies</label>
<input type="number" name="general.trusted_proxy_hops" min="0" max="5"
value="{{ settings['general.trusted_proxy_hops'] }}" style="max-width:120px;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
How many proxies sit in front of Steward (e.g. 1 for Traefik, 2 for Cloudflare in front of Traefik).
Steward believes their X-Forwarded-For and X-Forwarded-Proto headers up to that many hops: it marks
the login cookie Secure and sends HSTS only when they report HTTPS, and throttles logins by the address
they report. 0 trusts no proxy.
</div>
</div>
<div class="form-group">
<label>Session lifetime <span style="color:var(--text-muted);font-size:0.8rem;">(hours)</span></label>
<input type="number" name="session.lifetime_hours" min="1" max="720"
value="{{ settings['session.lifetime_hours'] }}" style="max-width:120px;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">A login lasts this long; then Steward asks for the password again.</div>
</div>
<div class="form-group">
<label>Failed logins before a pause</label>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<input type="number" name="login.max_failures" min="1" max="100"
value="{{ settings['login.max_failures'] }}" style="max-width:90px;">
<span style="font-size:0.85rem;color:var(--text-muted);">failures from one address pause its logins for</span>
<input type="number" name="login.lockout_minutes" min="1" max="1440"
value="{{ settings['login.lockout_minutes'] }}" style="max-width:90px;">
<span style="font-size:0.85rem;color:var(--text-muted);">minutes</span>
</div>
</div>
<div class="form-group">
+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
+116
View File
@@ -0,0 +1,116 @@
"""Sessions: HTTPS-only flags follow the trusted proxy, logins are throttled
by address, logout is a POST, and a session speaks for the user as they are."""
import time
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from quart import session
from steward.auth.middleware import refresh_session_user
from steward.auth.throttle import LoginThrottle, login_throttle
from steward.core.proxy import trusted_value
from steward.models.users import UserRole
@pytest.mark.parametrize("values,hops,want", [
(["https"], 0, None),
(["https"], 1, "https"),
(["http, https"], 1, "https"),
(["evil", "https"], 1, "https"),
(["https, http"], 2, "https"),
(["https"], 2, None),
([], 1, None),
])
def test_trusted_value_reads_from_the_right(values, hops, want):
assert trusted_value(values, hops) == want
@pytest.mark.parametrize("hops,headers,secure", [
(0, {"X-Forwarded-Proto": "https"}, False),
(1, {"X-Forwarded-Proto": "https"}, True),
(1, {}, False),
])
async def test_secure_cookie_and_hsts_follow_the_trusted_proxy(app, client, hops, headers, secure):
app.config["TRUSTED_PROXY_HOPS"] = hops
response = await client.get("/login", headers=headers)
cookies = [c for c in response.headers.getlist("Set-Cookie") if c.startswith("session=")]
assert cookies, "the login page sets a session (its CSRF token)"
assert ("; Secure" in cookies[0]) is secure
assert ("Strict-Transport-Security" in response.headers) is secure
def test_throttle_counts_failures_within_the_window():
t = LoginThrottle()
for i in range(3):
assert t.retry_after("1.2.3.4", 3, 60, now=100 + i) == 0
t.failed("1.2.3.4", now=100 + i)
assert t.retry_after("1.2.3.4", 3, 60, now=103) > 0
assert t.retry_after("5.6.7.8", 3, 60, now=103) == 0
assert t.retry_after("1.2.3.4", 3, 60, now=161) == 0 # the first failure aged out
t.failed("1.2.3.4", now=161)
t.succeeded("1.2.3.4")
assert t.retry_after("1.2.3.4", 3, 60, now=162) == 0
@pytest.fixture
def fresh_throttle():
login_throttle._failures.clear()
yield login_throttle
login_throttle._failures.clear()
async def test_login_is_refused_after_repeated_failures(app, client, fresh_throttle):
app.config["LOGIN_MAX_FAILURES"] = 2
async with client.session_transaction() as sess:
sess["_csrf"] = "t"
form = {"username": "admin", "password": "wrong"}
with patch("steward.auth.routes.get_user_count", new=AsyncMock(return_value=1)), \
patch("steward.auth.routes.get_user_by_username", new=AsyncMock(return_value=None)):
for _ in range(2):
assert (await client.post("/login", form=form, headers={"X-CSRF-Token": "t"})).status_code == 400
refused = await client.post("/login", form=form, headers={"X-CSRF-Token": "t"})
assert refused.status_code == 429
assert "Too many failed logins" in await refused.get_data(as_text=True)
async def test_logout_is_a_post(client):
async with client.session_transaction() as sess:
sess.update(user_id="u", user_role="admin", username="a", _csrf="t")
assert (await client.get("/logout")).status_code == 405
response = await client.post("/logout", headers={"X-CSRF-Token": "t"})
assert response.status_code == 302
async with client.session_transaction() as sess:
assert "user_id" not in sess
class _DB:
def __init__(self, user):
self.user = user
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def get(self, model, key):
return self.user
@pytest.mark.parametrize("user,login_age_s,keeps,role", [
(SimpleNamespace(is_active=True, role=UserRole.viewer), 60, True, "viewer"), # demoted
(SimpleNamespace(is_active=False, role=UserRole.admin), 60, False, None), # deactivated
(None, 60, False, None), # deleted
(SimpleNamespace(is_active=True, role=UserRole.admin), 9 * 3600, False, None), # expired
])
async def test_session_speaks_for_the_user_as_they_are(app, user, login_age_s, keeps, role):
app.db_sessionmaker = lambda: _DB(user)
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=8)
async with app.test_request_context("/"):
session.update(user_id="u", user_role="admin", username="a",
login_at=time.time() - login_age_s)
await refresh_session_user()
assert ("user_id" in session) is keeps
assert session.get("user_role") == role