diff --git a/steward/app.py b/steward/app.py index 7828a80..ec96e66 100644 --- a/steward/app.py +++ b/steward/app.py @@ -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). diff --git a/steward/auth/middleware.py b/steward/auth/middleware.py index 3ae0855..f0842d0 100644 --- a/steward/auth/middleware.py +++ b/steward/auth/middleware.py @@ -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: diff --git a/steward/auth/routes.py b/steward/auth/routes.py index 11331ac..e5175e9 100644 --- a/steward/auth/routes.py +++ b/steward/auth/routes.py @@ -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() diff --git a/steward/auth/throttle.py b/steward/auth/throttle.py new file mode 100644 index 0000000..f06cf73 --- /dev/null +++ b/steward/auth/throttle.py @@ -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() diff --git a/steward/core/proxy.py b/steward/core/proxy.py new file mode 100644 index 0000000..1c4c063 --- /dev/null +++ b/steward/core/proxy.py @@ -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 "") diff --git a/steward/core/settings.py b/steward/core/settings.py index 6ce99b7..c63db3b 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -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. diff --git a/steward/settings/routes.py b/steward/settings/routes.py index a513009..4bd7706 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -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", diff --git a/steward/templates/base.html b/steward/templates/base.html index f74a2c6..09eec31 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -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 %}
- ⏻ {{ session.username }} +
{{ csrf_field() }} + +
{% endif %} diff --git a/steward/templates/settings/general.html b/steward/templates/settings/general.html index 6a526c5..b5363fa 100644 --- a/steward/templates/settings/general.html +++ b/steward/templates/settings/general.html @@ -21,10 +21,35 @@ +
+ + +
+ 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. +
+
+
+
A login lasts this long; then Steward asks for the password again.
+
+ +
+ +
+ + failures from one address pause its logins for + + minutes +
diff --git a/tests/integration/test_api_tokens.py b/tests/integration/test_api_tokens.py index 4e3854d..58f49f0 100644 --- a/tests/integration/test_api_tokens.py +++ b/tests/integration/test_api_tokens.py @@ -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/") diff --git a/tests/integration/test_mcp_plugin_tools.py b/tests/integration/test_mcp_plugin_tools.py index 92b14c4..bea44a4 100644 --- a/tests/integration/test_mcp_plugin_tools.py +++ b/tests/integration/test_mcp_plugin_tools.py @@ -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 diff --git a/tests/test_sessions.py b/tests/test_sessions.py new file mode 100644 index 0000000..5c2bcf9 --- /dev/null +++ b/tests/test_sessions.py @@ -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