Files
neuron-tai/packages/tracker/meshnet_tracker/accounts.py
2026-07-07 22:13:12 +03:00

336 lines
12 KiB
Python

"""Tracker user accounts: registration, login, API-key management.
Accounts are identified by email address or wallet address (either works as
the login identifier) and authenticated with a password (PBKDF2-SHA256).
The first account ever registered becomes the admin; everyone after is a
regular user.
Mutations are append-only events with unique ids — the same replication
model as ``BillingLedger`` — so accounts and API keys converge across the
tracker hive via gossip, and every dashboard can serve registration/login.
Sessions are local to each tracker and persisted so dashboard cookies survive
tracker restarts.
"""
from __future__ import annotations
import hashlib
import json
import re
import secrets
import sqlite3
import threading
import time
import uuid
DEFAULT_ACCOUNTS_DB_PATH = "accounts.sqlite"
SESSION_TTL = 7 * 86400.0 # seconds
PBKDF2_ITERATIONS = 200_000
MIN_PASSWORD_LENGTH = 8
API_KEY_PREFIX = "sk-mesh-"
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _hash_password(password: str, salt: str) -> str:
return hashlib.pbkdf2_hmac(
"sha256", password.encode(), bytes.fromhex(salt), PBKDF2_ITERATIONS
).hex()
class AccountStore:
"""Thread-safe account/API-key store with SQLite persistence and event replication."""
def __init__(self, db_path: str | None = None) -> None:
self._db_path = db_path
self._lock = threading.Lock()
self._accounts: dict[str, dict] = {} # account_id -> record
self._by_identifier: dict[str, str] = {} # lowercased email/wallet -> account_id
self._active_keys: dict[str, str] = {} # api_key -> account_id
self._revoked_keys: set[str] = set()
self._sessions: dict[str, dict] = {} # token -> {account_id, expires}
self._seen_event_ids: set[str] = set()
self._event_log: list[dict] = []
self._dirty = False
if self._db_path:
self._init_db()
self._load_from_db()
# ---- registration & login ----
def register(
self,
*,
email: str | None = None,
wallet: str | None = None,
password: str,
) -> dict:
"""Create an account. The first account becomes the admin.
Raises ValueError with a user-facing message on invalid input.
"""
email = (email or "").strip().lower() or None
wallet = (wallet or "").strip() or None
if not email and not wallet:
raise ValueError("provide an email or a wallet address")
if email and not _EMAIL_RE.match(email):
raise ValueError("invalid email address")
if len(password or "") < MIN_PASSWORD_LENGTH:
raise ValueError(f"password must be at least {MIN_PASSWORD_LENGTH} characters")
with self._lock:
for identifier in filter(None, (email, wallet)):
if identifier.lower() in self._by_identifier:
raise ValueError(f"an account for {identifier!r} already exists")
salt = secrets.token_hex(16)
event = {
"id": f"register-{uuid.uuid4().hex}",
"type": "register",
"account_id": f"acct-{uuid.uuid4().hex}",
"email": email,
"wallet": wallet,
"role": "admin" if not self._accounts else "user",
"password_hash": _hash_password(password, salt),
"salt": salt,
"ts": time.time(),
}
self._apply_locked(event)
return self._public_view(self._accounts[event["account_id"]])
def verify_login(self, identifier: str, password: str) -> dict | None:
"""Return the public account view when credentials match, else None."""
with self._lock:
account_id = self._by_identifier.get((identifier or "").strip().lower())
if account_id is None:
return None
record = self._accounts[account_id]
if _hash_password(password or "", record["salt"]) != record["password_hash"]:
return None
return self._public_view(record)
# ---- sessions (local to this tracker) ----
def create_session(self, account_id: str) -> str:
token = secrets.token_urlsafe(32)
with self._lock:
self._sessions[token] = {
"account_id": account_id,
"expires": time.time() + SESSION_TTL,
}
self._dirty = True
self.save_to_db()
return token
def session_account(self, token: str | None) -> dict | None:
if not token:
return None
with self._lock:
session = self._sessions.get(token)
if session is None:
return None
if session["expires"] < time.time():
del self._sessions[token]
return None
record = self._accounts.get(session["account_id"])
return self._public_view(record) if record else None
def destroy_session(self, token: str | None) -> None:
if not token:
return
with self._lock:
if self._sessions.pop(token, None) is not None:
self._dirty = True
self.save_to_db()
# ---- API keys ----
def create_api_key(self, account_id: str) -> str:
api_key = API_KEY_PREFIX + secrets.token_hex(24)
with self._lock:
if account_id not in self._accounts:
raise ValueError("unknown account")
self._apply_locked({
"id": f"createkey-{uuid.uuid4().hex}",
"type": "create_key",
"account_id": account_id,
"api_key": api_key,
"ts": time.time(),
})
return api_key
def revoke_api_key(self, account_id: str, api_key: str) -> bool:
"""Revoke a key owned by ``account_id``. Returns False if not owned."""
with self._lock:
if self._active_keys.get(api_key) != account_id:
return False
self._apply_locked({
"id": f"revokekey-{uuid.uuid4().hex}",
"type": "revoke_key",
"account_id": account_id,
"api_key": api_key,
"ts": time.time(),
})
return True
def keys_for(self, account_id: str) -> list[str]:
with self._lock:
return sorted(
key for key, owner in self._active_keys.items() if owner == account_id
)
def is_key_revoked(self, api_key: str) -> bool:
with self._lock:
return api_key in self._revoked_keys
def is_active_key(self, api_key: str) -> bool:
with self._lock:
return api_key in self._active_keys
def owner_of_key(self, api_key: str) -> str | None:
with self._lock:
return self._active_keys.get(api_key)
# ---- views ----
def _public_view(self, record: dict) -> dict:
return {
"account_id": record["account_id"],
"email": record.get("email"),
"wallet": record.get("wallet"),
"role": record["role"],
"created_ts": record.get("ts", 0.0),
}
def get_account(self, account_id: str) -> dict | None:
with self._lock:
record = self._accounts.get(account_id)
return self._public_view(record) if record else None
def list_accounts(self) -> list[dict]:
with self._lock:
views = []
for record in self._accounts.values():
view = self._public_view(record)
view["api_keys"] = sorted(
key for key, owner in self._active_keys.items()
if owner == record["account_id"]
)
views.append(view)
return sorted(views, key=lambda v: v["created_ts"])
def account_count(self) -> int:
with self._lock:
return len(self._accounts)
# ---- replication (same model as BillingLedger) ----
def events_since(self, index: int) -> tuple[list[dict], int]:
with self._lock:
return list(self._event_log[index:]), len(self._event_log)
def apply_events(self, events: list[dict]) -> int:
applied = 0
with self._lock:
for event in events:
event_id = event.get("id")
if not event_id or event_id in self._seen_event_ids:
continue
self._apply_locked(event)
applied += 1
return applied
def _apply_locked(self, event: dict) -> None:
etype = event.get("type")
if etype == "register":
account_id = event["account_id"]
record = {
"account_id": account_id,
"email": event.get("email"),
"wallet": event.get("wallet"),
"role": event.get("role", "user"),
"password_hash": event["password_hash"],
"salt": event["salt"],
"ts": float(event.get("ts", 0.0)),
}
self._accounts[account_id] = record
for identifier in filter(None, (record["email"], record["wallet"])):
self._by_identifier.setdefault(identifier.lower(), account_id)
elif etype == "create_key":
api_key = event["api_key"]
if api_key not in self._revoked_keys:
self._active_keys[api_key] = event["account_id"]
elif etype == "revoke_key":
api_key = event["api_key"]
self._active_keys.pop(api_key, None)
self._revoked_keys.add(api_key)
else:
return
self._seen_event_ids.add(event["id"])
self._event_log.append(event)
self._dirty = True
# ---- persistence ----
def _init_db(self) -> None:
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
con.execute(
"CREATE TABLE IF NOT EXISTS account_events "
"(event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, ts REAL NOT NULL)"
)
con.execute(
"CREATE TABLE IF NOT EXISTS account_sessions "
"(token TEXT PRIMARY KEY, account_id TEXT NOT NULL, expires REAL NOT NULL)"
)
con.commit()
con.close()
def _load_from_db(self) -> None:
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
rows = con.execute(
"SELECT payload FROM account_events ORDER BY ts, event_id"
).fetchall()
session_rows = con.execute(
"SELECT token, account_id, expires FROM account_sessions WHERE expires >= ?",
(time.time(),),
).fetchall()
con.close()
with self._lock:
for (payload,) in rows:
try:
event = json.loads(payload)
except json.JSONDecodeError:
continue
if event.get("id") not in self._seen_event_ids:
self._apply_locked(event)
self._sessions = {
token: {"account_id": account_id, "expires": float(expires)}
for token, account_id, expires in session_rows
if account_id in self._accounts
}
self._dirty = False
def save_to_db(self) -> None:
if not self._db_path:
return
with self._lock:
if not self._dirty:
return
events = list(self._event_log)
sessions = [
(token, session["account_id"], float(session["expires"]))
for token, session in self._sessions.items()
if session["expires"] >= time.time()
]
self._dirty = False
con = sqlite3.connect(self._db_path) # type: ignore[arg-type]
con.executemany(
"INSERT OR IGNORE INTO account_events (event_id, payload, ts) VALUES (?, ?, ?)",
[(e["id"], json.dumps(e), float(e.get("ts", 0.0))) for e in events],
)
con.execute("DELETE FROM account_sessions")
con.executemany(
"INSERT INTO account_sessions (token, account_id, expires) VALUES (?, ?, ?)",
sessions,
)
con.commit()
con.close()