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:
|
||||
|
||||
+23
-1
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user