300 lines
11 KiB
Python
300 lines
11 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 deliberately local to each tracker (bearer tokens in memory).
|
|
"""
|
|
|
|
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,
|
|
}
|
|
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:
|
|
self._sessions.pop(token, None)
|
|
|
|
# ---- 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
|
|
|
|
# ---- 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.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()
|
|
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._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)
|
|
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.commit()
|
|
con.close()
|