| """ |
| 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) |
|
|
|
|
| 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) |
|
|