Disattiva temporaneamente form prenotazioni
This commit is contained in:
169
backend/app/services/booking_security.py
Normal file
169
backend/app/services/booking_security.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict, deque
|
||||
from datetime import UTC, datetime
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.config import settings
|
||||
from app.schemas.booking import BookingCaptchaChallenge, BookingRequestCreate
|
||||
|
||||
|
||||
_audit_lock = threading.Lock()
|
||||
_rate_limit_lock = threading.Lock()
|
||||
_rate_limit_hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
def new_request_id() -> str:
|
||||
return uuid4().hex
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(",", 1)[0].strip()
|
||||
|
||||
real_ip = request.headers.get("x-real-ip", "")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def is_rate_limited(client_ip: str) -> bool:
|
||||
now = time.time()
|
||||
window = settings.booking_rate_limit_window_seconds
|
||||
max_requests = settings.booking_rate_limit_max_requests
|
||||
|
||||
with _rate_limit_lock:
|
||||
hits = _rate_limit_hits[client_ip]
|
||||
while hits and now - hits[0] > window:
|
||||
hits.popleft()
|
||||
|
||||
if len(hits) >= max_requests:
|
||||
return True
|
||||
|
||||
hits.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def create_captcha_challenge() -> BookingCaptchaChallenge:
|
||||
left = random.randint(2, 9)
|
||||
right = random.randint(2, 9)
|
||||
expires_at = int(time.time()) + settings.booking_captcha_ttl_seconds
|
||||
nonce = uuid4().hex
|
||||
answer = str(left + right)
|
||||
payload = f"{answer}:{expires_at}:{nonce}"
|
||||
signature = _sign(payload)
|
||||
token = _encode(f"{payload}:{signature}")
|
||||
|
||||
return BookingCaptchaChallenge(
|
||||
captcha_id=token,
|
||||
question=f"Quanto fa {left} + {right}?",
|
||||
)
|
||||
|
||||
|
||||
def verify_captcha(captcha_id: str, captcha_answer: str) -> bool:
|
||||
try:
|
||||
decoded = _decode(captcha_id)
|
||||
answer, expires_at_value, nonce, signature = decoded.split(":", 3)
|
||||
payload = f"{answer}:{expires_at_value}:{nonce}"
|
||||
expected_signature = _sign(payload)
|
||||
expires_at = int(expires_at_value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
if not hmac.compare_digest(signature, expected_signature):
|
||||
return False
|
||||
|
||||
if expires_at < int(time.time()):
|
||||
return False
|
||||
|
||||
return hmac.compare_digest(captcha_answer.strip(), answer)
|
||||
|
||||
|
||||
def write_booking_audit_event(
|
||||
event: str,
|
||||
*,
|
||||
request_id: str,
|
||||
request: Request,
|
||||
payload: BookingRequestCreate | None = None,
|
||||
error: Exception | str | None = None,
|
||||
) -> None:
|
||||
client_ip = get_client_ip(request)
|
||||
record: dict[str, Any] = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"event": event,
|
||||
"request_id": request_id,
|
||||
"client_ip": client_ip,
|
||||
"user_agent": request.headers.get("user-agent", "")[:300],
|
||||
"referer": request.headers.get("referer", "")[:300],
|
||||
}
|
||||
|
||||
if payload is not None:
|
||||
record.update(
|
||||
{
|
||||
"email_hash": _hash_value(payload.email.lower().strip()),
|
||||
"email_domain": _email_domain(payload.email),
|
||||
"phone_hash": _hash_value(payload.phone.strip()),
|
||||
"name_length": len(payload.name),
|
||||
"pet_type": payload.pet_type,
|
||||
"doctor": payload.doctor,
|
||||
"service": payload.service,
|
||||
"date": payload.date,
|
||||
"time": payload.time,
|
||||
"notes_length": len(payload.notes or ""),
|
||||
"honeypot_filled": bool(payload.website),
|
||||
}
|
||||
)
|
||||
|
||||
if error is not None:
|
||||
record["error_type"] = error.__class__.__name__ if isinstance(error, Exception) else "Error"
|
||||
record["error"] = str(error)[:500]
|
||||
|
||||
log_path = Path(settings.booking_audit_log_path)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with _audit_lock:
|
||||
with log_path.open("a", encoding="utf-8") as log_file:
|
||||
log_file.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def _sign(payload: str) -> str:
|
||||
return hmac.new(
|
||||
settings.booking_captcha_secret.encode("utf-8"),
|
||||
payload.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _encode(value: str) -> str:
|
||||
return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode(value: str) -> str:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii")).decode("utf-8")
|
||||
|
||||
|
||||
def _hash_value(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _email_domain(email: str) -> str:
|
||||
if "@" not in email:
|
||||
return ""
|
||||
return email.rsplit("@", 1)[1].lower()[:120]
|
||||
@@ -25,13 +25,14 @@ def _build_booking_html(payload: BookingRequestCreate) -> str:
|
||||
<p>È stata inviata una nuova richiesta di prenotazione non vincolante dal sito web.</p>
|
||||
<table cellpadding="8" cellspacing="0" border="0" style="border-collapse: collapse;">
|
||||
<tr><td><strong>Nome e cognome</strong></td><td>{payload.name}</td></tr>
|
||||
<tr><td><strong>Email</strong></td><td>{payload.email}</td></tr>
|
||||
<tr><td><strong>Telefono</strong></td><td>{payload.phone}</td></tr>
|
||||
<tr><td><strong>Nome animale</strong></td><td>{payload.pet_name or "-"}</td></tr>
|
||||
<tr><td><strong>Tipo animale</strong></td><td>{payload.pet_type}</td></tr>
|
||||
<tr><td><strong>Medico richiesto</strong></td><td>{payload.doctor}</td></tr>
|
||||
<tr><td><strong>Tipo di visita</strong></td><td>{payload.service}</td></tr>
|
||||
<tr><td><strong>Data preferita</strong></td><td>{payload.date}</td></tr>
|
||||
<tr><td><strong>Orario preferito</strong></td><td>{payload.time or "Qualsiasi orario"}</td></tr>
|
||||
<tr><td><strong>Periodo preferito</strong></td><td>{payload.date}</td></tr>
|
||||
<tr><td><strong>Fascia oraria preferita</strong></td><td>{payload.time or "Qualsiasi orario"}</td></tr>
|
||||
<tr><td><strong>Note</strong></td><td>{payload.notes or "-"}</td></tr>
|
||||
<tr><td><strong>Inviata il</strong></td><td>{submitted_at}</td></tr>
|
||||
</table>
|
||||
@@ -49,13 +50,14 @@ def _build_booking_text(payload: BookingRequestCreate) -> str:
|
||||
return (
|
||||
"Nuova richiesta di prenotazione visita\n\n"
|
||||
f"Nome e cognome: {payload.name}\n"
|
||||
f"Email: {payload.email}\n"
|
||||
f"Telefono: {payload.phone}\n"
|
||||
f"Nome animale: {payload.pet_name or '-'}\n"
|
||||
f"Tipo animale: {payload.pet_type}\n"
|
||||
f"Medico richiesto: {payload.doctor}\n"
|
||||
f"Tipo di visita: {payload.service}\n"
|
||||
f"Data preferita: {payload.date}\n"
|
||||
f"Orario preferito: {payload.time or 'Qualsiasi orario'}\n"
|
||||
f"Periodo preferito: {payload.date}\n"
|
||||
f"Fascia oraria preferita: {payload.time or 'Qualsiasi orario'}\n"
|
||||
f"Note: {payload.notes or '-'}\n"
|
||||
f"Inviata il: {submitted_at}\n\n"
|
||||
"La richiesta non costituisce conferma automatica dell'appuntamento."
|
||||
@@ -70,6 +72,7 @@ def send_booking_request_email(payload: BookingRequestCreate) -> None:
|
||||
message["Subject"] = _build_booking_subject(payload)
|
||||
message["From"] = settings.booking_email_from
|
||||
message["To"] = settings.booking_email_to
|
||||
message["Reply-To"] = payload.email
|
||||
message.set_content(_build_booking_text(payload))
|
||||
message.add_alternative(_build_booking_html(payload), subtype="html")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user