walletpress/backend/core/hosting.py
cryptorugmunch e13bd4d774
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / security (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / license (push) Waiting to run
CI / ai-review (push) Waiting to run
docs: apply fleet-template (16-artifact scaffold)
Adds missing standard artifacts:
- README.md (if missing)
- AGENTS.md (AI agent contract)
- PLAN.md (current sprint)
- STATUS.md (where we are)
- DEVELOPMENT.md (dev workflow)
- DEPLOYMENT.md (deploy procedure)
- TESTING.md (test strategy)
- DECISIONS.md (ADR index + templates)
- .github/CODEOWNERS
- .github/workflows/ci.yml

Preserves all existing artifacts.

Refs: RugMunchMedia/fleet-template
2026-07-02 02:07:06 +07:00

320 lines
12 KiB
Python

"""WalletPress Hosted — multi-tenant user management + usage tracking.
Revenue model:
Free: 3 chains, 10 wallets/day, no API access
Starter: $29/mo, 10 chains, 500 wallets/day, API access
Pro: $79/mo, 55 chains, unlimited wallets, full API, 2FA
Enterprise: $199/mo, everything + dedicated support + SLA
Users self-register, get API keys, and pay via Stripe.
Admin creates plans, views usage, manages subscriptions.
SECURITY: Passwords are hashed with Argon2id (m=64MB, t=3, p=4).
Legacy SHA-256 hashes are migrated on next successful login.
"""
from __future__ import annotations
import logging
import os
import secrets
import sqlite3
import time
from pathlib import Path
from typing import Optional
from argon2 import PasswordHasher
from argon2.exceptions import (
VerifyMismatchError,
InvalidHashError,
VerificationError,
)
from .config import cfg
logger = logging.getLogger("wp.hosting")
# Argon2id parameters: OWASP recommendations as of 2024
# m = 64 MiB memory cost, t = 3 iterations, p = 4 parallelism
# (argon2-cffi defaults are 64MB / 3 iterations / 4 lanes)
_ph = PasswordHasher()
def _hash_password(plaintext: str) -> str:
"""Hash a password with Argon2id. Returns the encoded hash string.
The returned string includes the salt, parameters, and hash in a
self-describing format like:
$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
"""
return _ph.hash(plaintext)
def _verify_password(stored_hash: str, plaintext: str) -> bool:
"""Verify a plaintext password against a stored hash.
Supports both new Argon2id hashes AND legacy unsalted SHA-256 hashes
(for migration). Returns True on match, False on mismatch.
"""
if not stored_hash:
return False
# Legacy SHA-256 hash: 64 hex chars, no $ separators
if "$" not in stored_hash and len(stored_hash) == 64:
try:
import hashlib
legacy_match = hashlib.sha256(plaintext.encode()).hexdigest() == stored_hash
return legacy_match
except Exception:
return False
# Argon2id hash
try:
_ph.verify(stored_hash, plaintext)
return True
except (VerifyMismatchError, VerificationError, InvalidHashError):
return False
def _needs_rehash(stored_hash: str) -> bool:
"""Check if the stored hash uses outdated parameters and needs re-hashing."""
if "$" not in stored_hash:
return True # Legacy SHA-256 — needs migration to Argon2id
try:
return _ph.check_needs_rehash(stored_hash)
except InvalidHashError:
return True
PLANS = {
"free": {"name": "Free", "price_usd": 0, "chains": 3, "daily_wallet_limit": 10, "api_access": False, "features": ["basic_generation"]},
"starter": {"name": "Starter", "price_usd": 29, "chains": 10, "daily_wallet_limit": 500, "api_access": True, "features": ["basic_generation", "batch", "mnemonic", "export"]},
"pro": {"name": "Pro", "price_usd": 79, "chains": 55, "daily_wallet_limit": 0, "api_access": True, "features": ["all"]},
"enterprise": {"name": "Enterprise", "price_usd": 199, "chains": 55, "daily_wallet_limit": 0, "api_access": True, "features": ["all", "2fa", "sla"]},
}
class HostingDB:
"""User and subscription management."""
def __init__(self, db_path: Path):
self.db_path = db_path
self._init_db()
def _conn(self):
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_db(self):
self.db_path.parent.mkdir(parents=True, exist_ok=True)
conn = self._conn()
conn.executescript("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
name TEXT DEFAULT '',
plan TEXT DEFAULT 'free',
api_key TEXT UNIQUE DEFAULT '',
stripe_customer_id TEXT DEFAULT '',
subscription_id TEXT DEFAULT '',
subscription_status TEXT DEFAULT 'active',
created_at REAL NOT NULL,
last_login_at REAL DEFAULT 0,
email_verified INTEGER DEFAULT 0,
email_verify_token TEXT DEFAULT '',
email_verify_expires REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS usage_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
action TEXT NOT NULL,
chain TEXT DEFAULT '',
count INTEGER DEFAULT 1,
timestamp REAL NOT NULL,
ip TEXT DEFAULT '',
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE INDEX IF NOT EXISTS idx_usage_user ON usage_log(user_id);
CREATE INDEX IF NOT EXISTS idx_usage_date ON usage_log(timestamp);
""")
conn.commit()
conn.close()
def register(self, email: str, password: str, name: str = "") -> dict:
"""Register a new user with email verification (P2-12 fix).
Email verification is OPT-IN via WP_REQUIRE_EMAIL_VERIFY=1. By default
(community / self-hosted dev), users can use the API key immediately.
For hosted deployments with SMTP configured, set that flag to require
email confirmation before the API key becomes active.
Returns:
dict with user_id, api_key (provisional), plan, and optionally
a verification_token the caller should email to the user.
"""
conn = self._conn()
existing = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
if existing:
conn.close()
return {"error": "Email already registered"}
user_id = f"u_{secrets.token_hex(8)}"
pw_hash = _hash_password(password)
api_key = f"wp_live_{secrets.token_hex(24)}"
require_verify = os.getenv("WP_REQUIRE_EMAIL_VERIFY", "0") == "1"
verify_token = secrets.token_urlsafe(32) if require_verify else ""
verify_expires = time.time() + 86400 if require_verify else 0 # 24h
conn.execute(
"""INSERT INTO users
(id, email, password_hash, name, plan, api_key, created_at,
email_verified, email_verify_token, email_verify_expires)
VALUES (?, ?, ?, ?, 'free', ?, ?, ?, ?, ?)""",
(
user_id, email, pw_hash, name, api_key, time.time(),
0 if require_verify else 1, # auto-verify if not required
verify_token, verify_expires,
),
)
conn.commit()
conn.close()
result = {
"user_id": user_id,
"api_key": api_key,
"plan": "free",
"email_verification_required": require_verify,
}
if require_verify:
result["verification_token"] = verify_token
result["note"] = (
"Send POST /hosting/verify-email with {token} to activate. "
"Token expires in 24h. Configure WP_SMTP_HOST etc. to send "
"the email automatically (see core/email_notify.py)."
)
return result
def verify_email(self, token: str) -> bool:
"""Mark the email as verified using a token from registration."""
if not token:
return False
conn = self._conn()
row = conn.execute(
"SELECT id, email_verify_expires FROM users WHERE email_verify_token = ?",
(token,),
).fetchone()
if not row:
conn.close()
return False
if row["email_verify_expires"] and time.time() > row["email_verify_expires"]:
conn.close()
return False
conn.execute(
"""UPDATE users
SET email_verified = 1, email_verify_token = '', email_verify_expires = 0
WHERE id = ?""",
(row["id"],),
)
conn.commit()
conn.close()
return True
def is_email_verified(self, user_id: str) -> bool:
"""True if the user has verified their email (or verification is not required)."""
conn = self._conn()
row = conn.execute(
"SELECT email_verified FROM users WHERE id = ?", (user_id,)
).fetchone()
conn.close()
if not row:
return False
return bool(row["email_verified"])
def login(self, email: str, password: str) -> Optional[dict]:
"""Verify login and migrate legacy SHA-256 hash to Argon2id on success.
Returns the user row (without password_hash) on success, None on failure.
On successful login with an outdated hash, transparently re-hashes the
password with current Argon2id parameters.
"""
conn = self._conn()
row = conn.execute("SELECT * FROM users WHERE email = ?", (email,)).fetchone()
if not row:
conn.close()
return None
stored_hash = row["password_hash"]
if not _verify_password(stored_hash, password):
conn.close()
return None
# Migrate outdated hash to current Argon2id params
if _needs_rehash(stored_hash):
try:
new_hash = _hash_password(password)
conn.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(new_hash, row["id"]),
)
conn.commit()
logger.info(f"Migrated password hash to Argon2id for user {row['id']}")
except Exception as e:
logger.warning(f"Password hash migration failed for user {row['id']}: {e}")
# Return everything except password_hash — never leak it
safe = {k: v for k, v in dict(row).items() if k != "password_hash"}
conn.close()
return safe
def get_by_api_key(self, api_key: str) -> Optional[dict]:
conn = self._conn()
row = conn.execute("SELECT * FROM users WHERE api_key = ?", (api_key,)).fetchone()
conn.close()
return dict(row) if row else None
def log_usage(self, user_id: str, action: str, chain: str = "", count: int = 1, ip: str = ""):
conn = self._conn()
conn.execute(
"INSERT INTO usage_log (user_id, action, chain, count, timestamp, ip) VALUES (?, ?, ?, ?, ?, ?)",
(user_id, action, chain, count, time.time(), ip),
)
conn.commit()
conn.close()
def daily_usage(self, user_id: str) -> int:
today = int(time.time()) - (int(time.time()) % 86400)
conn = self._conn()
row = conn.execute(
"SELECT COALESCE(SUM(count), 0) as total FROM usage_log WHERE user_id = ? AND timestamp >= ?",
(user_id, today),
).fetchone()
conn.close()
return row["total"] if row else 0
def check_limit(self, user_id: str) -> tuple[bool, str]:
conn = self._conn()
row = conn.execute("SELECT plan FROM users WHERE id = ?", (user_id,)).fetchone()
conn.close()
if not row:
return False, "User not found"
plan = PLANS.get(row["plan"], PLANS["free"])
if plan["daily_wallet_limit"] == 0:
return True, "" # Unlimited
used = self.daily_usage(user_id)
if used >= plan["daily_wallet_limit"]:
return False, f"Daily limit reached ({plan['daily_wallet_limit']} wallets). Upgrade at /hosting/plans"
return True, ""
def get_user(self, user_id: str) -> Optional[dict]:
conn = self._conn()
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
conn.close()
return dict(row) if row else None
# Singleton
_hosting: Optional[HostingDB] = None
def get_hosting() -> HostingDB:
global _hosting
if _hosting is None:
_hosting = HostingDB(cfg.data_dir / "hosting.db")
return _hosting