Spaces:
Running
Running
| """ | |
| 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 <support@hdremover.com>") | |
| 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 |