File size: 5,079 Bytes
f9609df | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 | """
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)
|