From 7496af72d0498c10836e4021bdcc9aefb3be8435 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Sun, 9 Aug 2026 06:02:32 -0500 Subject: [PATCH] Fix PCM auth: login for API token and multi webhook secrets. PCM DirectMail v3 needs POST /auth/login (apiKey+apiSecret) before design/order calls; accept each subscription's copy-only signature secret via PCM_WEBHOOK_SECRETS. --- .env.example | 10 +- .env.prod.example | 9 +- docs/monica-site-design.md | 5 +- site/messaging/README.md | 25 +++-- site/messaging/providers/postcard/pcm.py | 126 ++++++++++++++++++++++- site/messaging/tests.py | 82 ++++++++++++++- site/messaging/views.py | 67 +++++++++--- site/monica_site/settings/base.py | 8 +- 8 files changed, 299 insertions(+), 33 deletions(-) diff --git a/.env.example b/.env.example index d122fad..52bee3d 100644 --- a/.env.example +++ b/.env.example @@ -40,10 +40,16 @@ SMTP2GO_SMS_API_KEY= SMTP2GO_WEBHOOK_SECRET= # Postcards — PCM Integrations (DirectMail API v3) +# Portal → My Account → API Keys: copy Sandbox or Production key + secret. +# App POSTs /auth/login then uses the returned Bearer token (not the raw key). POSTCARD_PROVIDER=pcm PCM_API_KEY= -# Inbound webhook auth (Bearer / ?token=) — set a long random string -PCM_WEBHOOK_SECRET= +PCM_API_SECRET= +# Optional: PCM_CHILD_REF_NBR= +# Inbound webhook auth — PCM generates one signature secret per subscription. +# Paste all of them comma-separated (also accepts single PCM_WEBHOOK_SECRET=). +PCM_WEBHOOK_SECRETS= +# PCM_WEBHOOK_SECRET= # Return address on every postcard order (JSON). Example: # PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"123 Main St","city":"Naperville","state":"IL","zipCode":"60540"} PCM_RETURN_ADDRESS= diff --git a/.env.prod.example b/.env.prod.example index 255af8f..fa7eaf5 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -53,10 +53,15 @@ SMTP2GO_SMS_API_KEY=replace-me # Email + SMS event webhook auth. SMTP2GO Authorization header = Bearer + this value. SMTP2GO_WEBHOOK_SECRET=replace-me -# Postcards — PCM Integrations +# Postcards — PCM Integrations (key+secret → POST /auth/login → Bearer token) POSTCARD_PROVIDER=pcm PCM_API_KEY=replace-me -PCM_WEBHOOK_SECRET=replace-me +PCM_API_SECRET=replace-me +# PCM_CHILD_REF_NBR= +# One signature secret per PCM webhook subscription (comma-separated). +PCM_WEBHOOK_SECRETS=replace-me,replace-me-2 +# PCM_WEBHOOK_SECRET=replace-me # optional single-secret alias + # JSON return address for postcard orders PCM_RETURN_ADDRESS={"firstName":"Monica","lastName":"Dhillon","address":"replace-me","city":"replace-me","state":"IL","zipCode":"replace-me"} # LOB_API_KEY= # only if POSTCARD_PROVIDER=lob diff --git a/docs/monica-site-design.md b/docs/monica-site-design.md index 03fc087..7ffd097 100644 --- a/docs/monica-site-design.md +++ b/docs/monica-site-design.md @@ -471,7 +471,8 @@ SMTP2GO_SMS_API_KEY=... # Postcards (PCM Integrations) POSTCARD_PROVIDER=pcm # pcm|lob|click2mail|postgrid PCM_API_KEY=... -PCM_WEBHOOK_SECRET=... +PCM_API_SECRET=... +PCM_WEBHOOK_SECRETS=... PCM_RETURN_ADDRESS={...} # Social (native) @@ -522,7 +523,7 @@ TIANJI_WEBSITE_ID=... alternates); `POSTCARD_PROVIDER=pcm`. - PCM iframe designer; save `design_id` on `MessageTemplate`; postcard campaign audience; inbound PCM webhooks → `ProviderEvent`; campaign completion notify email. -- Client prerequisites: PCM Integrations account + API key, A2P 10DLC for SMS. +- Client prerequisites: PCM Integrations account + API key/secret, A2P 10DLC for SMS. - **Exit:** realtor mails a postcard batch and sees delivery tracking. ### Phase 4 — Social automation diff --git a/site/messaging/README.md b/site/messaging/README.md index 30a781e..857b346 100644 --- a/site/messaging/README.md +++ b/site/messaging/README.md @@ -51,11 +51,18 @@ Default postcard provider. Designer embeds PCM’s editor; orders use DirectMail | Var | Purpose | |-----|---------| -| `PCM_API_KEY` | Bearer token for `https://v3.pcmintegrations.com` | -| `PCM_WEBHOOK_SECRET` | Auth for inbound status webhooks | +| `PCM_API_KEY` | API key from PCM portal (My Account → API Keys) | +| `PCM_API_SECRET` | Matching API secret; used with key on `POST /auth/login` | +| `PCM_CHILD_REF_NBR` | Optional child-app ref for multi-account | +| `PCM_WEBHOOK_SECRETS` | Comma-separated signature secrets (one per PCM subscription) | +| `PCM_WEBHOOK_SECRET` | Optional single-secret alias (merged into the list above) | | `PCM_RETURN_ADDRESS` | JSON return address on orders | | `POSTCARD_PROVIDER` | `pcm` (default) | +Auth flow: `POST /auth/login` with `{apiKey, apiSecret}` → short-lived +`token` used as `Authorization: Bearer …` on design/order calls +([PCM Logging In](https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in)). + ### Designer Portal → **Postcard design**: create/list designs via API, edit in iframe @@ -64,16 +71,19 @@ Portal → **Postcard design**: create/list designs via API, edit in iframe ### Postcard webhook -Create a webhook subscription in the PCM dashboard (Working with Webhooks): +PCM allows **one event per subscription**, and each subscription gets its own +**signature secret** (copy-only in the UI). Create one subscription per status +you care about; point them all at the same URL and paste every secret into env. | Field | Value | |-------|--------| | URL | `https://mkdrealtor.com/portal/messaging/webhooks/postcard/` | -| Authorization | **Bearer** + `PCM_WEBHOOK_SECRET` | -| Events | Order / recipient status (Pending, Processing, Processed, Delivered, Undeliverable, Canceled) | +| Events | One subscription each: Pending, Processing, Processed, Delivered, Undeliverable, Canceled (skip QrCodeScan unless needed) | | Environments | Sandbox and/or Production as needed | +| Secrets | Copy each subscription signature → `PCM_WEBHOOK_SECRETS=sec1,sec2,…` | -Fallback: `?token=` on the URL. +We accept Bearer, `?token=`, or common signature headers (raw secret or +HMAC-SHA256 of body) matching **any** listed secret. Correlation: we send `extRefNbr=` on each recipient; webhooks should echo that (or `orderID`, matched to `Message.provider_message_id`). @@ -99,7 +109,8 @@ EMAIL_HOST_PASSWORD=… SMTP2GO_WEBHOOK_SECRET=… SMTP2GO_SMS_API_KEY=… # SMS sends only PCM_API_KEY=… -PCM_WEBHOOK_SECRET=… +PCM_API_SECRET=… +PCM_WEBHOOK_SECRETS=sec1,sec2,… PCM_RETURN_ADDRESS={…} ``` diff --git a/site/messaging/providers/postcard/pcm.py b/site/messaging/providers/postcard/pcm.py index f23044b..d4ce105 100644 --- a/site/messaging/providers/postcard/pcm.py +++ b/site/messaging/providers/postcard/pcm.py @@ -4,7 +4,9 @@ from __future__ import annotations import json import logging +import threading from dataclasses import dataclass +from datetime import datetime, timedelta, timezone from typing import Any import requests @@ -16,6 +18,9 @@ logger = logging.getLogger(__name__) PCM_API_BASE = "https://v3.pcmintegrations.com" +# Refresh a bit before PCM's expires timestamp. +_TOKEN_SKEW = timedelta(seconds=60) + # PCM size codes for custom designer designs. PCM_SIZE_CHOICES = ( ("46", "4.25 × 6"), @@ -30,17 +35,117 @@ class PcmApiError(RuntimeError): """Raised when a PCM API call fails.""" +_token_lock = threading.Lock() +_cached_token: str | None = None +_cached_expires: datetime | None = None + + def _api_key() -> str: return (settings.PCM_API_KEY or "").strip() -def _headers() -> dict[str, str]: - key = _api_key() - if not key: - raise PcmApiError("PCM_API_KEY is not configured") +def _api_secret() -> str: + return (settings.PCM_API_SECRET or "").strip() + + +def _parse_expires(raw: Any) -> datetime: + """Parse PCM expires timestamp; default to 55 minutes from now.""" + if isinstance(raw, (int, float)): + # Unix seconds vs ms + ts = float(raw) + if ts > 1e12: + ts /= 1000.0 + return datetime.fromtimestamp(ts, tz=timezone.utc) + if isinstance(raw, str) and raw.strip(): + text = raw.strip().replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(text) + except ValueError: + dt = None + if dt is not None: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + return datetime.now(timezone.utc) + timedelta(minutes=55) + + +def clear_token_cache() -> None: + """Drop cached bearer token (tests / forced re-login).""" + global _cached_token, _cached_expires + with _token_lock: + _cached_token = None + _cached_expires = None + + +def login(*, force: bool = False) -> str: + """POST /auth/login with apiKey + apiSecret → bearer token. + + See https://docs.pcmintegrations.com/docs/directmail-api/ffef03a112bb0-logging-in + """ + global _cached_token, _cached_expires + + with _token_lock: + now = datetime.now(timezone.utc) + if ( + not force + and _cached_token + and _cached_expires + and now < (_cached_expires - _TOKEN_SKEW) + ): + return _cached_token + + key = _api_key() + secret = _api_secret() + if not key or not secret: + raise PcmApiError( + "PCM_API_KEY and PCM_API_SECRET are required " + "(POST /auth/login, then use the returned token)" + ) + + body: dict[str, str] = {"apiKey": key, "apiSecret": secret} + child = (getattr(settings, "PCM_CHILD_REF_NBR", None) or "").strip() + if child: + body["childRefNbr"] = child + + response = requests.post( + f"{PCM_API_BASE}/auth/login", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + }, + json=body, + timeout=30, + ) + if response.status_code >= 400: + detail = (response.text or "")[:500] + raise PcmApiError( + f"PCM POST /auth/login → {response.status_code}: {detail}" + ) + try: + data = response.json() + except ValueError as exc: + raise PcmApiError("PCM login returned non-JSON") from exc + if not isinstance(data, dict): + raise PcmApiError("PCM login returned unexpected payload") + + token = str(data.get("token") or "").strip() + if not token: + raise PcmApiError("PCM login response missing token") + + _cached_token = token + _cached_expires = _parse_expires(data.get("expires")) + logger.info( + "PCM login ok; token expires %s", + _cached_expires.isoformat(), + ) + return token + + +def _headers(*, force_login: bool = False) -> dict[str, str]: + token = login(force=force_login) return { "Accept": "application/json", - "Authorization": f"Bearer {key}", + "Authorization": f"Bearer {token}", "Content-Type": "application/json", } @@ -63,6 +168,17 @@ def pcm_request( json=json_body, timeout=timeout, ) + # Expired/revoked session → login once and retry. + if response.status_code == 401: + clear_token_cache() + response = requests.request( + method, + url, + headers=_headers(force_login=True), + params=params, + json=json_body, + timeout=timeout, + ) if response.status_code >= 400: detail = (response.text or "")[:500] raise PcmApiError( diff --git a/site/messaging/tests.py b/site/messaging/tests.py index 439734a..7c6db4b 100644 --- a/site/messaging/tests.py +++ b/site/messaging/tests.py @@ -608,7 +608,7 @@ class PcmPostcardWebhookTests(TestCase): import json url = reverse("messaging:postcard_webhook") - with self.settings(PCM_WEBHOOK_SECRET="pcm-secret"): + with self.settings(PCM_WEBHOOK_SECRET="pcm-secret", PCM_WEBHOOK_SECRETS=""): denied = self.client.post( url, data=json.dumps({"status": "Delivered", "orderID": 555}), @@ -627,3 +627,83 @@ class PcmPostcardWebhookTests(TestCase): HTTP_AUTHORIZATION="Bearer pcm-secret", ) self.assertEqual(ok.status_code, 200) + + def test_accepts_any_secret_from_list(self): + import json + + url = reverse("messaging:postcard_webhook") + with self.settings( + PCM_WEBHOOK_SECRET="", + PCM_WEBHOOK_SECRETS="sec-a,sec-b", + ): + denied = self.client.post( + url, + data=json.dumps({"status": "Delivered", "orderID": "order-555"}), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer wrong", + ) + self.assertEqual(denied.status_code, 403) + ok = self.client.post( + url, + data=json.dumps({"status": "Delivered", "orderID": "order-555"}), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer sec-b", + ) + self.assertEqual(ok.status_code, 200) + + +class PcmAuthTests(TestCase): + def setUp(self): + from messaging.providers.postcard import pcm as pcm_mod + + pcm_mod.clear_token_cache() + + def tearDown(self): + from messaging.providers.postcard import pcm as pcm_mod + + pcm_mod.clear_token_cache() + + def test_login_required_before_design_list(self): + from unittest.mock import MagicMock, patch + + from messaging.providers.postcard import pcm as pcm_mod + + login_resp = MagicMock() + login_resp.status_code = 200 + login_resp.content = b'{"token":"session-tok","expires":"2099-01-01T00:00:00.000Z"}' + login_resp.json.return_value = { + "token": "session-tok", + "expires": "2099-01-01T00:00:00.000Z", + } + + design_resp = MagicMock() + design_resp.status_code = 200 + design_resp.content = b'{"results":[]}' + design_resp.json.return_value = {"results": []} + + with self.settings(PCM_API_KEY="key", PCM_API_SECRET="secret"): + with patch("messaging.providers.postcard.pcm.requests.post") as post: + with patch( + "messaging.providers.postcard.pcm.requests.request" + ) as request: + post.return_value = login_resp + request.return_value = design_resp + designs = pcm_mod.list_designs() + + self.assertEqual(designs, []) + post.assert_called_once() + self.assertIn("/auth/login", post.call_args.args[0]) + self.assertEqual( + post.call_args.kwargs["json"], + {"apiKey": "key", "apiSecret": "secret"}, + ) + auth = request.call_args.kwargs["headers"]["Authorization"] + self.assertEqual(auth, "Bearer session-tok") + + def test_missing_secret_raises(self): + from messaging.providers.postcard import pcm as pcm_mod + + with self.settings(PCM_API_KEY="key", PCM_API_SECRET=""): + with self.assertRaises(pcm_mod.PcmApiError) as ctx: + pcm_mod.login(force=True) + self.assertIn("PCM_API_SECRET", str(ctx.exception)) diff --git a/site/messaging/views.py b/site/messaging/views.py index a7847f8..bdc8b70 100644 --- a/site/messaging/views.py +++ b/site/messaging/views.py @@ -1,3 +1,6 @@ +import hashlib +import hmac + from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required @@ -74,19 +77,60 @@ def _campaign_report(campaign: Campaign) -> dict: } -def _webhook_authorized(request, *, secret: str) -> bool: - secret = (secret or "").strip() - if not secret: +def _webhook_authorized(request, *, secret: str = "", secrets: list[str] | None = None) -> bool: + """Accept Bearer / ?token= matching any configured secret (constant-time).""" + candidates: list[str] = [] + if secrets: + candidates.extend(s.strip() for s in secrets if (s or "").strip()) + single = (secret or "").strip() + if single and single not in candidates: + candidates.append(single) + if not candidates: return True + token = (request.GET.get("token") or "").strip() auth = (request.headers.get("Authorization") or "").strip() - if token and token == secret: - return True - if auth.lower().startswith("bearer ") and auth[7:].strip() == secret: - return True + bearer = auth[7:].strip() if auth.lower().startswith("bearer ") else "" + # Common signature-header names PCM / gateways may use (raw secret or HMAC). + sig_headers = ( + request.headers.get("X-PCM-Signature") + or request.headers.get("X-Webhook-Signature") + or request.headers.get("X-Signature") + or request.headers.get("X-Hub-Signature-256") + or "" + ).strip() + if sig_headers.lower().startswith("sha256="): + sig_headers = sig_headers[7:].strip() + + body = request.body or b"" + for candidate in candidates: + if token and hmac.compare_digest(token, candidate): + return True + if bearer and hmac.compare_digest(bearer, candidate): + return True + if sig_headers: + if hmac.compare_digest(sig_headers, candidate): + return True + digest = hmac.new( + candidate.encode("utf-8"), body, hashlib.sha256 + ).hexdigest() + if hmac.compare_digest(sig_headers, digest): + return True return False +def _pcm_webhook_secrets() -> list[str]: + """All PCM subscription signature secrets from env.""" + raw_list = (getattr(settings, "PCM_WEBHOOK_SECRETS", None) or "").strip() + single = (getattr(settings, "PCM_WEBHOOK_SECRET", None) or "").strip() + out: list[str] = [] + if raw_list: + out.extend(p.strip() for p in raw_list.split(",") if p.strip()) + if single and single not in out: + out.append(single) + return out + + @login_required @require_http_methods(["GET", "POST"]) def campaign_list(request): @@ -453,14 +497,11 @@ def postcard_webhook(request): """ PCM Integrations order / mail-tracking webhook. - Configure in PCM → Webhooks: + Configure in PCM → Webhooks (one subscription per event): URL: https:///portal/messaging/webhooks/postcard/ - Authorization: Bearer + PCM_WEBHOOK_SECRET - Events: order / recipient status updates (Delivered, Undeliverable, …) + Copy each subscription's signature secret into PCM_WEBHOOK_SECRETS """ - if not _webhook_authorized( - request, secret=settings.PCM_WEBHOOK_SECRET or "" - ): + if not _webhook_authorized(request, secrets=_pcm_webhook_secrets()): return HttpResponseForbidden("invalid webhook token") payload = parse_webhook_payload(request) if not payload: diff --git a/site/monica_site/settings/base.py b/site/monica_site/settings/base.py index 8698ab5..389a6d3 100644 --- a/site/monica_site/settings/base.py +++ b/site/monica_site/settings/base.py @@ -241,8 +241,14 @@ SMTP2GO_WEBHOOK_SECRET = env("SMTP2GO_WEBHOOK_SECRET", "") # --- Postcards (PCM Integrations default) --- POSTCARD_PROVIDER = env("POSTCARD_PROVIDER", "pcm") +# Login credentials for POST /auth/login → short-lived Bearer token. PCM_API_KEY = env("PCM_API_KEY", "") -# Shared secret for inbound PCM event webhooks (Bearer / ?token=). +PCM_API_SECRET = env("PCM_API_SECRET", "") +# Optional child-app reference for PCM multi-account (childRefNbr). +PCM_CHILD_REF_NBR = env("PCM_CHILD_REF_NBR", "") +# PCM webhook auth: each subscription has its own signature secret (copy from PCM UI). +# Prefer PCM_WEBHOOK_SECRETS=sec1,sec2,… ; PCM_WEBHOOK_SECRET still accepted (single). +PCM_WEBHOOK_SECRETS = env("PCM_WEBHOOK_SECRETS", "") PCM_WEBHOOK_SECRET = env("PCM_WEBHOOK_SECRET", "") # JSON object: company, firstName, lastName, address, address2, city, state, zipCode PCM_RETURN_ADDRESS = env("PCM_RETURN_ADDRESS", "")