from __future__ import annotations import json import hashlib import time from collections import deque from pathlib import Path from django.conf import settings from django.contrib.auth import authenticate, get_user_model, login, logout from django.core.cache import cache from django.http import FileResponse, Http404, JsonResponse from django.middleware.csrf import get_token, rotate_token from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_GET, require_POST from codexpi.security_audit import SecurityAuditLogger FRONTEND = settings.BASE_DIR / "frontend" / "dist" AUDIT = SecurityAuditLogger(settings.DATA_DIR / "security.log") WINDOW_SECONDS = 300 MAX_ATTEMPTS = 8 def client_ip(request) -> str: forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "") return (forwarded.split(",", 1)[0].strip() if forwarded else "") or request.META.get("HTTP_X_REAL_IP") or request.META.get("REMOTE_ADDR") or "unknown" def audit_session_id(session_key: str | None) -> str | None: """Return a non-reversible correlation id, never the session cookie value.""" if not session_key: return None return f"sha256:{hashlib.sha256(session_key.encode()).hexdigest()[:20]}" def security_headers(response): response["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" response["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" response["Cache-Control"] = "no-store" return response def json_body(request) -> dict: try: value = json.loads(request.body or b"{}") except (json.JSONDecodeError, UnicodeDecodeError): value = {} return value if isinstance(value, dict) else {} def rate_state(key: str) -> deque[float]: now = time.monotonic() values = deque(cache.get(key, [])) while values and values[0] < now - WINDOW_SECONDS: values.popleft() return values @require_GET @ensure_csrf_cookie def bootstrap(request): token = get_token(request) return security_headers(JsonResponse({"setup_required": not get_user_model().objects.exists(), "cookie_secure": settings.SESSION_COOKIE_SECURE, "csrf_token": token})) @require_POST def login_api(request): source = client_ip(request) body = json_body(request) username = str(body.get("username", ""))[:150] password = str(body.get("password", ""))[:512] ip_key = f"login-ip:{source}" user_key = f"login-user:{username.lower()}" events = rate_state(ip_key) if len(events) >= MAX_ATTEMPTS or cache.get(f"locked:{username.lower()}"): AUDIT.log("auth.login_rate_limited", source, username=username, outcome="denied", reason="rate_limit_or_lock") return security_headers(JsonResponse({"detail": "Trop de tentatives. Réessayez plus tard."}, status=429)) user = authenticate(request, username=username, password=password) if user is None or not user.is_active: now = time.monotonic() events.append(now) cache.set(ip_key, list(events), WINDOW_SECONDS) failures = int(cache.get(user_key, 0)) + 1 cache.set(user_key, failures, 900) if failures >= 5: cache.set(f"locked:{username.lower()}", True, 900) time.sleep(min(0.25 * (2 ** max(0, failures - 1)), 4.0)) AUDIT.log("auth.login_failed", source, username=username, outcome="denied", reason="invalid_or_locked") return security_headers(JsonResponse({"detail": "Identifiants invalides ou compte temporairement verrouillé"}, status=401)) cache.delete(ip_key) cache.delete(user_key) cache.delete(f"locked:{username.lower()}") login(request, user) rotate_token(request) csrf_token = get_token(request) request.session["codexpi_username"] = user.get_username() request.session["codexpi_is_admin"] = bool(user.is_staff or user.is_superuser) request.session["codexpi_api_csrf"] = csrf_token request.session.set_expiry(settings.SESSION_COOKIE_AGE) request.session.save() correlation_id = audit_session_id(request.session.session_key) AUDIT.log("auth.login_succeeded", source, username=user.get_username(), session_id=correlation_id, outcome="allowed") AUDIT.log("session.created", source, username=user.get_username(), session_id=correlation_id, outcome="created") return security_headers(JsonResponse({"username": user.get_username(), "is_admin": bool(user.is_staff or user.is_superuser), "csrf_token": csrf_token, "expires_at": request.session.get_expiry_date().isoformat()})) @require_POST def setup_api(request): User = get_user_model() if User.objects.exists(): return security_headers(JsonResponse({"detail": "La configuration initiale est déjà terminée"}, status=409)) body = json_body(request) username = str(body.get("username", "")).strip() password = str(body.get("password", "")) if not username or len(username) < 3 or len(password) < 12: return security_headers(JsonResponse({"detail": "Utilisez un nom valide et un mot de passe d’au moins 12 caractères"}, status=400)) user = User.objects.create_superuser(username=username, password=password) login(request, user) rotate_token(request) csrf_token = get_token(request) request.session["codexpi_username"] = user.get_username() request.session["codexpi_is_admin"] = True request.session["codexpi_api_csrf"] = csrf_token request.session.set_expiry(settings.SESSION_COOKIE_AGE) request.session.save() AUDIT.log("auth.initial_admin_created", client_ip(request), username=user.get_username(), outcome="created") return security_headers(JsonResponse({"username": user.get_username(), "is_admin": True, "csrf_token": csrf_token}, status=201)) @require_POST def logout_api(request): if not request.user.is_authenticated: return security_headers(JsonResponse({"detail": "Authentification requise"}, status=401)) source = client_ip(request) username = request.user.get_username() session_id = audit_session_id(request.session.session_key) logout(request) AUDIT.log("session.deleted", source, username=username, session_id=session_id, outcome="revoked") return security_headers(JsonResponse({"ok": True})) @require_GET def me_api(request): if not request.user.is_authenticated: return security_headers(JsonResponse({"detail": "Authentification requise"}, status=401)) token = request.session.get("codexpi_api_csrf") if not token: token = get_token(request) request.session["codexpi_api_csrf"] = token return security_headers(JsonResponse({"username": request.user.get_username(), "is_admin": bool(request.user.is_staff or request.user.is_superuser), "csrf_token": token, "expires_at": request.session.get_expiry_date().isoformat()})) def safe_file(root: Path, relative: str) -> Path: candidate = (root / relative).resolve() resolved = root.resolve() if candidate != resolved and resolved not in candidate.parents: raise Http404 if not candidate.is_file(): raise Http404 return candidate def index(_request): response = FileResponse(safe_file(FRONTEND, "index.html").open("rb"), content_type="text/html") return security_headers(response) def asset(_request, path: str): return FileResponse(safe_file(FRONTEND / "assets", path).open("rb")) def icon(_request, path: str): return FileResponse(safe_file(FRONTEND / "icons", path).open("rb")) def manifest(_request): return FileResponse(safe_file(FRONTEND, "manifest.webmanifest").open("rb"), content_type="application/manifest+json") def frontend_version(_request): response = FileResponse(safe_file(FRONTEND, "version.json").open("rb"), content_type="application/json") response["Cache-Control"] = "no-store" return response def service_worker(_request): response = FileResponse(safe_file(FRONTEND, "sw.js").open("rb"), content_type="application/javascript") response["Service-Worker-Allowed"] = "/" response["Cache-Control"] = "no-cache" return response