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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user