""" auth.py Local, file-based multi-profile authentication for InsightUX. InsightUX is a single-process desktop app with no server and no database — this module IS the whole "backend": a JSON file of profiles (users.json, one entry per person who has ever used this install) plus salted-hash password verification. Plaintext passwords are never stored, logged, or returned to the caller. No heavy imports (stdlib only), so this can be imported cheaply from browser_session.py, calibrate.py, and validate.py alike — same reasoning theme.py already documents for its own "no cv2/mediapipe" import. """ import os import json import uuid import hmac import hashlib import secrets from datetime import datetime, timezone PBKDF2_ITERATIONS = 200_000 _HASH_NAME = "sha256" class AuthError(Exception): """Raised for user-facing auth failures (bad password, duplicate email, missing fields, ...) — callers show str(e) directly as the status message, same pattern as the rest of the app's _set_status calls.""" def _hash_password(password, salt=None): """Returns (salt_hex, hash_hex). A fresh random salt is generated unless one is supplied (re-hashing a login attempt for comparison).""" if salt is None: salt = secrets.token_bytes(16) elif isinstance(salt, str): salt = bytes.fromhex(salt) digest = hashlib.pbkdf2_hmac(_HASH_NAME, password.encode("utf-8"), salt, PBKDF2_ITERATIONS) return salt.hex(), digest.hex() def verify_password(password, salt_hex, hash_hex): _, candidate_hex = _hash_password(password, salt_hex) return hmac.compare_digest(candidate_hex, hash_hex) def _users_path(data_dir): return os.path.join(data_dir, "users.json") def load_users(data_dir): """{user_id: {...}} — empty dict if the file doesn't exist yet (first run) or is unreadable, never raises.""" path = _users_path(data_dir) if not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError): return {} def save_users(data_dir, users): os.makedirs(data_dir, exist_ok=True) path = _users_path(data_dir) tmp = path + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(users, f, indent=2) os.replace(tmp, path) # atomic on POSIX and Windows -- never leaves users.json half-written def _avatar_initial(name): name = (name or "").strip() return (name[0] if name else "?").upper() def public_profile(record): """Strip salt/hash before this ever reaches JS -- every function that hands profile data back to the frontend routes through this.""" return { "id": record["id"], "name": record["name"], "email": record["email"], "avatar": record.get("avatar") or _avatar_initial(record.get("name")), "created_at": record.get("created_at"), } def create_user(data_dir, name, email, password): name = (name or "").strip() email = (email or "").strip().lower() if not name: raise AuthError("Name is required.") if not email or "@" not in email: raise AuthError("A valid email is required.") if not password or len(password) < 6: raise AuthError("Password must be at least 6 characters.") users = load_users(data_dir) for existing in users.values(): if existing.get("email", "").lower() == email: raise AuthError("A profile with that email already exists.") user_id = uuid.uuid4().hex[:12] salt_hex, hash_hex = _hash_password(password) record = { "id": user_id, "name": name, "email": email, "salt": salt_hex, "hash": hash_hex, "avatar": _avatar_initial(name), "created_at": datetime.now(timezone.utc).isoformat(), } users[user_id] = record save_users(data_dir, users) return public_profile(record) def verify_login(data_dir, user_id, password): """Returns the public profile dict on success, raises AuthError (never a KeyError/generic exception) on any failure — bad id, missing password, wrong password all look the same to the caller.""" users = load_users(data_dir) record = users.get(user_id) if not record or not password or not verify_password(password, record["salt"], record["hash"]): raise AuthError("Incorrect email/profile or password.") return public_profile(record) def list_profiles(data_dir): """Public profile list for the login/switch-profile picker -- never includes salt/hash. Sorted by name for a stable, predictable picker.""" users = load_users(data_dir) profiles = [public_profile(u) for u in users.values()] profiles.sort(key=lambda p: p["name"].lower()) return profiles def user_dir(data_dir, user_id): """Every per-user file (sessions/, calibration.pkl, the fine-tuned ONNX) lives under this one root — the single place that maps a user id to a filesystem path.""" return os.path.join(data_dir, "users", user_id)