#5508 "Every other email through the outbox: weekly report, invites, password resets, and the test buttons" (milestone 501). - outbox.send_each queues one email per reader (claimed, so the sender doesn't take it too) and tries them at once, for a page that answers with what happened; outbox.said words the result. attempt takes a cfg for a test of the form's unsaved settings and returns the settled rows. - Weekly report (scheduled and Send now) and invites: queued, tried at once; one the server couldn't take yet is retried for an hour. An invite that didn't go shows its link with the reason, as before. - Password reset: queued in the reset link's own transaction; the sender delivers it on its next tick. - Settings -> Notifications test email and a rule's Send test: queued and recorded with retry=False, tried once (System 156 ruling). - Removed mail.send, notifications._send_email and _smtp_send_sync; dispatch_notifications is webhook only. A guard asserts only the outbox (and smtp_open itself) opens an SMTP connection, with a self-test. Also fixes run 8941 (#5507's integration test): its Also-send-to addresses were "@test", which is_real_address rightly drops; they are example.com now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
535 lines
24 KiB
Python
535 lines
24 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"))
|
|
|
|
|
|
# ── 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"))
|