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">