"""Durable, single-use password reset token service. Tokens are opaque random strings; only their SHA-256 hash is stored. Consuming a valid token marks it used and bumps the user's ``auth_version`` so previously issued sessions are invalidated. """ from __future__ import annotations import hashlib import secrets from datetime import datetime, timedelta, timezone from typing import Optional from sqlalchemy.orm import Session from ..models.password_reset import PasswordResetToken from ..models.saas import User DEFAULT_TTL = timedelta(hours=24) DEFAULT_TTL_HOURS = 24 def _hash(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def _utc_now() -> datetime: # Use naive UTC: SQLite returns naive datetimes for DateTime(timezone=True) # on read, so comparisons are kept tz-naive to avoid aware/naive errors. return datetime.utcnow() class PasswordResetService: def __init__(self, session: Session, *, ttl: timedelta | None = None): self.session = session self.ttl = ttl or DEFAULT_TTL def create_token(self, *, user_id: str) -> str: token = secrets.token_urlsafe(32) record = PasswordResetToken( user_id=user_id, token_hash=_hash(token), expires_at=_utc_now() + self.ttl, ) self.session.add(record) self.session.flush() return token def _latest_token(self, user_id: str) -> Optional[PasswordResetToken]: return ( self.session.query(PasswordResetToken) .filter(PasswordResetToken.user_id == user_id) .order_by(PasswordResetToken.created_at.desc()) .first() ) def consume_token(self, token: str, *, user_id: str) -> bool: """Consume a valid, unexpired, unused token for ``user_id``. On success marks the token used and increments the user's auth_version (invalidating older sessions). Returns False otherwise. """ record = self._latest_token(user_id) if record is None or record.used: return False if _hash(token) != record.token_hash: return False if record.expires_at < _utc_now(): return False record.used = True user = self.session.get(User, user_id) if user is not None: user.auth_version = (user.auth_version or 0) + 1 self.session.flush() return True