Files
ObsiGate/backend/auth/user_store.py
T
bruno 162a5b4acc
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 47s
CI / test (push) Successful in 2m21s
CI / build (push) Successful in 43s
CI / e2e (push) Successful in 10m48s
fix(security): consolidation & securite phase 1 (#84, BUG-021 a BUG-034)
- sanitizer XSS serveur (markdown + page de partage) [BUG-021/022]
- rate-limit/lockout MFA [BUG-023]
- isolation vaults par segments [BUG-024]
- caps regex ReDoS [BUG-025]
- SSRF webhooks + secrets externalises [BUG-026]
- rotation/revocation des jetons [BUG-027]
- politique de mot de passe + invalidation sessions [BUG-028]
- verrous users.json [BUG-029]
- IP reelle dans les audits [BUG-030]
- rate-limit par compte [BUG-031]
- symlinks hors vault ignores [BUG-032]
- recherche simple via inverted index [BUG-033]
- token en memoire + cookie HttpOnly, CSP durcie [BUG-034]

Tests: pytest 961 passed / 6 skipped, ruff 0, mypy 0, frontend vert.
2026-09-13 10:51:42 -04:00

196 lines
6.6 KiB
Python

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