feat(account): change your own password on the Account page (milestone 497)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 1m26s

Step #5428 "Account page: change your own password" of milestone 497
"Passwords: change your own, and reset it by email". A Password section
in the page's one form, for local accounts: current, new, again. Blank
keeps the password; a new one signs out every other browser
(sessions_since) and keeps this one. A wrong current password counts
toward the login throttle. Audited as account.password_changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:14:45 -04:00
co-authored by Claude Opus 5.5
parent ea31deabf9
commit ea523b8c37
5 changed files with 204 additions and 11 deletions
+58 -7
View File
@@ -1,8 +1,13 @@
"""The signed-in user's own account (milestone 490): the time zone every
page writes its times in, and their email (milestone 495). Any role reaches
it, for themselves only."""
page writes its times in, their email (milestone 495) and, for a local
account, their password (milestone 497). Any role reaches it, for
themselves only."""
from __future__ import annotations
import time
from datetime import datetime, timezone
import bcrypt
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
from steward.auth.middleware import require_role
@@ -14,6 +19,38 @@ account_bp = Blueprint("account", __name__, url_prefix="/account")
_SOURCES = {"local": "a Steward password", "ldap": "LDAP", "oidc": "single sign-on"}
def _password_matches(user: User, password: str) -> bool:
try:
return bcrypt.checkpw(password.encode(), user.password_hash.encode())
except ValueError: # not a bcrypt hash: no password matches it
return False
def _change_password(user: User, form) -> bool:
"""Apply the Password section: False when it was left blank, True when the
password changed; ValueError says why it can't. Every other browser signed
in as this account is signed out (#5427)."""
from steward.auth.throttle import login_throttle
from steward.core.proxy import client_ip
from steward.settings.users import _hash, password_problem
new = form.get("new_password") or ""
if not new or user.auth_source != "local":
return False
ip = client_ip()
if login_throttle.retry_after(ip, current_app.config.get("LOGIN_MAX_FAILURES", 5),
current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15) * 60):
raise ValueError("Too many wrong passwords from this address. Try again later.")
if not _password_matches(user, form.get("current_password") or ""):
login_throttle.failed(ip)
raise ValueError("Your current password isn't right, so nothing was saved.")
problem = password_problem(new, form.get("new_confirm") or "")
if problem:
raise ValueError(problem)
user.password_hash = _hash(new)
user.sessions_since = datetime.now(timezone.utc)
return True
async def _me(db) -> User:
# A share link opens widgets read-only; it has no account.
user_id = session.get("user_id")
@@ -39,15 +76,25 @@ async def account(error: str | None = None, status: int = 200):
"account/index.html", user=user, source=_SOURCES.get(user.auth_source, user.auth_source),
zone_name=zone_name, zone_title=zone_name.split("/", 1)[-1].replace("_", " "),
zone_label=formatting.tz_label(tz), zones=formatting.zones_by_region(),
defaults=defaults, error=error), status
defaults=defaults, error=error, min_password=_min_password(),
done=_DONE.get(request.args.get("done", ""))), status
_DONE = {"saved": "Saved.",
"password": "Saved. Your password is changed; anywhere else you were signed in, you're signed out."}
def _min_password() -> int:
from steward.settings.users import MIN_PASSWORD
return MIN_PASSWORD
@account_bp.post("/")
@require_role(UserRole.viewer)
async def account_save():
"""The page's one form: your time zone, your address and whether you're
emailed alerts and the weekly report (milestone 495). Nothing is saved
unless all of it is valid."""
"""The page's one form: your time zone, your address, whether you're
emailed alerts and the weekly report (milestone 495), and a new password
(milestone 497). Nothing is saved unless all of it is valid."""
from sqlalchemy.exc import IntegrityError
from steward.core.audit import log_audit
from steward.core.mail import apply_email_form
@@ -64,17 +111,21 @@ async def account_save():
raise ValueError(error)
before = user.time_zone
user.time_zone = name
password_changed = _change_password(user, form)
except ValueError as exc:
return await account(str(exc), 400)
except IntegrityError:
return await account("Another account already uses that address.", 400)
session["tz"] = name
if password_changed:
session["login_at"] = time.time() # this session carries on under the new password
await log_audit(current_app, user.id, user.username, "account.password_changed")
if before != name:
await log_audit(current_app, user.id, user.username, "account.time_zone",
detail={"time_zone": name, "was": before or "UTC"})
if changes:
await log_audit(current_app, user.id, user.username, "account.email", detail=changes)
return redirect("/account/")
return redirect("/account/?done=" + ("password" if password_changed else "saved"))
@account_bp.post("/time-zone/adopt")
+3
View File
@@ -120,6 +120,9 @@ ACTIONS = {
"auth.setup": "set up Steward",
"account.time_zone": "set their time zone",
"account.email": "changed their email settings",
"account.password_changed": "changed their password",
"user.password_reset_requested": "asked for a password reset email",
"user.password_reset_by_email": "reset their password from an emailed link",
"user.created": "added user",
"user.renamed": "renamed user",
"user.role_changed": "changed the role of user",
+27 -3
View File
@@ -1,6 +1,7 @@
{# account/index.html — the signed-in user's own account (milestone 490): who
they are, the time zone every page writes its times in, and their email
(milestone 495). One form, one Save. #}
they are, the time zone every page writes its times in, their email
(milestone 495) and a local account's password (milestone 497). One form,
one Save. #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, zone_select %}
{% from "account/_email_choices.html" import email_section with context %}
@@ -13,7 +14,8 @@
· times show in <strong>{{ zone_title }}</strong> ({{ zone_label }})
</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>
{% elif done %}<div class="alert alert-success" role="status">{{ done }}</div>{% endif %}
<form method="post" action="/account/" class="form-page">{{ csrf_field() }}
<section class="card form-section" aria-labelledby="acct-time">
@@ -27,6 +29,28 @@
{{ email_section(user, defaults, "your time zone") }}
<section class="card form-section" aria-labelledby="acct-password">
<h2 id="acct-password" class="section-title">Password</h2>
{% if user.auth_source == "local" %}
<p class="field-hint">Leave these empty to keep your password. A new one signs you out everywhere else you're signed in.</p>
<div class="field">
<label class="field-label" for="acct-pw-now">Current password</label>
<input class="input" id="acct-pw-now" type="password" name="current_password" autocomplete="current-password">
</div>
<div class="field">
<label class="field-label" for="acct-pw-new">New password</label>
<input class="input" id="acct-pw-new" type="password" name="new_password" minlength="{{ min_password }}" autocomplete="new-password">
<span class="field-hint">At least {{ min_password }} characters.</span>
</div>
<div class="field">
<label class="field-label" for="acct-pw-again">New password again</label>
<input class="input" id="acct-pw-again" type="password" name="new_confirm" autocomplete="new-password">
</div>
{% else %}
<p class="field-hint">You sign in with {{ source }}, so your password is changed there, not in Steward.</p>
{% endif %}
</section>
<div class="form-actions"><button type="submit" class="btn">Save</button></div>
</form>
{% endblock %}