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