feat(auth): reset a forgotten password from an emailed link (milestone 497)
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:
@@ -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 <x>" in html and "<x>" not in html
|
||||
assert all(u.startswith("https://steward.example") for u in re.findall(r"""https?://[^\s"'<>)]+""", html))
|
||||
Reference in New Issue
Block a user