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 %}
{% 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 @@ +