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)