#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>
579 lines
26 KiB
Python
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"))
|