Files
denya-onecare/app/routers/whatsapp.py
T
root 3ae1062d65 P0 security batch: admin-only user mgmt, unified role model, login rate limiting, webhook secret, security headers, pagination caps
- Remove POST /api/auth/register (404); no sign-up UI; users are admin-managed
- Add admin-only POST/PATCH/DELETE /api/auth/users (forced canonical roles,
  self-lockout + reference guards)
- Unify role model in app/core/roles.py; reject unknown roles at creation and
  at login/JWT validation; startup normalizes unambiguous legacy aliases
- Login rate limiting ~5 fails/15 min per IP+email -> 429 (in-process, tunable)
- WhatsApp webhook requires X-Webhook-Secret; fail-closed when env unset;
  GET handshake uses constant-time verify token (403 on mismatch)
- GET /api/whatsapp/mock-log now requires auth
- Security headers middleware: X-Frame-Options DENY, nosniff, CSP on HTML,
  HSTS behind TLS
- Pagination: limit alias for page_size, hard cap enforced, both -> 422
2026-09-08 10:36:16 +00:00

222 lines
8.6 KiB
Python

"""WhatsApp webhook handler — Meta Graph API integration.
P0 hardening:
* ``POST /api/whatsapp/webhook`` requires the ``X-Webhook-Secret`` header to
match ``WHATSAPP_WEBHOOK_SECRET``. Fail-closed: when the env var is unset
every message is rejected (same posture as the SECRET_KEY guard).
* ``GET /api/whatsapp/webhook`` (Meta handshake) validates ``hub.verify_token``
with a constant-time compare and returns 403 on mismatch.
* ``GET /api/whatsapp/mock-log`` now requires authentication (was a public
debug endpoint that could 500 and leak stack traces without auth).
"""
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timezone
from typing import Annotated
import httpx
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User
from app.models.whatsapp_log import WhatsAppLog
from app.schemas.whatsapp import (
MetaWebhookRequest,
MockWhatsAppLogEntry,
WebhookVerificationResponse,
WhatsAppReplyResponse,
)
from app.services.ticket import create_ticket
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/whatsapp", tags=["whatsapp"])
REPLY_TEMPLATE = (
"Thank you for contacting Denya OneCare. "
"Your ticket number is {ticket_number}. "
"We will get back to you soon."
)
# ── Meta Graph API helpers ──────────────────────────────────────────
async def send_whatsapp_reply(
to_phone: str,
text: str,
) -> WhatsAppReplyResponse:
"""Send a text message via Meta Graph API."""
url = f"{settings.META_GRAPH_BASE}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages"
headers = {
"Authorization": f"Bearer {settings.WHATSAPP_ACCESS_TOKEN}",
"Content-Type": "application/x-www-form-urlencoded",
}
data = {
"messaging_product": "whatsapp",
"to": to_phone,
"type": "text",
"text": {"body": text},
}
# Encode nested dict as JSON string for form data (Meta requirement)
data["text"] = '{"body":' + f'"{text}"' + "}"
try:
async with httpx.AsyncClient() as client:
response = await client.post(url, headers=headers, data=data, timeout=15.0)
response.raise_for_status()
return WhatsAppReplyResponse(success=True, message="Reply sent")
except httpx.HTTPError as exc:
logger.error("Failed to send WhatsApp reply: %s", exc)
return WhatsAppReplyResponse(success=False, message=str(exc))
# ── Webhook auth (fail-closed) ──────────────────────────────────────
async def require_webhook_secret(
x_webhook_secret: Annotated[str | None, Header(alias="X-Webhook-Secret")] = None,
) -> None:
"""Reject webhook messages unless X-Webhook-Secret matches the env secret.
Reads ``settings.WHATSAPP_WEBHOOK_SECRET`` at request time so the value
can be injected per-deployment. Empty/unset env ⇒ reject everything.
"""
expected = settings.WHATSAPP_WEBHOOK_SECRET
if not expected:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Webhook disabled: WHATSAPP_WEBHOOK_SECRET is not configured",
)
if not x_webhook_secret or not secrets.compare_digest(x_webhook_secret, expected):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid webhook secret",
)
# ── Webhook endpoint ────────────────────────────────────────────────
@router.get("/webhook")
async def whatsapp_webhook_verify(
mode: str | None = Query(None, alias="hub.mode"),
verify_token: str | None = Query(None, alias="hub.verify_token"),
challenge: str | None = Query(None, alias="hub.challenge"),
) -> WebhookVerificationResponse | dict:
"""Meta webhook handshake (GET): echo the challenge when the token matches."""
if mode != "subscribe" or not challenge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing hub.mode / hub.challenge",
)
expected = settings.WHATSAPP_VERIFY_TOKEN
if not expected or not secrets.compare_digest(verify_token or "", expected):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Verify token mismatch",
)
return WebhookVerificationResponse(challenge=challenge)
async def _process_entries(
body: MetaWebhookRequest,
db: AsyncSession,
) -> dict:
"""Create tickets + logs for inbound messages. Shared by the POST handler."""
if not body.entry:
return {"status": "no entry"}
for entry in body.entry:
if not entry.changes:
continue
for change in entry.changes:
if not change.message or not change.message.text:
logger.info("Non-text message received, skipping")
continue
message_text = change.message.text.text
sender = change.message.from_field
wa_msg_id = change.message.id or change.id
# ── Create ticket ──────────────────────────────────────
try:
ticket = await create_ticket(
db,
data={
"description": message_text,
"reporter": sender,
"reported_via": "WhatsApp",
},
)
except Exception as exc:
logger.error("Failed to create ticket: %s", exc)
# Still log the message even if ticket creation fails
log = WhatsAppLog(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=None,
ticket_number=None,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
return {"status": "ticket creation failed, message logged"}
# ── Store WhatsApp log ─────────────────────────────────
log = WhatsAppLog(
from_number=sender,
message_text=message_text,
wa_message_id=wa_msg_id,
ticket_id=ticket.id,
ticket_number=ticket.ticket_number,
received_at=datetime.now(timezone.utc),
)
db.add(log)
await db.flush()
# ── Send auto-reply ────────────────────────────────────
reply_text = REPLY_TEMPLATE.format(ticket_number=ticket.ticket_number)
reply_result = await send_whatsapp_reply(sender, reply_text)
if not reply_result.success:
logger.warning(
"Auto-reply failed for ticket %s: %s",
ticket.ticket_number,
reply_result.message,
)
return {
"status": "processed",
"ticket_id": ticket.id,
"ticket_number": ticket.ticket_number,
"reply_sent": reply_result.success,
}
return {"status": "no messages"}
@router.post("/webhook")
async def whatsapp_webhook(
body: MetaWebhookRequest,
db: Annotated[AsyncSession, Depends(get_db)],
_auth: None = Depends(require_webhook_secret),
) -> dict:
"""Process an inbound WhatsApp message (Meta POST). Requires webhook secret."""
return await _process_entries(body, db)
# ── Debug endpoint (auth required) ──────────────────────────────────
@router.get("/mock-log", response_model=list[MockWhatsAppLogEntry])
async def mock_whatsapp_log(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_user)],
limit: int = Query(50, ge=1, le=200),
) -> list[MockWhatsAppLogEntry]:
"""Return recent WhatsApp webhook submissions (authenticated only)."""
result = await db.execute(
select(WhatsAppLog).order_by(WhatsAppLog.received_at.desc()).limit(limit)
)
return list(result.scalars().all())