diff --git a/backend/app/api/booking.py b/backend/app/api/booking.py index c2d722b..42cbbb1 100644 --- a/backend/app/api/booking.py +++ b/backend/app/api/booking.py @@ -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.", ) diff --git a/backend/app/config.py b/backend/app/config.py index e131a89..ad92e6d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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: diff --git a/backend/app/schemas/booking.py b/backend/app/schemas/booking.py index c77ad0b..d6f04ea 100644 --- a/backend/app/schemas/booking.py +++ b/backend/app/schemas/booking.py @@ -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 diff --git a/backend/app/services/booking_security.py b/backend/app/services/booking_security.py new file mode 100644 index 0000000..77dcbe0 --- /dev/null +++ b/backend/app/services/booking_security.py @@ -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] diff --git a/backend/app/services/mailer.py b/backend/app/services/mailer.py index 36c7708..286d36e 100644 --- a/backend/app/services/mailer.py +++ b/backend/app/services/mailer.py @@ -25,13 +25,14 @@ def _build_booking_html(payload: BookingRequestCreate) -> str:
È stata inviata una nuova richiesta di prenotazione non vincolante dal sito web.
| Nome e cognome | {payload.name} |
| {payload.email} | |
| Telefono | {payload.phone} |
| Nome animale | {payload.pet_name or "-"} |
| Tipo animale | {payload.pet_type} |
| Medico richiesto | {payload.doctor} |
| Tipo di visita | {payload.service} |
| Data preferita | {payload.date} |
| Orario preferito | {payload.time or "Qualsiasi orario"} |
| Periodo preferito | {payload.date} |
| Fascia oraria preferita | {payload.time or "Qualsiasi orario"} |
| Note | {payload.notes or "-"} |
| Inviata il | {submitted_at} |
Compila il modulo per inviare una richiesta di appuntamento. La richiesta - non e vincolante e dovra essere confermata dallo staff della clinica, che ti - ricontattera il prima possibile. + non è vincolante e dovrà essere confermata dallo staff della clinica, che ti + ricontatterà il prima possibile.
Richiesta non vincolante
L'invio del modulo non costituisce conferma automatica dell'appuntamento. - Il team verifichera disponibilita, tipologia di visita e urgenza del caso prima - di confermare data e orario. + Il team verificherà disponibilità, tipologia di visita e urgenza del caso prima + di confermare periodo e fascia oraria.
+ Il modulo di richiesta appuntamento è temporaneamente sospeso per manutenzione. + Per prenotazioni o informazioni contatta direttamente la clinica. +
+ + Chiama 320 532.24.39 + +- La tua richiesta di prenotazione e stata inviata correttamente. + La tua richiesta di prenotazione è stata inviata correttamente.
- Il team della Clinica Veterinaria Formiginese la prendera in carico il prima - possibile e ti ricontattera per confermare disponibilita, data e orario. + Il team della Clinica Veterinaria Formiginese la prenderà in carico il prima + possibile e ti ricontatterà per confermare disponibilità, data e orario.
- Compila i campi richiesti e inviaci una proposta di data: sara lo staff a - confermare la visita. + Compila i campi richiesti e inviaci una preferenza di periodo: sarà lo staff a + ricontattarti per confermare la visita.