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 %}
+1 -1
View File
@@ -76,7 +76,7 @@ def test_the_browser_zone_is_adopted_once_and_the_account_page_changes_it(app):
assert "times show in <strong>New York</strong> (E" in page # EDT or EST
assert '<option value="America/New_York" selected>New York</option>' in page
assert "/account/time-zone/adopt" not in page # nothing left to adopt
assert out["save"] == (302, "/account/") and out["after_save"] == "Europe/London"
assert out["save"] == (302, "/account/?done=saved") and out["after_save"] == "Europe/London"
status, body = out["refused"]
assert status == 400 and "Nowhere is not a time zone Steward knows." in body
assert out["after_refused"] == "Europe/London" and out["session_tz"] == "Europe/London"
+115
View File
@@ -0,0 +1,115 @@
"""Integration: local users manage their own password (milestone 497): change
it on the Account page, or reset it from an emailed link. The routes run
for real against Postgres; only mail delivery is replaced."""
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
import bcrypt
import pytest
pytestmark = pytest.mark.integration
CSRF = {"X-CSRF-Token": "test-token"}
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
from steward.auth.throttle import login_throttle
login_throttle._failures.clear() # wrong passwords below count against this address
app = create_app(testing=False)
yield app
login_throttle._failures.clear()
async def _local(app, password="old password", source="local"):
"""A local account with a real password, and a browser signed in as it."""
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
name = f"pw-{uid[:8]}"
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=name, email=f"{name}@example.com", role=UserRole.viewer,
password_hash=bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode(),
is_active=True, auth_source=source, time_zone="Europe/London"))
return uid, name, await _signed_in(app, uid, name)
async def _signed_in(app, uid, name, ago=60):
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role="viewer", username=name, _csrf="test-token",
login_at=time.time() - ago)
return client
async def _user(app, uid):
from steward.models.users import User
async with app.db_sessionmaker() as s:
return await s.get(User, uid)
async def _audits(app, uid):
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid)
.order_by(AuditEvent.timestamp))).scalars().all()
return [(r.action, json.loads(r.detail_json or "{}")) for r in rows]
def _checks(user, password):
return bcrypt.checkpw(password.encode(), user.password_hash.encode())
def test_a_local_user_changes_their_password_and_their_other_browsers_sign_out(app):
async def _go():
uid, name, me = await _local(app)
elsewhere = await _signed_in(app, uid, name, ago=120)
page = await (await me.get("/account/")).get_data(as_text=True)
assert 'name="current_password"' in page and 'name="new_password"' in page
keep = {"time_zone": "Europe/London", "email": f"{name}@example.com"}
# Blank: the rest saves and the password stays.
resp = await me.post("/account/", headers=CSRF, form=keep | {"current_password": "old password"})
assert resp.headers["Location"] == "/account/?done=saved"
assert _checks(await _user(app, uid), "old password")
resp = await me.post("/account/", headers=CSRF, form=keep | {
"current_password": "wrong one", "new_password": "new password", "new_confirm": "new password"})
assert resp.status_code == 400 and "current password isn" in await resp.get_data(as_text=True)
resp = await me.post("/account/", headers=CSRF, form=keep | {
"current_password": "old password", "new_password": "new password", "new_confirm": "other"})
assert resp.status_code == 400 and "passwords don" in await resp.get_data(as_text=True)
assert _checks(await _user(app, uid), "old password")
resp = await me.post("/account/", headers=CSRF, form=keep | {
"current_password": "old password", "new_password": "new password", "new_confirm": "new password"})
assert resp.headers["Location"] == "/account/?done=password"
assert _checks(await _user(app, uid), "new password")
page = await (await me.get(resp.headers["Location"])).get_data(as_text=True)
assert "Your password is changed" in page # still signed in here
assert (await elsewhere.get("/account/")).status_code == 302 # signed out there
assert "account.password_changed" in [a for a, _ in await _audits(app, uid)]
asyncio.run(_go())
def test_an_sso_account_has_no_password_to_change(app):
async def _go():
uid, name, me = await _local(app, source="oidc")
page = await (await me.get("/account/")).get_data(as_text=True)
assert 'name="new_password"' not in page and "changed there, not in Steward" in page
resp = await me.post("/account/", headers=CSRF, form={
"time_zone": "Europe/London", "email": f"{name}@example.com",
"current_password": "old password", "new_password": "new password", "new_confirm": "new password"})
assert resp.status_code == 302 and _checks(await _user(app, uid), "old password")
asyncio.run(_go())