Files
FabledSteward/steward/auth/routes.py
T
bvandeusenandClaude Opus 5.5 e445c83e67
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m28s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m17s
feat(backup): restore at first-run setup, restore --in-place, docs lead with the UI (#5818, #5819)
#5818 "First-run setup: Restore from a backup, by upload": beside creating
the first admin, setup offers "Or restore from a backup". Only while no
user exists; the upload is checked (a newer backup or one whose key can't
open its secrets is refused and not kept), then restored with no safety
backup (nothing to keep), and Steward restarts into the backup's users.

#5819 "Docs and CI": backups.md leads with restoring from Settings →
Backups and from setup; `steward restore --in-place` does the same-schema
restore from the shell, and CI's image check now uses it with a real
pg_restore: a changed setting comes back and the audit log stays.

Also exempts errors/restoring.html from the breadcrumb guard, as with
the other whole-app error pages: it replaces every page while a restore
runs, so there is nowhere to trail back to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:05:39 -04:00

579 lines
26 KiB
Python

from __future__ import annotations
from datetime import datetime, timezone
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__)
_SOURCE_LABEL = {"ldap": "LDAP", "oidc": "SSO"}
async def _provision_external_user(app, source: str, subject: str, username: str,
email: str, role_str: str) -> tuple[User | None, str | None]:
"""The Steward user for an identity a provider vouched for: (user, None),
or (None, the reason to show at the login page).
Found by (source, subject) only. A first sign-in creates the user with an
unusable password; a username or email another account already holds is
refused rather than merged, so a directory entry named `admin` cannot sign
in as the local admin. The role follows the provider's groups on every
sign-in; a user disabled in Steward stays out.
"""
import secrets
from sqlalchemy.exc import IntegrityError
label = _SOURCE_LABEL[source]
if not subject or len(subject) > 512 or not username or len(username) > 64:
return None, f"{label} returned a username or id Steward cannot store (at most 64 characters)."
new_role = UserRole(role_str)
async with app.db_sessionmaker() as db:
try:
async with db.begin():
user = (await db.execute(select(User).where(
User.auth_source == source, User.external_id == subject))).scalar_one_or_none()
if user is not None:
if not user.is_active:
return None, "This account is disabled in Steward."
user.role = new_role
else:
email = email or f"{username}@{source}"
clash = (await db.execute(select(User.username).where(
(User.username == username) | (User.email == email)))).first()
if clash is not None:
return None, (
f"Steward already has an account named {clash[0]} with this username "
f"or email, and it does not sign in with {label}. An admin must rename "
f"or remove it before this {label} account can sign in.")
user = User(username=username, email=email, role=new_role,
password_hash=bcrypt.hashpw(secrets.token_bytes(32),
bcrypt.gensalt()).decode(),
auth_source=source, external_id=subject)
db.add(user)
except IntegrityError:
# Another sign-in created the same name between our check and insert.
return None, f"Could not create the {label} account; try signing in again."
db.expunge(user)
return user, None
async def _login_refused(message: str, next_url: str | None = None):
"""The login page with why a provider-vouched sign-in was still refused."""
from quart import current_app
return await render_template(
"auth/login.html", error=message,
oidc_enabled=current_app.config.get("OIDC", {}).get("enabled", False),
next_url=safe_next_url(next_url)), 403
async def get_user_count(app) -> int:
async with app.db_sessionmaker() as db:
result = await db.execute(select(func.count()).select_from(User))
return result.scalar_one()
async def get_user_by_username(app, username: str) -> User | None:
async with app.db_sessionmaker() as db:
result = await db.execute(select(User).where(User.username == username))
return result.scalar_one_or_none()
@auth_bp.get("/login")
@public_route("the login page")
async def login():
from quart import current_app
if await get_user_count(current_app) == 0:
return redirect(url_for("auth.setup"))
oidc_cfg = current_app.config.get("OIDC", {})
return await render_template("auth/login.html",
oidc_enabled=oidc_cfg.get("enabled", False),
next_url=safe_next_url(request.args.get("next")))
@auth_bp.post("/login")
@public_route("the login form")
async def login_post():
from quart import current_app
from steward.core.audit import log_audit
form = await request.form
username = form.get("username", "").strip()
password_str = form.get("password", "")
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.auth_source == "local" 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})
return redirect(dest)
# Try LDAP fallback if enabled
ldap_cfg = current_app.config.get("LDAP", {})
if ldap_cfg.get("enabled"):
from steward.auth.ldap_auth import ldap_authenticate
ldap_info = await ldap_authenticate(ldap_cfg, username, password_str)
if ldap_info:
user, refusal = await _provision_external_user(
current_app, "ldap", ldap_info["subject"], ldap_info["username"],
ldap_info["email"], ldap_info["role"])
if refusal:
return await _login_refused(refusal, form.get("next"))
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",
error="Invalid credentials",
oidc_enabled=oidc_cfg.get("enabled", False),
next_url=safe_next_url(form.get("next")),
), 400
@auth_bp.get("/login/oidc")
@public_route("starts SSO login")
async def login_oidc():
from quart import current_app
from steward.auth.oidc import get_discovery, build_authorize_url, generate_state
oidc_cfg = current_app.config.get("OIDC", {})
if not oidc_cfg.get("enabled") or not oidc_cfg.get("discovery_url"):
return redirect(url_for("auth.login"))
try:
doc = await get_discovery(oidc_cfg["discovery_url"])
except Exception as exc:
return await render_template("auth/login.html", error=f"OIDC discovery failed: {exc}",
oidc_enabled=True), 502
state = generate_state()
nonce = generate_state()
session["oidc_state"] = state
session["oidc_nonce"] = nonce
session["oidc_next"] = safe_next_url(request.args.get("next"))
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
url = build_authorize_url(
doc, oidc_cfg["client_id"], redirect_uri,
oidc_cfg.get("scopes", "openid profile email"), state, nonce,
)
return redirect(url)
@auth_bp.get("/login/oidc/callback")
@public_route("SSO login callback")
async def login_oidc_callback():
from quart import current_app
from steward.auth.oidc import (get_discovery, exchange_code, get_userinfo, map_role,
id_token_refusal)
from steward.core.audit import log_audit
oidc_cfg = current_app.config.get("OIDC", {})
error = request.args.get("error")
if error:
desc = request.args.get("error_description", error)
return await render_template("auth/login.html", error=f"SSO error: {desc}",
oidc_enabled=True), 400
code = request.args.get("code", "")
state = request.args.get("state", "")
if not code or state != session.pop("oidc_state", None):
return await render_template("auth/login.html", error="Invalid SSO state — try again.",
oidc_enabled=True), 400
nonce = session.pop("oidc_nonce", None)
try:
doc = await get_discovery(oidc_cfg["discovery_url"])
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
tokens = await exchange_code(
doc, oidc_cfg["client_id"], oidc_cfg["client_secret"], code, redirect_uri
)
userinfo = await get_userinfo(doc, tokens["access_token"])
except Exception as exc:
return await render_template("auth/login.html", error=f"SSO login failed: {exc}",
oidc_enabled=True), 502
refusal = id_token_refusal(tokens.get("id_token", ""), nonce, userinfo.get("sub", ""))
if refusal:
return await render_template("auth/login.html", error=refusal, oidc_enabled=True), 400
username_claim = oidc_cfg.get("username_claim", "preferred_username")
email_claim = oidc_cfg.get("email_claim", "email")
groups_claim = oidc_cfg.get("groups_claim", "groups")
subject = userinfo["sub"]
username = userinfo.get(username_claim) or subject
email = userinfo.get(email_claim, "")
groups = userinfo.get(groups_claim, [])
if not isinstance(groups, list):
groups = []
role = map_role(groups, oidc_cfg.get("admin_group", ""), oidc_cfg.get("operator_group", ""))
user, refusal = await _provision_external_user(current_app, "oidc", subject, username, email, role)
if refusal:
return await _login_refused(refusal, session.pop("oidc_next", None))
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.login",
detail={"method": "oidc", "role": role})
dest = safe_next_url(session.pop("oidc_next", None)) or url_for("dashboard.index")
return redirect(dest)
@auth_bp.post("/logout")
@public_route("ends the session")
async def logout():
logout_user()
return redirect(url_for("auth.login"))
@auth_bp.get("/setup")
@public_route("first-run setup (refuses once a user exists)")
async def setup():
from quart import current_app
if await get_user_count(current_app) > 0:
return redirect(url_for("auth.login"))
# Pre-fill the public URL with however the operator reached this page — the
# common case is correct, and it's what agent installs / share links use.
return await render_template(
"auth/setup.html", default_url=request.host_url.rstrip("/"))
@auth_bp.post("/setup")
@public_route("first-run setup (refuses once a user exists)")
async def setup_post():
from quart import current_app
from steward.core.audit import log_audit
if await get_user_count(current_app) > 0:
return redirect(url_for("auth.login"))
form = await request.form
username = form.get("username", "").strip()
email = form.get("email", "").strip()
password = form.get("password", "").encode()
if not username or not email or not password:
return await render_template("auth/setup.html", error="All fields required"), 400
pw_hash = bcrypt.hashpw(password, bcrypt.gensalt()).decode()
user = User(username=username, email=email, password_hash=pw_hash, role=UserRole.admin)
steward_url = (form.get("public_base_url", "") or "").strip().rstrip("/")
async with current_app.db_sessionmaker() as db:
async with db.begin():
db.add(user)
if steward_url:
from steward.core.settings import set_setting
await set_setting(db, "general.public_base_url", steward_url)
if steward_url:
# Apply immediately so agent installs / share links use it without a restart.
current_app.config["PUBLIC_BASE_URL"] = steward_url
login_user(user)
await log_audit(current_app, user.id, user.username, "auth.setup",
detail={"username": username})
return redirect(url_for("dashboard.index"))
@auth_bp.post("/setup/restore")
@public_route("first-run setup's restore (refuses once a user exists)")
async def setup_restore():
"""Bring a new Steward up from a backup instead of an empty start (#5818):
only while no user exists, so there is nothing here to lose and no
safety backup to take. The upload's size limit is raised in
auth/csrf.py UPLOAD_LIMITS. The restore then runs as from Settings →
Backups (backup/restoring.py) and Steward restarts into the backup's
users."""
import shutil
from pathlib import Path
from quart import current_app
from steward.backup import job, restoring
from steward.backup.dump import BackupError
if await get_user_count(current_app) > 0:
return redirect(url_for("auth.login"))
async def refused(error: str):
return await render_template("auth/setup.html", default_url=request.host_url.rstrip("/"),
restore_error=error), 400
upload = (await request.files).get("file")
if upload is None or not upload.filename:
return await refused("Choose a backup file to restore.")
name = Path(upload.filename).name
held = restoring.stage()
await upload.save(held.path)
try:
report = await job.check_file(current_app._get_current_object(), held.path, name)
except BackupError as exc:
shutil.rmtree(held.dir, ignore_errors=True)
return await refused(str(exc))
if report.schema == "newer" or not report.ok:
shutil.rmtree(held.dir, ignore_errors=True)
return await refused("This Steward is older than the one that made the backup; update Steward, "
"then restore it." if report.schema == "newer" else report.summary())
if not restoring.start_restore(current_app._get_current_object(), held.path, name,
username="first-run setup", replace_all=report.schema == "older",
owned_dir=held.dir, safety=False):
return await refused("A restore is already under way.")
return redirect(url_for("auth.setup"))
# ── Invites (milestone 496) ──────────────────────────────────────────────────
_INVITE_GONE = ("This invite link has expired, been used or been withdrawn. "
"Ask whoever sent it for a new one.")
async def _invite_page(key: str, invite=None, inviter: str = "", error: str | None = None,
form=None, status: int = 200):
"""The invite page: the form while `invite` works, else one plain message
that doesn't say whether the key was unknown, used, revoked or expired."""
if invite is None and error is None:
error = _INVITE_GONE
return await render_template(
"auth/invite.html", key=key, invite=invite, inviter=inviter, error=error,
form=form or {}, min_password=_min_password()), status
def _min_password() -> int:
from steward.settings.users import MIN_PASSWORD
return MIN_PASSWORD
async def _inviter_name(db, invite) -> str:
if invite.invited_by is None:
return "An admin"
who = await db.get(User, invite.invited_by)
return who.username if who else "An admin"
def _too_many_tries() -> str | None:
"""Why this address may not try an invite or reset key now (too many bad
ones), or None."""
from quart import current_app
wait_s = login_throttle.retry_after(
client_ip(), current_app.config.get("LOGIN_MAX_FAILURES", 5),
current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) * 60)
if not wait_s:
return None
minutes = max(1, round(wait_s / 60))
return f"Too many tries from this address. Try again in {minutes} minute{'s' if minutes != 1 else ''}."
@auth_bp.get("/invite/<key>")
@public_route("an invite link: the key is the credential")
async def invite(key: str):
from quart import current_app
from steward.core.invites import find_pending
if wait := _too_many_tries():
return await _invite_page(key, error=wait, status=429)
async with current_app.db_sessionmaker() as db:
found = await find_pending(db, key)
inviter = await _inviter_name(db, found) if found else ""
if found is None:
login_throttle.failed(client_ip())
return await _invite_page(key, status=404)
return await _invite_page(key, found, inviter, form={"email": found.email})
@auth_bp.post("/invite/<key>")
@public_route("an invite link: the key is the credential")
async def invite_accept(key: str):
"""Make the invitee's local account from their own username, email and
password, with the role the admin chose; use the key up and sign them in,
in one transaction, so a key makes one account."""
from quart import current_app
from sqlalchemy.exc import IntegrityError
from steward.core.audit import log_audit
from steward.core.invites import find_pending, signup_problem, taken
from steward.settings.users import _hash
if wait := _too_many_tries():
return await _invite_page(key, error=wait, status=429)
form = await request.form
username = form.get("username") or ""
email = (form.get("email") or "").strip()
password, confirm = form.get("password") or "", form.get("confirm") or ""
shown = {"username": username, "email": email}
try:
async with current_app.db_sessionmaker() as db:
async with db.begin():
found = await find_pending(db, key, lock=True)
if found is None:
login_throttle.failed(client_ip())
return await _invite_page(key, status=404)
inviter = await _inviter_name(db, found)
problem = signup_problem(username, email, password, confirm) or await taken(db, username, email)
if problem:
return await _invite_page(key, found, inviter, problem, shown, 400)
user = User(username=username, email=email, role=found.role, auth_source="local",
password_hash=_hash(password))
db.add(user)
await db.flush()
found.used_at = datetime.now(timezone.utc)
found.used_by = user.id
invite_id, role = found.id, found.role.value
db.expunge(user)
except IntegrityError:
async with current_app.db_sessionmaker() as db:
found = await find_pending(db, key)
inviter = await _inviter_name(db, found) if found else ""
if found is None:
return await _invite_page(key, status=404)
return await _invite_page(key, found, inviter,
"That username or email was just taken. Pick another.", shown, 400)
login_user(user)
await log_audit(current_app, user.id, user.username, "user.joined", "user", user.id,
detail={"username": user.username, "role": role, "invite": invite_id,
"invited_by": inviter})
return redirect(url_for("dashboard.index"))
# ── Reset a password by email (milestone 497) ────────────────────────────────
_RESET_GONE = "This reset link has expired or been used. Ask for a new one."
_RESET_SENT = ("If that names a Steward account with a password and an email address, "
"a link to choose a new password is on its way to that address. It works for an hour.")
def _mail_ready() -> bool:
from quart import current_app
return bool(current_app.config.get("SMTP", {}).get("host"))
async def _forgot_page(sent: bool = False, error: str | None = None, status: int = 200):
return await render_template("auth/forgot.html", mail_ready=_mail_ready(), sent=sent,
error=error), status
@auth_bp.get("/forgot")
@public_route("asks for a password-reset email")
async def forgot():
return await _forgot_page()
@auth_bp.post("/forgot")
@public_route("asks for a password-reset email")
async def forgot_post():
"""Email a reset link to the account the form names, if it may have one.
The answer is the same either way, so the page can't be used to find
accounts; every request from an address counts toward its limit."""
from quart import current_app
from steward.auth.throttle import reset_throttle
from steward.core import mail, outbox
from steward.core.audit import log_audit
from steward.core.formatting import zone
from steward.core.password_resets import account_for, issue, link
from steward.core.settings import public_base_url
if not _mail_ready():
return await _forgot_page(status=400)
ip = client_ip()
if reset_throttle.retry_after(ip, current_app.config.get("LOGIN_MAX_FAILURES", 5),
current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) * 60):
return await _forgot_page(error="Too many requests from this address. Try again later.", status=429)
reset_throttle.failed(ip)
name = (await request.form).get("name") or ""
async with current_app.db_sessionmaker() as db:
async with db.begin():
user = await account_for(db, name)
if user is not None:
reset, key = await issue(db, user)
username, user_id = user.username, user.id
to = mail.Recipient(user.email, zone(user.time_zone), False, username)
subject, text, html = mail.reset_email(username, reset.expires_at,
link(public_base_url(request), key), to.tz)
# Sent by the outbox, with the link's own transaction; the page
# says the same either way, and the queue records how it went.
outbox.enqueue(db, kind="reset", to=to, subject=subject, text=text, html=html)
if user is None:
return await _forgot_page(sent=True)
await log_audit(current_app, user_id, username, "user.password_reset_requested", "user", user_id,
detail={"address": ip})
return await _forgot_page(sent=True)
async def _reset_page(key: str, username: str | None = None, error: str | None = None,
status: int = 200):
"""The new-password form while the link works, else one plain message."""
if username is None and error is None:
error = _RESET_GONE
return await render_template("auth/reset.html", key=key, username=username, error=error,
min_password=_min_password()), status
async def _reset_target(db, key: str, lock: bool = False):
"""(link, account) for a working key whose account may still be reset by
email; (None, None) otherwise."""
from steward.core.password_resets import find_pending
found = await find_pending(db, key, lock=lock)
user = await db.get(User, found.user_id) if found else None
if user is None or not user.is_active or user.auth_source != "local":
return None, None
return found, user
@auth_bp.get("/reset/<key>")
@public_route("a password-reset link: the key is the credential")
async def reset(key: str):
from quart import current_app
if wait := _too_many_tries():
return await _reset_page(key, error=wait, status=429)
async with current_app.db_sessionmaker() as db:
_, user = await _reset_target(db, key)
if user is None:
login_throttle.failed(client_ip())
return await _reset_page(key, status=404)
return await _reset_page(key, user.username)
@auth_bp.post("/reset/<key>")
@public_route("a password-reset link: the key is the credential")
async def reset_post(key: str):
"""Set the new password, use the link up, sign out every browser signed
in as the account and sign this one in, in one transaction."""
from quart import current_app
from steward.core.audit import log_audit
from steward.settings.users import _hash, password_problem
if wait := _too_many_tries():
return await _reset_page(key, error=wait, status=429)
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
found, user = await _reset_target(db, key, lock=True)
if user is None:
login_throttle.failed(client_ip())
return await _reset_page(key, status=404)
problem = password_problem(form.get("password") or "", form.get("confirm") or "")
if problem:
return await _reset_page(key, user.username, problem, 400)
now = datetime.now(timezone.utc)
user.password_hash = _hash(form.get("password"))
user.sessions_since = now
found.used_at = now
db.expunge(user)
login_throttle.succeeded(client_ip())
login_user(user)
await log_audit(current_app, user.id, user.username, "user.password_reset_by_email", "user", user.id,
detail={"username": user.username})
return redirect(url_for("dashboard.index"))