feat(auth): reset a forgotten password from an emailed link (milestone 497)
CI / lint (push) Failing after 3s
CI / integration (push) Successful in 1m37s
CI / unit (push) Failing after 1m43s
CI / publish (push) Skipped

Step #5429 "Reset by email: request, link and new-password page" of
milestone 497 "Passwords: change your own, and reset it by email".

- Sign-in page: "Forgot your password?" while a mail server is set.
- /forgot takes a username or email and answers the same either way; only
  an active local account with a real address gets mail. Requests count
  per address on their own throttle, so they never lock out sign-in.
- password_resets (migration 0039): a one-time key, stored as its sha256,
  working for an hour; asking again replaces it.
- /reset/<key>: the new password, then signed straight in; every other
  browser is signed out (sessions_since). Bad keys count as invite keys do.
- Email "[Steward] Reset your password", in the account's own zone.
- Audited: user.password_reset_requested, user.password_reset_by_email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 20:17:24 -04:00
co-authored by Claude Opus 5.5
parent ea523b8c37
commit 593b583d23
17 changed files with 528 additions and 7 deletions
+2
View File
@@ -166,6 +166,8 @@ def create_app(
from .auth import csrf as _csrf from .auth import csrf as _csrf
app.jinja_env.globals["csrf_token"] = _csrf.csrf_token app.jinja_env.globals["csrf_token"] = _csrf.csrf_token
app.jinja_env.globals["csrf_field"] = _csrf.csrf_field app.jinja_env.globals["csrf_field"] = _csrf.csrf_field
# The sign-in page offers "Forgot your password?" only while mail can send (milestone 497).
app.jinja_env.globals["reset_by_email"] = lambda: bool(app.config.get("SMTP", {}).get("host"))
app.before_request(_csrf.check_request) app.before_request(_csrf.check_request)
# The session cookie is Secure, and HSTS is sent, only on requests that # The session cookie is Secure, and HSTS is sent, only on requests that
# arrived over HTTPS — directly or as a trusted proxy reports it # arrived over HTTPS — directly or as a trusted proxy reports it
+135 -4
View File
@@ -324,8 +324,9 @@ async def _inviter_name(db, invite) -> str:
return who.username if who else "An admin" return who.username if who else "An admin"
def _invite_throttled() -> str | None: def _too_many_tries() -> str | None:
"""Why this address may not try an invite key now (too many bad ones), or None.""" """Why this address may not try an invite or reset key now (too many bad
ones), or None."""
from quart import current_app from quart import current_app
wait_s = login_throttle.retry_after( wait_s = login_throttle.retry_after(
client_ip(), current_app.config.get("LOGIN_MAX_FAILURES", 5), client_ip(), current_app.config.get("LOGIN_MAX_FAILURES", 5),
@@ -341,7 +342,7 @@ def _invite_throttled() -> str | None:
async def invite(key: str): async def invite(key: str):
from quart import current_app from quart import current_app
from steward.core.invites import find_pending from steward.core.invites import find_pending
if wait := _invite_throttled(): if wait := _too_many_tries():
return await _invite_page(key, error=wait, status=429) return await _invite_page(key, error=wait, status=429)
async with current_app.db_sessionmaker() as db: async with current_app.db_sessionmaker() as db:
found = await find_pending(db, key) found = await find_pending(db, key)
@@ -363,7 +364,7 @@ async def invite_accept(key: str):
from steward.core.audit import log_audit from steward.core.audit import log_audit
from steward.core.invites import find_pending, signup_problem, taken from steward.core.invites import find_pending, signup_problem, taken
from steward.settings.users import _hash from steward.settings.users import _hash
if wait := _invite_throttled(): if wait := _too_many_tries():
return await _invite_page(key, error=wait, status=429) return await _invite_page(key, error=wait, status=429)
form = await request.form form = await request.form
username = form.get("username") or "" username = form.get("username") or ""
@@ -402,3 +403,133 @@ async def invite_accept(key: str):
detail={"username": user.username, "role": role, "invite": invite_id, detail={"username": user.username, "role": role, "invite": invite_id,
"invited_by": inviter}) "invited_by": inviter})
return redirect(url_for("dashboard.index")) 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
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)
to, username, user_id, tz = user.email, user.username, user.id, user.time_zone
if user is None:
return await _forgot_page(sent=True)
cfg = current_app.config.get("SMTP", {})
subject, text, html = mail.reset_email(username, reset.expires_at,
link(public_base_url(request), key), zone(tz))
try:
await mail.send(cfg, [mail.message(cfg, to, subject, text, html)])
except Exception as exc: # the page still says the same; the log says why
current_app.logger.warning("Password reset email to %s failed: %s", to, exc)
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"))
+4
View File
@@ -46,3 +46,7 @@ class LoginThrottle:
login_throttle = LoginThrottle() login_throttle = LoginThrottle()
# Password-reset emails asked for from one address (milestone 497): every
# request counts, matched or not, so it can't be used to find accounts, and
# kept apart from login_throttle so asking for links never locks out sign-in.
reset_throttle = LoginThrottle()
+9
View File
@@ -271,6 +271,15 @@ ROLE_WORDS = {
} }
def reset_email(username: str, expires_at: datetime, url: str, tz: tzinfo) -> tuple[str, str, str]:
"""(subject, text, html) for a password-reset link (milestone 497): whose
account, when the link stops working (in the account's own zone) and the
link, with what to do if they didn't ask."""
facts = {"username": username, "link": url, "expires": moment(expires_at, tz)}
text, html = _render("password_reset", r=facts)
return "[Steward] Reset your password", text, html
def invite_email(inviter: str, role: str, expires_at: datetime, url: str, def invite_email(inviter: str, role: str, expires_at: datetime, url: str,
tz: tzinfo) -> tuple[str, str, str]: tz: tzinfo) -> tuple[str, str, str]:
"""(subject, text, html) for an invite (milestone 496): who sent it, the """(subject, text, html) for an invite (milestone 496): who sent it, the
+76
View File
@@ -0,0 +1,76 @@
"""Reset a local password from an emailed link (milestone 497).
Anyone may ask from the sign-in page, by username or email, and the page
answers the same whether or not an account matched. Only an active local
account with an address mail can reach gets a link. The link carries a
one-time key (only its sha256 is kept) and works for an hour; asking again
replaces it. Using it sets the password, signs out every browser signed in
as the account (users.sessions_since) and signs the person in.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, or_, select
from steward.core.api_tokens import hash_token
from steward.core.invites import new_key
from steward.models.password_resets import PasswordReset
from steward.models.users import User
LIFETIME = timedelta(hours=1)
def state(reset: PasswordReset, now: datetime | None = None) -> str:
""""used", "expired" or "pending"."""
now = now or datetime.now(timezone.utc)
if reset.used_at is not None:
return "used"
if reset.expires_at <= now:
return "expired"
return "pending"
def link(base: str, key: str) -> str:
return f"{base.rstrip('/')}/reset/{key}"
async def account_for(db, name: str) -> User | None:
"""The account a "username or email" names, when it may be reset by
email: active, local, and with an address mail can reach."""
from steward.core.mail import is_real_address
name = (name or "").strip()
if not name or len(name) > 255:
return None
user = (await db.execute(select(User).where(
or_(User.username == name, func.lower(User.email) == name.lower()))
.order_by((User.username == name).desc()).limit(1))).scalar_one_or_none()
if user is None or not user.is_active or user.auth_source != "local" or not is_real_address(user.email):
return None
return user
async def issue(db, user: User) -> tuple[PasswordReset, str]:
"""A new link for `user`, flushed, and its raw key (sent once, never
stored). The account's earlier unused links stop working. Call in a
transaction."""
await db.execute(delete(PasswordReset).where(
PasswordReset.user_id == user.id, PasswordReset.used_at.is_(None)))
now = datetime.now(timezone.utc)
key = new_key()
reset = PasswordReset(user_id=user.id, key_hash=hash_token(key), created_at=now,
expires_at=now + LIFETIME)
db.add(reset)
await db.flush()
return reset, key
async def find_pending(db, key: str, lock: bool = False) -> PasswordReset | None:
"""The link `key` opens, while it still works; None for any other key."""
if not key or len(key) > 128:
return None
query = select(PasswordReset).where(PasswordReset.key_hash == hash_token(key))
if lock:
query = query.with_for_update()
reset = (await db.execute(query)).scalar_one_or_none()
return reset if reset is not None and state(reset) == "pending" else None
@@ -0,0 +1,33 @@
"""Reset a local password from an emailed link (milestone 497): a one-time
key, stored as its sha256, that works for an hour.
Revision ID: 0039_password_resets
Revises: 0038_user_sessions_since
Create Date: 2026-10-09
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0039_password_resets"
down_revision: Union[str, None] = "0038_user_sessions_since"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"password_resets",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("key_hash", sa.String(64), nullable=False, unique=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_password_resets_user_id", table_name="password_resets")
op.drop_table("password_resets")
+1
View File
@@ -2,6 +2,7 @@ from .base import Base
from .users import User from .users import User
from .api_tokens import ApiToken from .api_tokens import ApiToken
from .invites import UserInvite from .invites import UserInvite
from .password_resets import PasswordReset
from .hosts import Host from .hosts import Host
from .monitors import Monitor, MonitorResult, MonitorType from .monitors import Monitor, MonitorResult, MonitorType
from .metrics import PluginMetric from .metrics import PluginMetric
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PasswordReset(Base):
"""An emailed link to set a new password for a local account (milestone
497). The link carries a one-time key; only its sha256 is stored, as for
invites. It works for an hour, once; asking again replaces it."""
__tablename__ = "password_resets"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+3 -1
View File
@@ -451,11 +451,13 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
.form-section > .section-title { margin-bottom: 0.9rem; } .form-section > .section-title { margin-bottom: 0.9rem; }
.form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; } .form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; }
.form-pair[hidden] { display: none; } .form-pair[hidden] { display: none; }
/* The invite page (milestone 496): the login page's centred card, with labelled fields. */ /* The invite, forgot-password and reset pages (milestones 496, 497): the login
page's centred card, with labelled fields. */
.invite-page { max-width: 420px; margin: 4rem auto; } .invite-page { max-width: 420px; margin: 4rem auto; }
.invite-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 500; margin: 0 0 0.5rem; } .invite-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 500; margin: 0 0 0.5rem; }
.invite-lede { color: var(--text-muted); margin: 0 0 1.25rem; } .invite-lede { color: var(--text-muted); margin: 0 0 1.25rem; }
.invite-submit { width: 100%; margin-top: 0.25rem; } .invite-submit { width: 100%; margin-top: 0.25rem; }
.login-forgot { text-align: center; margin: 1rem 0 0; font-size: 0.85rem; }
/* A link shown once to copy (an invite without mail): the value and its Copy button. */ /* A link shown once to copy (an invite without mail): the value and its Copy button. */
.copy-once { display: flex; gap: 0.5rem; align-items: center; } .copy-once { display: flex; gap: 0.5rem; align-items: center; }
.copy-once .input { flex: 1; font-family: ui-monospace, monospace; font-size: 0.8rem; } .copy-once .input { flex: 1; font-family: ui-monospace, monospace; font-size: 0.8rem; }
+30
View File
@@ -0,0 +1,30 @@
{# auth/forgot.html — ask for a password-reset email (milestone 497). It answers
the same whether or not an account matched. #}
{% extends "base.html" %}
{% block title %}Forgot your password — Steward{% endblock %}
{% block content %}
<div class="invite-page">
<div class="card">
<h1 class="invite-title">Forgot your password?</h1>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
{% if sent %}
<div class="alert alert-success" role="status">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.</div>
<a href="/login" class="btn btn-ghost">Back to sign in</a>
{% elif not mail_ready %}
<p class="invite-lede">Steward has no mail server set, so it can't email you a link. Ask an admin to set a new password for you in Settings › Users.</p>
<a href="/login" class="btn btn-ghost">Back to sign in</a>
{% else %}
<p class="invite-lede">Enter your username or email address. If your account has a Steward password, we'll email you a link to choose a new one.</p>
<form method="post" action="/forgot">{{ csrf_field() }}
<div class="field">
<label class="field-label" for="forgot-name">Username or email</label>
<input class="input" id="forgot-name" type="text" name="name" maxlength="255" required autofocus autocomplete="username">
<span class="field-hint">Accounts that sign in with LDAP or single sign-on change their password there.</span>
</div>
<button type="submit" class="btn invite-submit">Email me a link</button>
</form>
<p class="field-hint"><a href="/login">Back to sign in</a></p>
{% endif %}
</div>
</div>
{% endblock %}
+3
View File
@@ -32,6 +32,9 @@
Sign In Sign In
</button> </button>
</form> </form>
{% if reset_by_email() %}
<p class="login-forgot"><a href="/forgot">Forgot your password?</a></p>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{# auth/reset.html — choose a new password from an emailed link (milestone 497).
`username` is set while the link works; otherwise only `error` shows. #}
{% extends "base.html" %}
{% block title %}Choose a new password — Steward{% endblock %}
{% block content %}
<div class="invite-page">
<div class="card">
<h1 class="invite-title">Choose a new password</h1>
{% if username %}
<p class="invite-lede">For <strong>{{ username }}</strong>. Once it's set you're signed in here, and signed out anywhere else.</p>
{% endif %}
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
{% if username %}
<form method="post" action="/reset/{{ key }}">{{ csrf_field() }}
<input type="text" name="username" value="{{ username }}" autocomplete="username" hidden>
<div class="field">
<label class="field-label" for="reset-pw">New password</label>
<input class="input" id="reset-pw" type="password" name="password" minlength="{{ min_password }}" required autofocus autocomplete="new-password">
<span class="field-hint">At least {{ min_password }} characters.</span>
</div>
<div class="field">
<label class="field-label" for="reset-pw2">New password again</label>
<input class="input" id="reset-pw2" type="password" name="confirm" required autocomplete="new-password">
</div>
<button type="submit" class="btn invite-submit">Set password and sign in</button>
</form>
{% else %}
<a href="/forgot" class="btn btn-ghost">Ask for a new link</a>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,19 @@
{# A password-reset email's HTML part (milestone 497): r = the facts mail.reset_email builds. #}
{% extends "_layout.html" %}
{% from "_parts.html" import button, fact with context %}
{% block title %}Reset your Steward password{% endblock %}
{% block body %}
<tr><td style="padding:10px 24px 4px 24px;font:600 20px/1.35 {{ c.font }};color:{{ c.text }};">Reset your Steward password</td></tr>
<tr><td style="padding:6px 24px 4px 24px;font:14px/1.55 {{ c.font }};color:{{ c.text }};">Someone asked to reset the password for {{ r.username }}. Open the link to choose a new one. Anywhere you're signed in now, you'll be signed out.</td></tr>
<tr><td style="padding:10px 24px 4px 24px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{ fact("Account", r.username) }}
{{ fact("Link works until", r.expires) }}
</table>
</td></tr>
<tr><td style="padding:16px 24px 8px 24px;">{{ button(r.link, "Choose a new password") }}</td></tr>
<tr><td style="padding:4px 24px 20px 24px;font:12px/1.5 {{ c.font }};color:{{ c.muted }};word-break:break-all;">Or paste this into your browser: {{ r.link }}</td></tr>
{% endblock %}
{% block footer %}
The link works once. If you didn't ask for this, ignore it: your password is unchanged.
{% endblock %}
@@ -0,0 +1,13 @@
{# A password-reset email's plain-text part; the same facts as password_reset.html. #}
Someone asked to reset the Steward password for {{ r.username }}.
Open the link to choose a new password. Anywhere you're signed in now, you'll
be signed out.
Link works until: {{ r.expires }}
Choose a new password: {{ r.link }}
--
The link works once. If you didn't ask for this, ignore it: your password is
unchanged.
+37
View File
@@ -0,0 +1,37 @@
"""Reset a password by email (milestone 497): a link's states, where it
points, and the email that carries it."""
import re
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from steward.core import mail, password_resets
from steward.models.password_resets import PasswordReset
NOW = datetime(2026, 10, 9, 12, 0, tzinfo=timezone.utc)
def _reset(**kw):
return PasswordReset(**({"user_id": "u", "key_hash": "x", "created_at": NOW,
"expires_at": NOW + password_resets.LIFETIME} | kw))
def test_a_link_works_for_an_hour_and_once():
assert password_resets.LIFETIME == timedelta(hours=1)
assert password_resets.state(_reset(), NOW + timedelta(minutes=59)) == "pending"
assert password_resets.state(_reset(), NOW + timedelta(hours=1)) == "expired"
assert password_resets.state(_reset(used_at=NOW), NOW) == "used"
def test_the_link_carries_the_key_under_steward_s_address():
assert password_resets.link("https://steward.example/", "abc") == "https://steward.example/reset/abc"
def test_the_reset_email_names_the_account_the_deadline_and_what_to_do_if_unasked():
url = "https://steward.example/reset/KEY123"
subject, text, html = mail.reset_email("ann <x>", NOW + timedelta(hours=1), url, ZoneInfo("Europe/London"))
assert subject == "[Steward] Reset your password"
for part in (text, html):
assert url in part and "Fri 9 Oct 2026 14:00 BST" in part
assert "your password is unchanged" in part and "UTC" not in part
assert "ann &lt;x&gt;" in html and "<x>" not in html
assert all(u.startswith("https://steward.example") for u in re.findall(r"""https?://[^\s"'<>)]+""", html))
+108 -2
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import os import os
import re
import time import time
import uuid import uuid
@@ -23,10 +24,13 @@ def app():
pytest.skip("needs Postgres") pytest.skip("needs Postgres")
from steward.app import create_app from steward.app import create_app
from steward.auth.throttle import login_throttle from steward.auth.throttle import login_throttle
login_throttle._failures.clear() # wrong passwords below count against this address from steward.auth.throttle import reset_throttle
for t in (login_throttle, reset_throttle):
t._failures.clear() # wrong passwords and reset requests below count against this address
app = create_app(testing=False) app = create_app(testing=False)
yield app yield app
login_throttle._failures.clear() for t in (login_throttle, reset_throttle):
t._failures.clear()
async def _local(app, password="old password", source="local"): async def _local(app, password="old password", source="local"):
@@ -113,3 +117,105 @@ def test_an_sso_account_has_no_password_to_change(app):
assert resp.status_code == 302 and _checks(await _user(app, uid), "old password") assert resp.status_code == 302 and _checks(await _user(app, uid), "old password")
asyncio.run(_go()) asyncio.run(_go())
_KEY = re.compile(r"/reset/([A-Za-z0-9_-]{20,})")
def _mail(app, monkeypatch):
from steward.core import mail
sent = []
async def fake_send(cfg, messages):
sent.extend(messages)
app.config["SMTP"] = {"host": "mx.example", "port": 587, "from": "steward@example.com"}
monkeypatch.setattr(mail, "send", fake_send)
return sent
async def _stranger(app):
client = app.test_client()
await client.get("/login")
async with client.session_transaction() as sess:
sess["_csrf"] = "test-token"
return client
def test_a_forgotten_password_is_reset_from_the_emailed_link(app, monkeypatch):
sent = _mail(app, monkeypatch)
async def _go():
uid, name, elsewhere = await _local(app)
guest = await _stranger(app)
page = await (await guest.get("/login")).get_data(as_text=True)
assert 'href="/forgot"' in page
# The same answer for a stranger's name, an SSO account and a real one.
sso, sso_name, _ = await _local(app, source="oidc")
answers = []
for who in ("nobody-" + uid[:8], sso_name, f"{name}@EXAMPLE.com"):
resp = await guest.post("/forgot", headers=CSRF, form={"name": who})
answers.append((resp.status_code, "on its way" in await resp.get_data(as_text=True)))
assert answers == [(200, True)] * 3
assert [m["To"] for m in sent] == [f"{name}@example.com"]
text = sent[0].get_body(("plain",)).get_content()
assert name in text and "BST" in text
key = _KEY.search(text).group(1)
page = await (await guest.get(f"/reset/{key}")).get_data(as_text=True)
assert "Choose a new password" in page and name in page
resp = await guest.post(f"/reset/{key}", headers=CSRF, form={"password": "brand new pw", "confirm": "nope"})
assert resp.status_code == 400 and "passwords don" in await resp.get_data(as_text=True)
resp = await guest.post(f"/reset/{key}", headers=CSRF, form={"password": "brand new pw", "confirm": "brand new pw"})
assert resp.status_code == 302
assert _checks(await _user(app, uid), "brand new pw")
async with guest.session_transaction() as sess:
assert sess["user_id"] == uid # signed straight in
assert (await guest.get("/account/")).status_code == 200
assert (await elsewhere.get("/account/")).status_code == 302 # the old browser is out
assert [a for a, _ in await _audits(app, uid)] == [
"user.password_reset_requested", "user.password_reset_by_email"]
again = await _stranger(app)
resp = await again.get(f"/reset/{key}")
assert resp.status_code == 404 and "expired or been used" in await resp.get_data(as_text=True)
resp = await again.post(f"/reset/{key}", headers=CSRF, form={"password": "third one!", "confirm": "third one!"})
assert resp.status_code == 404 and _checks(await _user(app, uid), "brand new pw")
asyncio.run(_go())
def test_asking_again_kills_the_first_link_and_an_old_link_expires(app, monkeypatch):
sent = _mail(app, monkeypatch)
async def _go():
from datetime import datetime, timedelta, timezone
from sqlalchemy import update
from steward.models.password_resets import PasswordReset
uid, name, _ = await _local(app)
guest = await _stranger(app)
for _ in range(2):
await guest.post("/forgot", headers=CSRF, form={"name": name})
first, second = (_KEY.search(m.get_body(("plain",)).get_content()).group(1) for m in sent)
assert (await guest.get(f"/reset/{first}")).status_code == 404
assert (await guest.get(f"/reset/{second}")).status_code == 200
async with app.db_sessionmaker() as s:
async with s.begin():
await s.execute(update(PasswordReset).where(PasswordReset.user_id == uid)
.values(expires_at=datetime.now(timezone.utc) - timedelta(seconds=1)))
assert (await guest.get(f"/reset/{second}")).status_code == 404
asyncio.run(_go())
def test_without_mail_the_forgot_page_says_to_ask_an_admin(app):
async def _go():
app.config["SMTP"] = {}
guest = await _stranger(app)
assert 'href="/forgot"' not in await (await guest.get("/login")).get_data(as_text=True)
page = await (await guest.get("/forgot")).get_data(as_text=True)
assert "Ask an admin" in page and 'name="name"' not in page
asyncio.run(_go())
+2
View File
@@ -27,6 +27,8 @@ TOP_LEVEL = {
"steward/templates/auth/login.html", "steward/templates/auth/login.html",
"steward/templates/auth/setup.html", "steward/templates/auth/setup.html",
"steward/templates/auth/invite.html", "steward/templates/auth/invite.html",
"steward/templates/auth/forgot.html",
"steward/templates/auth/reset.html",
"steward/templates/errors/404.html", "steward/templates/errors/404.html",
"steward/templates/errors/500.html", "steward/templates/errors/500.html",
"steward/templates/errors/database_unavailable.html", "steward/templates/errors/database_unavailable.html",