"""Email verification: single-use token issuance, consumption, resend throttle. Tokens are stored hashed (SHA-256; they carry 256 bits of entropy so a plain digest is sufficient). One email_tokens table serves both the verification flow (purpose='verify') and password reset (purpose='reset'). """ import logging from datetime import datetime, timedelta, timezone from database import get_db from security import new_token, token_hash from utils import utc_now_str as _now_str logger = logging.getLogger(__name__) TOKEN_TTL_HOURS = 24 RESEND_LIMIT = 3 # per user, per purpose, per 24h _TS = "%Y-%m-%d %H:%M:%S" def issue_token(user_id: int, purpose: str) -> str: """Create a fresh token, invalidating prior unconsumed tokens of the same purpose (a new verification/reset supersedes the old link). A partial unique index (one unconsumed token per user+purpose) serializes concurrent issuers; on conflict the issuance retries once so exactly one live token survives. """ token = new_token() expires = ( datetime.now(timezone.utc) + timedelta(hours=TOKEN_TTL_HOURS) ).strftime(_TS) last_exc: Exception | None = None for _attempt in (1, 2): try: with get_db() as db: # Consume (not delete) the prior token so issuance history # stays countable for the resend throttle. db.execute( "UPDATE email_tokens SET consumed_at = ?" " WHERE user_id = ? AND purpose = ? AND consumed_at IS NULL", (_now_str(), user_id, purpose), ) db.execute( "INSERT INTO email_tokens" " (user_id, token_hash, purpose, expires_at)" " VALUES (?, ?, ?, ?)", (user_id, token_hash(token), purpose, expires), ) return token except Exception as exc: # noqa: BLE001 if exc.__class__.__name__ not in ("IntegrityError", "UniqueViolation"): raise # A concurrent issuer's INSERT landed between our UPDATE and # INSERT above, so ours collided with its still-live row on the # partial unique index (one unconsumed token per user+purpose). # get_db() rolled back both of our statements with this whole # attempt, so there is nothing of ours to clean up — deleting # "whatever row is currently live" here would delete that # concurrent issuer's already-committed (and possibly # already-emailed) token instead of anything we created. # Simply retrying re-runs the UPDATE, which now correctly # consumes that row itself, then our INSERT succeeds. last_exc = exc raise RuntimeError("email token issuance failed after retry") from last_exc def resend_allowed(user_id: int, purpose: str) -> bool: """Whether another token may be issued (anti-spam throttle).""" cutoff = ( datetime.now(timezone.utc) - timedelta(hours=24) ).strftime(_TS) with get_db() as db: row = db.execute( "SELECT COUNT(*) AS n FROM email_tokens" " WHERE user_id = ? AND purpose = ? AND created_at >= ?", (user_id, purpose, cutoff), ).fetchone() return (row["n"] or 0) < RESEND_LIMIT def consume_token(token: str, purpose: str) -> int | None: """Atomically consume a token; returns the owning user_id or None. None for: unknown hash, wrong purpose, consumed, expired, or a concurrent consumer winning the race. Single-use and expiry are both enforced inside the conditional UPDATE (SQL-side, dialect-neutral) and confirmed via rowcount. """ digest = token_hash(token) with get_db() as db: row = db.execute( "SELECT id, user_id, consumed_at, expires_at FROM email_tokens" " WHERE token_hash = ? AND purpose = ?", (digest, purpose), ).fetchone() if not row or row["consumed_at"]: return None cur = db.execute( "UPDATE email_tokens SET consumed_at = ?" " WHERE id = ? AND consumed_at IS NULL" " AND expires_at > ?", (_now_str(), row["id"], _now_str()), ) if cur.rowcount != 1: # Concurrent consumer won, or the token expired. return None return row["user_id"] def peek_token(token: str, purpose: str) -> int | None: """Validate a token WITHOUT consuming it (for rendering reset forms). Returns the owning user_id when the token is known, unconsumed, and unexpired; None otherwise. Consumption still happens on submit, so opening the link repeatedly cannot invalidate it. """ digest = token_hash(token) with get_db() as db: row = db.execute( "SELECT user_id FROM email_tokens" " WHERE token_hash = ? AND purpose = ?" " AND consumed_at IS NULL AND expires_at > ?", (digest, purpose, _now_str()), ).fetchone() return row["user_id"] if row else None