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
+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()