""" auth.py — Passwordless email+OTP authentication for HD Remover. Replaces the old silent device-ID registration system. Identity is now the verified email address itself — one email = one account = one active plan. No passwords, no device IDs, no duplicate-plan risk from email mismatches. Flow: 1. POST /api/auth/request-otp {email} - reject disposable/temp-mail domains - rate limit per email + per IP - generate 6-digit OTP, store hashed with 5-min expiry - send via Resend 2. POST /api/auth/verify-otp {email, otp} - check hash + expiry + attempt count - on success: create account if new (plan: free), issue permanent api_key - on repeat login (existing verified email): just return existing api_key Env vars: RESEND_API_KEY, RESEND_FROM -> already used by payments.py, reused here BLACKLIST (see code-memory skill): #010 Storing OTP in plaintext -> NO, always hash (sha256) before storing #011 No rate limit on request-otp -> NO, allows OTP-spam / email-bombing abuse """ import os import re import time import random import string import hashlib import logging import threading from collections import deque from pathlib import Path import data_store from data_store import utcnow logger = logging.getLogger("hd_remover.auth") RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") RESEND_FROM = os.environ.get("RESEND_FROM", "HD Remover ") OTP_LENGTH = 6 OTP_EXPIRY_SEC = 5 * 60 # 5 minutes OTP_MAX_ATTEMPTS = 5 # wrong-guess attempts allowed per OTP OTP_RESEND_COOLDOWN = 60 # seconds between resend requests (same email) OTP_MAX_PER_HOUR = 3 # max OTP requests per email per hour OTP_MAX_PER_HOUR_IP = 8 # max OTP requests per IP per hour (looser, shared networks) EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") # ── Disposable domain blocklist (self-hosted, no external API) ──────────────── _DISPOSABLE_DOMAINS: set[str] = set() _disposable_lock = threading.Lock() def load_disposable_domains(path: str = None): """Load the local disposable-domain list once at startup.""" global _DISPOSABLE_DOMAINS path = path or str(Path(__file__).parent / "disposable_domains.txt") try: with open(path, "r", encoding="utf-8") as f: domains = {line.strip().lower() for line in f if line.strip() and not line.startswith("#")} with _disposable_lock: _DISPOSABLE_DOMAINS = domains logger.info(f"Loaded {len(domains)} disposable email domains from {path}") except FileNotFoundError: logger.warning(f"Disposable domain list not found at {path} — temp-mail check disabled") def is_disposable_email(email: str) -> bool: domain = email.rsplit("@", 1)[-1].lower().strip() with _disposable_lock: if domain in _DISPOSABLE_DOMAINS: return True # catch common subdomain-of-disposable patterns, e.g. sub.mailinator.com parts = domain.split(".") for i in range(1, len(parts) - 1): if ".".join(parts[i:]) in _DISPOSABLE_DOMAINS: return True return False # ── In-memory rate limiting (per-process; fine for single-Space deployment) ─── _otp_requests_by_email: dict = {} # {email: deque of timestamps} _otp_requests_by_ip: dict = {} # {ip: deque of timestamps} _rl_lock = threading.Lock() def _check_and_record_rate_limit(email: str, ip: str) -> str: """Returns '' if allowed, or an error message if rate-limited.""" now = utcnow().timestamp() with _rl_lock: eq = _otp_requests_by_email.setdefault(email, deque()) while eq and eq[0] < now - 3600: eq.popleft() if eq and (now - eq[-1]) < OTP_RESEND_COOLDOWN: wait = int(OTP_RESEND_COOLDOWN - (now - eq[-1])) return f"Please wait {wait}s before requesting another code." if len(eq) >= OTP_MAX_PER_HOUR: return "Too many code requests for this email. Try again in an hour." ipq = _otp_requests_by_ip.setdefault(ip, deque()) while ipq and ipq[0] < now - 3600: ipq.popleft() if len(ipq) >= OTP_MAX_PER_HOUR_IP: return "Too many requests from this network. Try again in an hour." eq.append(now) ipq.append(now) return "" # ── OTP storage (persisted via data_store so it survives restarts/scaling) ──── # data_store.get_otps() / upsert_otp() / delete_otp() expected — see data_store.py patch below. def _hash_otp(otp: str, email: str) -> str: # email salted so identical OTP digits across users never collide return hashlib.sha256(f"{email.lower()}:{otp}".encode()).hexdigest() def _gen_otp() -> str: return "".join(random.choices(string.digits, k=OTP_LENGTH)) def request_otp(email: str, ip: str) -> tuple[bool, str]: """Returns (ok, error_message).""" email = email.strip().lower() if not EMAIL_RE.match(email): return False, "Please enter a valid email address." if is_disposable_email(email): return False, "Temporary/disposable email addresses aren't allowed. Please use a permanent email." rl_err = _check_and_record_rate_limit(email, ip) if rl_err: return False, rl_err otp = _gen_otp() data_store.upsert_otp(email, { "otp_hash": _hash_otp(otp, email), "expires_at": time.time() + OTP_EXPIRY_SEC, "attempts": 0, }) sent = _send_otp_email(email, otp) if not sent: return False, "Failed to send verification email. Please try again shortly." return True, "" def verify_otp(email: str, otp: str) -> tuple[bool, str, str]: """Returns (ok, error_message, api_key).""" email = email.strip().lower() otp = otp.strip() record = data_store.get_otp(email) if not record: return False, "No verification code found for this email. Please request a new one.", "" if time.time() > record.get("expires_at", 0): data_store.delete_otp(email) return False, "Code expired. Please request a new one.", "" if record.get("attempts", 0) >= OTP_MAX_ATTEMPTS: data_store.delete_otp(email) return False, "Too many incorrect attempts. Please request a new code.", "" if _hash_otp(otp, email) != record.get("otp_hash"): data_store.increment_otp_attempts(email) remaining = OTP_MAX_ATTEMPTS - (record.get("attempts", 0) + 1) return False, f"Incorrect code. {max(remaining, 0)} attempts remaining.", "" # Success — consume the OTP so it can't be reused data_store.delete_otp(email) # Find or create the account (identity = email, permanently) existing_key, existing = data_store.find_key_by_email(email) if existing: return True, "", existing_key api_key = _create_free_account(email) return True, "", api_key def _create_free_account(email: str) -> str: from datetime import timedelta chars = string.ascii_lowercase + string.digits r = lambda n: "".join(random.choices(chars, k=n)) api_key = f"hdrm-fr-{r(8)}-{r(8)}" data_store.upsert_user(api_key, { "email": email, "plan": "free", "calls_today": 0, "reset_at": (utcnow() + timedelta(days=1)).timestamp(), "created_at": utcnow().isoformat(), "source": "email_otp", "expires_at": (utcnow() + timedelta(days=30)).timestamp(), "webhook_url": "", "email_verified": True, }) logger.info(f"Created new free account for {email} (key ...{api_key[-6:]})") return api_key # ── Resend email delivery ────────────────────────────────────────────────────── def _send_otp_email(email: str, otp: str) -> bool: if not RESEND_API_KEY: logger.warning("RESEND_API_KEY not set — cannot send OTP email") return False import requests as _req try: resp = _req.post( "https://api.resend.com/emails", headers={"Authorization": f"Bearer {RESEND_API_KEY}"}, json={ "from": RESEND_FROM, "to": [email], "subject": f"Your HD Remover code: {otp}", "text": ( f"Your HD Remover verification code is: {otp}\n\n" f"This code expires in {OTP_EXPIRY_SEC // 60} minutes.\n\n" "If you didn't request this, you can safely ignore this email." ), }, timeout=10, ) if resp.status_code >= 300: logger.error(f"Resend OTP email failed ({resp.status_code}): {resp.text}") return False logger.info(f"OTP email sent to {email}") return True except Exception as e: logger.error(f"Resend OTP email exception: {e}") return False