Files
codexpi/django_ui/views.py
CodexPi 995b17a6e2
Some checks are pending
Qualité / tests (push) Waiting to run
feat: publish CodexPi contest snapshot
2026-11-14 20:23:59 +01:00

193 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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