Disattiva temporaneamente form prenotazioni
This commit is contained in:
@@ -1,27 +1,88 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
|
||||
from app.schemas.booking import BookingRequestCreate, BookingRequestResponse
|
||||
from app.config import settings
|
||||
from app.schemas.booking import BookingCaptchaChallenge, BookingRequestCreate, BookingRequestResponse
|
||||
from app.services.booking_security import (
|
||||
create_captcha_challenge,
|
||||
get_client_ip,
|
||||
is_rate_limited,
|
||||
new_request_id,
|
||||
verify_captcha,
|
||||
write_booking_audit_event,
|
||||
)
|
||||
from app.services.mailer import MailConfigurationError, send_booking_request_email
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["booking"])
|
||||
|
||||
|
||||
@router.get("/booking-captcha", response_model=BookingCaptchaChallenge)
|
||||
def get_booking_captcha() -> BookingCaptchaChallenge:
|
||||
return create_captcha_challenge()
|
||||
|
||||
|
||||
@router.post("/booking-request", response_model=BookingRequestResponse)
|
||||
def create_booking_request(payload: BookingRequestCreate) -> BookingRequestResponse:
|
||||
def create_booking_request(request: Request, payload: BookingRequestCreate) -> BookingRequestResponse:
|
||||
request_id = new_request_id()
|
||||
write_booking_audit_event("received", request_id=request_id, request=request, payload=payload)
|
||||
|
||||
if not settings.booking_form_enabled:
|
||||
write_booking_audit_event("blocked_form_disabled", request_id=request_id, request=request, payload=payload)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Form in aggiornamento.",
|
||||
)
|
||||
|
||||
if payload.website:
|
||||
write_booking_audit_event("blocked_honeypot", request_id=request_id, request=request, payload=payload)
|
||||
return BookingRequestResponse(
|
||||
message="La richiesta è stata inviata correttamente e sarà presa in carico al più presto dal team.",
|
||||
)
|
||||
|
||||
client_ip = get_client_ip(request)
|
||||
if is_rate_limited(client_ip):
|
||||
write_booking_audit_event("blocked_rate_limit", request_id=request_id, request=request, payload=payload)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Sono state inviate troppe richieste. Riprova più tardi.",
|
||||
)
|
||||
|
||||
if not verify_captcha(payload.captcha_id, payload.captcha_answer):
|
||||
write_booking_audit_event("blocked_captcha", request_id=request_id, request=request, payload=payload)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Verifica anti-spam non valida. Ricarica il codice e riprova.",
|
||||
)
|
||||
|
||||
try:
|
||||
send_booking_request_email(payload)
|
||||
except MailConfigurationError as exc:
|
||||
write_booking_audit_event(
|
||||
"failed_configuration",
|
||||
request_id=request_id,
|
||||
request=request,
|
||||
payload=payload,
|
||||
error=exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Il servizio email non è ancora configurato.",
|
||||
) from exc
|
||||
except Exception as exc: # pragma: no cover - mail delivery path
|
||||
write_booking_audit_event(
|
||||
"failed_delivery",
|
||||
request_id=request_id,
|
||||
request=request,
|
||||
payload=payload,
|
||||
error=exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Si è verificato un problema durante l'invio della richiesta.",
|
||||
) from exc
|
||||
|
||||
write_booking_audit_event("sent", request_id=request_id, request=request, payload=payload)
|
||||
|
||||
return BookingRequestResponse(
|
||||
message="La richiesta è stata inviata correttamente e sarà presa in carico al più presto dal team.",
|
||||
)
|
||||
|
||||
@@ -32,6 +32,23 @@ class Settings:
|
||||
"BOOKING_EMAIL_SUBJECT_PREFIX",
|
||||
"[Clinica Veterinaria Formiginese]",
|
||||
)
|
||||
self.booking_form_enabled = os.getenv("BOOKING_FORM_ENABLED", "false").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
self.booking_audit_log_path = os.getenv(
|
||||
"BOOKING_AUDIT_LOG_PATH",
|
||||
str((BASE_DIR / "logs" / "booking_audit.log").resolve()),
|
||||
)
|
||||
self.booking_rate_limit_window_seconds = int(os.getenv("BOOKING_RATE_LIMIT_WINDOW_SECONDS", "900"))
|
||||
self.booking_rate_limit_max_requests = int(os.getenv("BOOKING_RATE_LIMIT_MAX_REQUESTS", "5"))
|
||||
self.booking_captcha_ttl_seconds = int(os.getenv("BOOKING_CAPTCHA_TTL_SECONDS", "600"))
|
||||
self.booking_captcha_secret = os.getenv(
|
||||
"BOOKING_CAPTCHA_SECRET",
|
||||
self.smtp_password or self.app_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def sqlite_file_path(self) -> str | None:
|
||||
|
||||
@@ -3,6 +3,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class BookingRequestCreate(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
email: str = Field(min_length=5, max_length=255)
|
||||
phone: str = Field(min_length=5, max_length=40)
|
||||
pet_name: str = Field(default="", max_length=120)
|
||||
pet_type: str = Field(default="cane", max_length=40)
|
||||
@@ -11,8 +12,16 @@ class BookingRequestCreate(BaseModel):
|
||||
date: str = Field(min_length=8, max_length=20)
|
||||
time: str = Field(default="", max_length=20)
|
||||
notes: str = Field(default="", max_length=2000)
|
||||
website: str = Field(default="", max_length=200)
|
||||
captcha_id: str = Field(min_length=20, max_length=300)
|
||||
captcha_answer: str = Field(min_length=1, max_length=10)
|
||||
|
||||
|
||||
class BookingRequestResponse(BaseModel):
|
||||
success: bool = True
|
||||
message: str
|
||||
|
||||
|
||||
class BookingCaptchaChallenge(BaseModel):
|
||||
captcha_id: str
|
||||
question: str
|
||||
|
||||
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