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.
This commit is contained in:
+64
-49
@@ -6,6 +6,7 @@
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -16,6 +17,12 @@ 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."""
|
||||
@@ -75,26 +82,28 @@ def create_user(
|
||||
display_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a new user. Raises ValueError if username already taken."""
|
||||
data = _read()
|
||||
if username in data["users"]:
|
||||
raise ValueError(f"User '{username}' already exists")
|
||||
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(),
|
||||
"last_login": None,
|
||||
"failed_attempts": 0,
|
||||
"locked_until": None,
|
||||
}
|
||||
data["users"][username] = user
|
||||
_write(data)
|
||||
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"}
|
||||
|
||||
@@ -105,28 +114,33 @@ def update_user(username: str, updates: dict) -> dict:
|
||||
Forbidden fields (id, username, created_at) are silently ignored.
|
||||
If 'password' is in updates, it's hashed and stored as password_hash.
|
||||
"""
|
||||
data = _read()
|
||||
if username not in data["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
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}
|
||||
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"))
|
||||
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)
|
||||
return {k: v for k, v in data["users"][username].items() if k != "password_hash"}
|
||||
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."""
|
||||
data = _read()
|
||||
if username not in data["users"]:
|
||||
raise ValueError(f"User '{username}' not found")
|
||||
del data["users"][username]
|
||||
_write(data)
|
||||
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}'")
|
||||
|
||||
|
||||
@@ -144,24 +158,25 @@ def record_login_failure(username: str) -> int:
|
||||
|
||||
After 5 failures, locks the account for 15 minutes.
|
||||
"""
|
||||
data = _read()
|
||||
user = data["users"].get(username)
|
||||
if not user:
|
||||
return 0
|
||||
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}
|
||||
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")
|
||||
# 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
|
||||
update_user(username, updates)
|
||||
return attempts
|
||||
|
||||
|
||||
def is_locked(username: str) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user