אימות חתימות webhook
כל בקשת webhook וכל בקשת כלי ש-ThunderPhone שולחת חתומות. אמתו את החתימה פעם אחת באמצעות המתכון כאן, ולאחר מכן השתמשו שוב באותה בדיקה בכל נקודת קצה שאתם מפעילים.
כל בקשה שאנו שולחים לשרת שלכם — מסירות webhook והפעלות של נקודות קצה לכלים — כוללת חתימת HMAC-SHA256 בכותרת X-ThunderPhone-Signature. הגדירו את האימות פעם אחת והשתמשו באותו מסייע בכל מטפל.
האלגוריתם
- קראו את גוף הבקשה הגולמי — הבתים המדויקים ששלחנו אליכם ב-POST.
- חשבו את
hmac_sha256(secret, body).hexdigest(). - השוו בזמן קבוע מול
X-ThunderPhone-Signature. (השוואת מחרוזות נאיבית חושפת מידע על תזמון.)
אנו חותמים בדיוק על הבתים שאנו משדרים, לכן אימות הגוף הגולמי
תמיד עובד. בתים אלה הם גם סידור ה-JSON הקנוני
של המטען — מפתחות ממוינים לפי האלפבית, מפרידים קומפקטיים
(, ו-: ללא רווחים), UTF-8. כך תקבלו מתכון שני, שקול לחלוטין,
למקרים שבהם המסגרת שלכם חושפת רק JSON מפוענח:
בצעו סידור מחדש באופן קנוני וחשבו עליו HMAC.
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")העדיפו את הגוף הגולמי — זהו שלב אחד פחות והוא חסין מפני בעיות בהמרה חוזרת של מספרי JSON בשפות מסוימות.
איזה סוד?
| מקור | סוד |
|---|---|
נקודת קצה של webhook (/v1/developer/webhook-endpoints) | secret לכל נקודת קצה (48 תווי הקסדצימליים), שמוחזר פעם אחת בעת היצירה |
| webhook מדור קודם עם כתובת URL יחידה | secret לכל ארגון, שמוחזר ב-GET /v1/webhook |
הפעלה של נקודת קצה לכלי (קריאה ישירה אל endpoint.url שלכם) | סוד ה-webhook ברמת הארגון (אותו סוד כמו ב-webhook המדור קודם עם כתובת URL יחידה) — לא סוד לכל נקודת קצה |
שמרו את הסוד במנהל הסודות או במשתנה הסביבה שלכם — לעולם אל תבצעו לו commit.
מימושי עזר
כל ארבעת המימושים מאמתים את גוף הבקשה הגולמי:
import hashlib
import hmac
def verify(body: bytes, signature: str, secret: str) -> bool:
"""Constant-time HMAC-SHA256 verification."""
expected = hmac.new(
secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature or "")import crypto from "node:crypto";
export function verify(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
if (!signature || expected.length !== signature.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func Verify(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}require "openssl"
def verify(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
Rack::Utils.secure_compare(expected, signature.to_s)
endחיבור ייעודי למסגרת
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
raise HTTPException(status_code=401)
import json
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
const app = express();
app.post(
"/thunderphone-webhook",
// IMPORTANT: parse as raw; do NOT use express.json() here.
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("X-ThunderPhone-Signature") || "";
if (!verify(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// … dispatch on event.type …
res.sendStatus(204);
},
);import json
from django.http import JsonResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def hook(request):
body = request.body # raw bytes
sig = request.headers.get("X-ThunderPhone-Signature", "")
if not verify(body, sig, SECRET):
return HttpResponseForbidden("invalid signature")
event = json.loads(body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})אימות קריאות לכלים
כאשר הסוכן מפעיל ישירות אחד מכלי הפונקציות שלכם
(לכלי יש endpoint), הבקשה כוללת שתי כותרות ThunderPhone לצד
הערכים שהגדרתם ב-endpoint.headers:
X-ThunderPhone-Call-ID— המזהה המספרי של השיחה הפעילה.X-ThunderPhone-Signature— HMAC-SHA256, עם מפתח שהוא סוד ה-webhook ברמת הארגון, על פני הבתים המדויקים של גוף הבקשה.
אותו מסייע verify() פועל ללא שינוי, עם שתי הסתייגויות:
- לכלי
GET/DELETEאין גוף. הארגומנטים מועברים כפרמטרים של שאילתה, והחתימה מחושבת על פני מחרוזת בתים ריקה — לכן השתמשו ב-verify(b"", sig, secret)(Python) אוverify(Buffer.alloc(0), sig, secret)(Node). אל תחשבו גיבוב של מחרוזת השאילתה. - לארגונים ללא webhook מדור קודם מוגדר אין סוד ארגוני. במקרה
כזה קריאות לכלים כוללות רק
X-ThunderPhone-Call-IDוללא כותרת חתימה. הגדירו את ה-webhook מדור קודם (PUT /v1/webhook) כדי לקבל סוד חתימה, או אמתו קריאות לכלים באמצעות כותרת משלכם דרךendpoint.headers.
@app.post("/tools/search-appointments")
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
sig = request.headers.get("X-ThunderPhone-Signature", "")
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
if not verify(body, sig, ORG_WEBHOOK_SECRET):
raise HTTPException(status_code=401)
args = json.loads(body)
...הפצת כלים במצב webhook (כלים ללא endpoint, הנשלחים ל-webhook
הארגוני שלכם כ-telephony.tool / web.tool) היא webhook רגיל עם
חתימה — המתכון הסטנדרטי שלמעלה חל. ראו כלי פונקציות
לשתי צורות הבקשה.
מלכודות נפוצות
סידור מחדש עם עיצוב ברירת מחדל
ניתוח גוף הבקשה וייצוא שלו מחדש עם הגדרות ברירת המחדל של ספריית ה-JSON שלכם
(רווחים אחרי , / :, מפתחות לפי סדר הוספה) יוצר
בתים שונים ושובר את ה-HMAC. אמתו את הגוף הגולמי — או אם
עליכם לסדר אותו מחדש, התאימו בדיוק לצורה הקנונית שלנו: מפתחות
ממוינים, מפרידים קומפקטיים, UTF-8.
המסגרת מנתחת JSON באופן אוטומטי
התווכה express.json() של Express צורכת את זרם הגוף
ואתם מאבדים את הבתים הגולמיים. השתמשו ב-express.raw() במיוחד בנתיב
ה-webhook, או אחסנו את הגוף הגולמי בחוצץ בתווכה מקדימה.
אותו הדבר נכון עבור NestJS / Koa — עיינו בתיעוד שלהם בנושא "גוף גולמי".
השוואה שאינה בטוחה מבחינת זמן
expected === signature ב-JS או expected == signature ב-
Python הן השוואות שמשך הזמן שלהן משתנה. השתמשו ב-crypto.timingSafeEqual
או ב-hmac.compare_digest בהתאמה. ההבדל בביצועים
זניח.
סוד שגוי עבור נקודות קצה של כלים
קריאות ישירות לנקודות קצה של כלים נחתמות באמצעות סוד ה-webhook
ברמת הארגון (GET /v1/webhook) — ולא באמצעות סוד לכל נקודת קצה
מתוך /v1/developer/webhook-endpoints. השתמשו מחדש באותה פונקציית verify()
, אך ודאו שאתם מעבירים לה את סוד הארגון בנתיבי כלים.
גיבוב מחרוזת השאילתה בכלי GET/DELETE
עבור שיטות כלים ללא גוף, החתימה מכסה את מחרוזת הבתים הריקה, וכך נשמר מתכון אוניברסלי אחד: חשבו HMAC על גוף הבקשה הגולמי, יהיה אשר יהיה. גיבוב כתובת ה-URL או מחרוזת השאילתה לעולם לא יתאים.
אי-החזרת 401 במקרה של אי-התאמה
החזרת 200 כאשר האימות נכשל הופכת את המטפל ליעד להפעלה חוזרת. השיבו תמיד בקוד שאינו 2xx אם האימות נכשל.