#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>
157 lines
5.9 KiB
Python
157 lines
5.9 KiB
Python
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
|
|
|
|
_ROLE_ORDER = [UserRole.viewer, UserRole.operator, UserRole.admin]
|
|
|
|
# Paths we never bounce back to after login (would loop or make no sense).
|
|
_NO_RETURN = {"/login", "/logout", "/setup"}
|
|
|
|
|
|
def role_meets(user_role: UserRole, minimum_role: UserRole) -> bool:
|
|
"""True when user_role is at least minimum_role in the viewer<operator<admin order."""
|
|
return _ROLE_ORDER.index(user_role) >= _ROLE_ORDER.index(minimum_role)
|
|
|
|
|
|
def safe_next_url(raw: str | None) -> str | None:
|
|
"""Return a safe same-site relative path+query from `raw`, else None.
|
|
|
|
Accepts a relative path or a full URL (e.g. HTMX's HX-Current-URL) and
|
|
reduces it to path[?query]. Rejects off-site/protocol-relative targets and
|
|
the auth pages themselves, so it can't be abused as an open redirect.
|
|
"""
|
|
if not raw:
|
|
return None
|
|
parts = urlsplit(raw)
|
|
# Protocol-relative (//host/…) has a netloc but no scheme — reject it; a full
|
|
# same-site URL (HX-Current-URL) has a scheme and we keep only its path.
|
|
if parts.netloc and not parts.scheme:
|
|
return None
|
|
path = parts.path
|
|
if parts.query:
|
|
path += "?" + parts.query
|
|
if not path.startswith("/") or path.startswith("//") or path.startswith("/\\"):
|
|
return None
|
|
if path.split("?", 1)[0] in _NO_RETURN:
|
|
return None
|
|
return path
|
|
|
|
|
|
def _login_redirect():
|
|
"""Redirect an unauthenticated request to /login, preserving where to return.
|
|
|
|
For HTMX requests the destination is the page the user is on (HX-Current-URL)
|
|
and we use HX-Redirect so the whole browser navigates (not a fragment swap);
|
|
for normal requests it's the requested path.
|
|
"""
|
|
is_htmx = request.headers.get("HX-Request") == "true"
|
|
nxt = safe_next_url(request.headers.get("HX-Current-URL")) if is_htmx else None
|
|
if nxt is None:
|
|
nxt = safe_next_url(request.full_path)
|
|
login_url = url_for("auth.login")
|
|
if nxt:
|
|
login_url = f"{login_url}?next={quote(nxt, safe='/')}"
|
|
if is_htmx:
|
|
resp = Response("", status=401)
|
|
resp.headers["HX-Redirect"] = login_url
|
|
return resp
|
|
return redirect(login_url)
|
|
|
|
|
|
def require_role(minimum_role: UserRole):
|
|
"""Decorator: requires authenticated user with at least minimum_role.
|
|
Also allows access for validated share-token requests (viewer level only).
|
|
"""
|
|
def decorator(f):
|
|
@functools.wraps(f)
|
|
async def wrapper(*args, **kwargs):
|
|
# Share-token requests bypass session auth for viewer-level endpoints
|
|
if getattr(g, "share_viewer", False) and minimum_role == UserRole.viewer:
|
|
return await f(*args, **kwargs)
|
|
user_id = session.get("user_id")
|
|
if not user_id:
|
|
return _login_redirect()
|
|
user_role = UserRole(session.get("user_role", "viewer"))
|
|
if _ROLE_ORDER.index(user_role) < _ROLE_ORDER.index(minimum_role):
|
|
abort(403)
|
|
return await f(*args, **kwargs)
|
|
wrapper.steward_role = minimum_role
|
|
return wrapper
|
|
return decorator
|
|
|
|
|
|
def public_route(reason: str):
|
|
"""Mark a view that is deliberately reachable without a role — login,
|
|
the health probe, a bearer-authenticated endpoint. Every other view must
|
|
carry require_role; check_view_is_marked refuses one that has neither, so
|
|
a route that forgets its decorator is closed rather than open."""
|
|
def decorator(f):
|
|
f.steward_public = reason
|
|
return f
|
|
return decorator
|
|
|
|
|
|
async def check_view_is_marked() -> Response | None:
|
|
"""before_request hook: default-deny for views with neither require_role
|
|
nor public_route. Static files and unmatched paths (404) pass through."""
|
|
from quart import current_app
|
|
endpoint = request.endpoint
|
|
if endpoint is None or endpoint == "static" or endpoint.endswith(".static"):
|
|
return None
|
|
view = current_app.view_functions.get(endpoint)
|
|
if view is None or hasattr(view, "steward_role") or hasattr(view, "steward_public"):
|
|
return None
|
|
current_app.logger.error("Refused %s: view %r has no require_role or public_route",
|
|
request.path, endpoint)
|
|
abort(403)
|
|
|
|
|
|
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:
|
|
session.clear()
|
|
|
|
|
|
def current_user_id() -> str | None:
|
|
return session.get("user_id")
|