Files
codexpi/django_ui/views.py

193 lines
8.1 KiB
Python
Raw Normal View History

2026-11-14 20:23:59 +01:00
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