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>
77 lines
2.9 KiB
Python
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
|