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.
This commit is contained in:
@@ -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=<PCM_WEBHOOK_SECRET>` 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=<Message.uuid>` 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={…}
|
||||
```
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+81
-1
@@ -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))
|
||||
|
||||
+54
-13
@@ -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://<host>/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:
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user