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