Files
FabledSteward/steward/core/password_resets.py
T
bvandeusenandClaude Opus 5.5 593b583d23
CI / lint (push) Failing after 3s
CI / integration (push) Successful in 1m37s
CI / unit (push) Failing after 1m43s
CI / publish (push) Skipped
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>
2026-10-08 20:17:24 -04:00

77 lines
2.9 KiB
Python

"""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