feat(auth): sessions end when they should; logins are throttled; Secure behind HTTPS (#5035)
#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:
+29
-4
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user