hdremover's picture
Rename Auth.py to auth.py
b6a6887 verified
Raw
History Blame Contribute Delete
9.02 kB
"""
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