Insight_UX_1.0 / auth.py
Aryaman25's picture
Update Insight_UX_1.0 with latest changes: add auth, participants, tests, and dev requirements
f9609df
Raw
History Blame Contribute Delete
5.08 kB
"""
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)