# backend/auth/user_store.py # CRUD operations on data/users.json with atomic writes. # File is read on each auth request (no stale cache), written atomically # via tmp+rename to prevent corruption on crash. import json import logging import shutil import threading import uuid from datetime import datetime, timedelta, timezone from pathlib import Path from .password import hash_password logger = logging.getLogger("obsigate.auth.users") USERS_FILE = Path("data/users.json") # Serialises read-modify-write cycles on users.json. ``RLock`` because a few # helpers (e.g. ``record_login_failure``) call other mutators while holding it. # BUG-029: without this, concurrent MFA enable + password change could lose one # of the two updates (last writer wins). _users_lock = threading.RLock() def _read() -> dict: """Read users.json. Returns empty structure if file doesn't exist.""" if not USERS_FILE.exists(): return {"version": 1, "users": {}} try: return json.loads(USERS_FILE.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as e: logger.error(f"Failed to read users.json: {e}") return {"version": 1, "users": {}} def _write(data: dict): """Atomic write: write to .tmp then rename to prevent corruption.""" try: USERS_FILE.parent.mkdir(parents=True, exist_ok=True) tmp = USERS_FILE.with_suffix(".tmp") tmp.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8") shutil.move(str(tmp), str(USERS_FILE)) try: USERS_FILE.chmod(0o600) except OSError: pass # Windows doesn't support Unix permissions except PermissionError as e: logger.critical("=" * 60) logger.critical(f"ERREUR DE PERMISSION : Impossible d'écrire dans {USERS_FILE.parent}") logger.critical("Le conteneur n'a pas les droits sur le volume monté dans /app/data.") logger.critical("FIX : Exécutez sur l'hôte (Linux) : sudo chown -R 1000:1000 /votre/chemin/data") logger.critical("=" * 60) raise e def has_users() -> bool: """Check if any users exist.""" return bool(_read()["users"]) def get_user(username: str) -> dict | None: """Get a user by username. Returns None if not found.""" return _read()["users"].get(username) def get_all_users() -> list[dict]: """Get all users WITHOUT password_hash (safe for API responses).""" users = _read()["users"] return [ {k: v for k, v in u.items() if k != "password_hash"} for u in users.values() ] def create_user( username: str, password: str, role: str = "user", vaults: list[str] | None = None, display_name: str | None = None, ) -> dict: """Create a new user. Raises ValueError if username already taken.""" with _users_lock: data = _read() if username in data["users"]: raise ValueError(f"User '{username}' already exists") user = { "id": str(uuid.uuid4()), "username": username, "display_name": display_name or username, "password_hash": hash_password(password), "role": role, "vaults": vaults or [], "active": True, "language": "fr", # default UI language "avatar": None, # profile picture data-URL (#113) "created_at": datetime.now(timezone.utc).isoformat(), "password_changed_at": datetime.now(timezone.utc).timestamp(), "last_login": None, "failed_attempts": 0, "locked_until": None, } data["users"][username] = user _write(data) logger.info(f"Created user '{username}' (role={role})") return {k: v for k, v in user.items() if k != "password_hash"} def update_user(username: str, updates: dict) -> dict: """Update user fields. Raises ValueError if user not found. Forbidden fields (id, username, created_at) are silently ignored. If 'password' is in updates, it's hashed and stored as password_hash. """ with _users_lock: data = _read() if username not in data["users"]: raise ValueError(f"User '{username}' not found") forbidden = {"id", "username", "created_at"} safe_updates = {k: v for k, v in updates.items() if k not in forbidden} if "password" in safe_updates: safe_updates["password_hash"] = hash_password(safe_updates.pop("password")) # BUG-028: invalidate every token issued before this change. safe_updates["password_changed_at"] = datetime.now(timezone.utc).timestamp() data["users"][username].update(safe_updates) _write(data) result = {k: v for k, v in data["users"][username].items() if k != "password_hash"} return result def delete_user(username: str): """Delete a user. Raises ValueError if not found.""" with _users_lock: data = _read() if username not in data["users"]: raise ValueError(f"User '{username}' not found") del data["users"][username] _write(data) logger.info(f"Deleted user '{username}'") def record_login_success(username: str): """Record a successful login: update last_login, reset failed attempts.""" update_user(username, { "last_login": datetime.now(timezone.utc).isoformat(), "failed_attempts": 0, "locked_until": None, }) def record_login_failure(username: str) -> int: """Record a failed login. Returns the new attempt count. After 5 failures, locks the account for 15 minutes. """ with _users_lock: data = _read() user = data["users"].get(username) if not user: return 0 attempts = user.get("failed_attempts", 0) + 1 updates = {"failed_attempts": attempts} # Lock after 5 failed attempts (15 minutes) if attempts >= 5: locked_until = ( datetime.now(timezone.utc) + timedelta(minutes=15) ).isoformat() updates["locked_until"] = locked_until logger.warning(f"Account '{username}' locked after {attempts} failed attempts") update_user(username, updates) return attempts def is_locked(username: str) -> bool: """Check if an account is temporarily locked. Auto-unlocks if the lock period has expired. """ user = get_user(username) if not user or not user.get("locked_until"): return False locked_until = datetime.fromisoformat(user["locked_until"]) if datetime.now(timezone.utc) > locked_until: # Lock expired — unlock update_user(username, {"locked_until": None, "failed_attempts": 0}) return False return True