193 lines
8.1 KiB
Python
193 lines
8.1 KiB
Python
|
|
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
|