Open in
Webhook-aláírások ellenőrzése
A ThunderPhone által küldött minden webhook- és eszközkérés időbélyeggel van aláírva. Ellenőrizze az itt található recepttel vagy SDK-segédprogrammal, és utasítsa el az öt percnél régebbi, ismételten elküldött kéréseket.
Minden, a szerverére küldött kérésünk (webhook-kézbesítések, a régi egyetlen URL-es webhook, valamint eszközvégpont-hívások) két aláírásfejlécet tartalmaz. Mindkettő ugyanazzal a titkos kulccsal készült HMAC-SHA256.
| Fejléc | Mit ír alá | Újrajátszás elleni védelem |
|---|---|---|
X-ThunderPhone-Signature-V2 (ajánlott) | Időbélyeg, metódus, útvonal, lekérdezés és törzs | Utasítsa el az 5 percnél régebbi kéréseket |
X-ThunderPhone-Signature | Csak a törzs | Nincs: egy elfogott kérés örökké érvényes marad |
Az új integrációkban ellenőrizze az X-ThunderPhone-Signature-V2 fejlécet, a meglévőket pedig állítsa át erre. Az X-ThunderPhone-Signature fejlécet továbbra is változatlanul elküldjük minden kérésben, így az erre épülő ellenőrzők továbbra is működnek. Mielőtt megszüntetnénk a küldését, bejelentjük a dátumot.
Aláírás V2
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e- A
taz Unix-idő másodpercben kifejezve, amikor elküldtük a kérést. Az újrapróbált kézbesítést elküldésekor ismét aláírjuk, sajáttértékkel. - A
v1az alábbi aláírt karakterlánc hexadecimális HMAC-SHA256 értéke, amely az Ön titkával van kulcsolva.
Az érték key=value párok vesszővel elválasztott listája. Hagyja figyelmen
kívül az ismeretlen kulcsokat, és fogadja el a kérést, ha bármelyik v1
egyezik: egy jövőbeli módosítás során hozzáadhatunk egy második aláírást.
Az aláírt karakterlánc
Négy mező, amelyek mindegyikét egy újsor (\n, 0x0A bájt) követi, az
utolsó kivételével:
<t>\n<METHOD>\n<path and query>\n<body>| Mező | Érték |
|---|---|
t | A fejléc t értéke |
METHOD | A HTTP-metódus nagybetűkkel: POST, GET, PUT, PATCH vagy DELETE |
| path and query | A HTTP-kérés sorában szereplő kérési cél: az elérési út, majd ? és a lekérdezési karakterlánc, ha van ilyen. Nem tartalmaz sémát, gazdagépet vagy töredéket. Pontosan a beérkezett formában használja, dekódolás és a paraméterek átrendezése nélkül |
body | A nyers kéréstörzs. A GET és DELETE eszközhívásoknak nincs törzsük, ezért az aláírt karakterláncuk az elérési út utáni újsorral végződik |
Ellenőrzéshez:
- Bármilyen elemzés előtt olvassa be a fejlécet, a metódust, a nyers elérési utat és lekérdezést, valamint a nyers törzset.
- Vegye ki a fejlécből a
tértéket és az összesv1értéket. - Utasítsa el a kérést, ha a
ttöbb mint 300 másodperccel eltér az Ön órájától előre vagy hátra. - Számítsa ki az aláírt karakterlánc hexadecimális HMAC-SHA256 értékét az Ön titkával.
- Hasonlítsa össze konstans időben a
v1értékkel. Ha egyik sem egyezik, válaszoljon401-gyel.
Kidolgozott példa
Titok: whsec_example_4c1f2a9b. Ezt a kérést a következő időpontban küldjük:
t = 1767225600 (2026-01-01 00:00:00 UTC):
POST /tools/lookup?region=us HTTP/1.1
Host: api.example.com
Content-Type: application/json
X-ThunderPhone-Signature-V2: t=1767225600,v1=03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e
{"customer_id":"cust_123"}Az aláírt karakterlánc ez a négy sor, a törzs után újsor nélkül:
1767225600
POST
/tools/lookup?region=us
{"customer_id":"cust_123"}A HMAC-SHA256 értéke:
03a89bec521f78d55727237ca4713bb0b8dcdf2e09e450865b5b5637b80c1c4e.
Ellenőrizze ezzel a megvalósítását, vagy állítsa elő openssl használatával:
printf '1767225600\nPOST\n/tools/lookup?region=us\n{"customer_id":"cust_123"}' \
| openssl dgst -sha256 -hmac 'whsec_example_4c1f2a9b'Egy ugyanebben a másodpercben indított GET eszközhívás a
/tools/lookup?customer_id=cust_123 címre üres törzzsel rendelkezik. Az
aláírt karakterlánca
1767225600\nGET\n/tools/lookup?customer_id=cust_123\n, az aláírása pedig
3ce214cb0b8f2bf39b1d19d4e7265df9dbaff5a5f838d3cc6c753a8f7a4ad280.
Az SDK-kkal
A Python- és TypeScript SDK-k tartalmaznak egy segédfüggvényt
(thunderphone 0.2.0 vagy újabb, @thunderphone/sdk 0.2.0 vagy újabb). Ez
ellenőrzi az 5 perces időablakot, konstans időben hasonlít össze, és titkok
listáját is elfogadja, így leállás nélkül forgathatja őket.
from thunderphone import verify_signature
ok = verify_signature(
header, # the X-ThunderPhone-Signature-V2 value
secret=SECRET, # or [NEW_SECRET, OLD_SECRET] while rotating
method=method, # "POST"
path=path, # "/tools/lookup?region=us", exactly as received
body=raw_body, # bytes, before any JSON parsing
tolerance=300, # optional; seconds
)import { verifySignature } from "@thunderphone/sdk";
const ok = verifySignature({
header, // the X-ThunderPhone-Signature-V2 value
secret: SECRET, // or [NEW_SECRET, OLD_SECRET] while rotating
method, // "POST"
path, // "/tools/lookup?region=us", exactly as received
body: rawBody, // Buffer or string, before any JSON parsing
toleranceSeconds: 300, // optional
});SDK nélkül
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_v2(header: str, secret: str, method: str, path: str, body: bytes) -> bool:
"""path is the path plus ?query exactly as received; body is the raw body."""
timestamp, signatures = None, []
for item in (header or "").split(","):
key, _, value = item.strip().partition("=")
if key == "t" and value.isdigit() and len(value) <= 15:
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}\n{method.upper()}\n{path}\n".encode() + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
// path is the path plus ?query exactly as received; body is the raw Buffer.
export function verifyV2(header, secret, method, path, body) {
let timestamp;
const signatures = [];
for (const item of (header || "").split(",")) {
const [key, value = ""] = item.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
if (timestamp === undefined) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
crypto
.createHmac("sha256", secret)
.update(`${timestamp}\n${method.toUpperCase()}\n${path}\n`)
.update(body)
.digest("hex"),
);
return signatures.some((sig) => {
const candidate = Buffer.from(sig);
return (
candidate.length === expected.length &&
crypto.timingSafeEqual(candidate, expected)
);
});
}Keretrendszer-integráció
Mindegyik példa beolvassa a nyers útvonalat, lekérdezési karakterláncot és törzset, majd meghívja az SDK segédfüggvényét.
import json
from fastapi import FastAPI, HTTPException, Request
from thunderphone import verify_signature
app = FastAPI()
def raw_path_and_query(request: Request) -> str:
path = (request.scope.get("raw_path") or request.url.path.encode()).decode("latin-1")
query = request.scope.get("query_string", b"").decode("latin-1")
return f"{path}?{query}" if query else path
@app.post("/thunderphone-webhook")
async def hook(request: Request):
body = await request.body() # raw bytes, NOT request.json()
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
event = json.loads(body)
# … dispatch on event["type"] …
return {"ok": True}import express from "express";
import { verifySignature } from "@thunderphone/sdk";
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 ok = verifySignature({
header: req.header("X-ThunderPhone-Signature-V2"),
secret: process.env.WEBHOOK_SECRET,
method: req.method,
path: req.originalUrl, // path and query as received
body: req.body,
});
if (!ok) 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 HttpResponseForbidden, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from thunderphone import verify_signature
@csrf_exempt
@require_POST
def hook(request):
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=SECRET,
method=request.method,
path=request.get_full_path(),
body=request.body, # raw bytes
):
return HttpResponseForbidden("invalid signature")
event = json.loads(request.body)
# … dispatch on event["type"] …
return JsonResponse({"ok": True})Az req.originalUrl (Express), valamint az ASGI raw_path és query_string
(FastAPI) pontosan a beérkezett célértéket adják vissza. A Django
get_full_path() és a Flask request.path először dekódolja az útvonalat, ami
szokásos útvonalak esetén ugyanazt a karakterláncot eredményezi. Ha az Ön végpontjának útvonala
százalékkódolt vagy fenntartott karaktereket tartalmaz, például %2F, ; vagy =, inkább
a szerver által biztosított nyers célértéket olvassa be (például a Gunicorn
esetén a request.META["RAW_URI"] értékét).
Melyik titkos kulcs?
Ugyanaz a titkos kulcs írja alá mindkét fejlécet.
| Forrás | Titkos kulcs |
|---|---|
Webhook-végpont (/v1/developer/webhook-endpoints) | A végpontonkénti secret (48 hexadecimális karakter), amelyet a rendszer egyszer, a létrehozáskor ad vissza |
| Régi, egy URL-es webhook | A szervezetenkénti secret, amelyet a GET /v1/webhook ad vissza |
Eszközvégpont-meghívás (közvetlen hívás az Ön endpoint.url címére) | A szervezeti szintű webhook-titkos kulcs (ugyanaz, mint a régi, egy URL-es webhook esetén) — nem végpontonkénti titkos kulcs |
Tárolja a titkos kulcsot a titkoskulcs-kezelőjében vagy környezeti változóban — soha ne commitolja.
Eszközhívások ellenőrzése
Amikor az ügynök közvetlenül meghívja az Ön egyik
függvényeszközét (az eszköz rendelkezik
endpoint értékkel), a kérés az Ön által konfigurált endpoint.headers mellett
a következő ThunderPhone-fejléceket tartalmazza:
X-ThunderPhone-Call-ID— az aktív hívás numerikus azonosítója.X-ThunderPhone-Signature-V2— a fenti időbélyeggel ellátott aláírás, amely az Ön szervezeti szintű webhooktitkával van kulcsolva.X-ThunderPhone-Signature— a korábbi, csak a törzsre vonatkozó aláírás, ugyanazzal a kulccsal.
Két dolog eltér a webhookoktól:
- A
GET/DELETEeszközöknek nincs törzsük. Az argumentumok lekérdezési paraméterekként érkeznek. A V2-aláírás ezeket az útvonalon és a lekérdezésen keresztül fedi le, a törzsmező pedig üres. Ellenőrizze a nyers lekérdezési karakterlánccal: a paraméterek elemzése és új lekérdezési karakterlánc létrehozása megváltoztathatja azok sorrendjét vagy kódolását. A régi fejléc az üres bájtsorozatot írja alá. - A korábbi webhookot nem konfiguráló szervezeteknek nincs szervezeti titkuk. Ebben
az esetben az eszközhívások csak
X-ThunderPhone-Call-IDfejlécet hordoznak, aláírásfejléceket nem. Állítsa be a korábbi webhookot (PUT /v1/webhook) aláírási titok beszerzéséhez, vagy hitelesítse az eszközhívásokat saját fejlécével azendpoint.headerssegítségével.
@app.api_route("/tools/search-appointments", methods=["GET", "POST"])
async def tool(request: Request):
body = await request.body() # b"" for GET/DELETE tools
if not verify_signature(
request.headers.get("X-ThunderPhone-Signature-V2"),
secret=ORG_WEBHOOK_SECRET,
method=request.method,
path=raw_path_and_query(request),
body=body,
):
raise HTTPException(status_code=401)
call_id = request.headers.get("X-ThunderPhone-Call-ID", "")
args = json.loads(body) if body else dict(request.query_params)
...Expressben egy törzsparser a GET kérésnél üres objektumként hagyja a req.body értékét,
ezért adja át a következőt: Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0).
A webhook-módú eszközdiszpécselés (az endpoint nélküli eszközök, amelyek
telephony.tool / web.tool formájában érkeznek az Ön szervezeti webhookjára) hagyományos
aláírt webhook — a fenti standard eljárás alkalmazandó. Mindkét kérésformátumról lásd:
Függvényeszközök.
Régi aláírás: X-ThunderPhone-Signature
Az eredeti fejléc a nyers törzs önmagában vett hexadecimális HMAC-SHA256-értéke, ugyanazzal a titokkal. Nem tartalmaz időbélyeget, ezért bárki, aki elfog egy aláírt kérést, újra elküldheti azt. A rendszer továbbra is minden kérésnél elküldi, amíg az integrációk át nem állnak a V2-re.
- Olvassa be a nyers kéréstörzset — az általunk küldött pontos bájtokat.
- Számítsa ki a következőt:
hmac_sha256(secret, body).hexdigest(). - Hasonlítsa össze konstans időben az
X-ThunderPhone-Signatureértékével.
Pontosan azokat a bájtokat írjuk alá, amelyeket továbbítunk. Ezek a bájtok egyben a
hasznos adat kanonikus JSON-szerializálása is — a kulcsok ábécésorrendben,
tömör elválasztókkal (, és : szóközök nélkül), UTF-8 kódolásban.
Ha a keretrendszere csak feldolgozott JSON-t biztosít, szerializálja újra kanonikusan,
és erre számítsa a HMAC-ot:
# Equivalent to hashing the raw body:
import json
canonical = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")A nyers törzset részesítse előnyben — ez eggyel kevesebb lépés, és védett a JSON-számok oda-vissza alakításából eredő sajátosságokkal szemben egyes nyelvekben. Ugyanez vonatkozik a V2 törzsmezőjére is.
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)
endGyakori buktatók
Óraeltolódás
Az 5 perces időablakot a szerver órájához viszonyítjuk. Tartsa azt NTP-vel szinkronban; az 5 percnél nagyobb eltérést mutató óra minden kérést elutasít.
Egy proxy átírja az útvonalat
Az aláírt útvonal az általunk kért útvonal: a ThunderPhone-ban beállított URL útvonala. Ha egy fordított proxy vagy API-átjáró eltávolít vagy hozzáad egy előtagot, mielőtt az alkalmazása megkapná a kérést, az ellenőrzés előtt állítsa helyre az általunk küldött útvonalat (például tegye vissza az előtagot).
Újraszerializálás alapértelmezett formázással
A törzs elemzése és újbóli kiírása a JSON-könyvtár
alapértelmezéseivel (szóközök a , / : után, beillesztési sorrendű kulcsok)
eltérő bájtokat eredményez, és érvényteleníti a HMAC-et. Ellenőrizze a nyers
törzset — vagy ha újra kell szerializálnia, pontosan egyezzen meg a kanonikus
formánkkal: rendezett kulcsok, tömör elválasztók, UTF-8.
A keretrendszer automatikusan elemzi a JSON-t
Az Express express.json() middleware-e felhasználja a törzsfolyamot,
így elvesznek a nyers bájtok. Kifejezetten a webhook
útvonalon használja az express.raw()-t, vagy pufferelje a nyers törzset egy
előzetes middleware-ben. Ugyanez vonatkozik a NestJS-re / Koára is — tekintse meg a „raw body” dokumentációjukat.
Időzítés szempontjából nem biztonságos összehasonlítás
A JS-ben használt expected === signature, illetve a
Pythonban használt expected == signature időzítésfüggő összehasonlítások. Használja rendre a crypto.timingSafeEqual
vagy a hmac.compare_digest függvényt. A teljesítménykülönbség
elhanyagolható.
Helytelen titok az eszközvégpontokhoz
A közvetlen eszközvégpont-hívásokat a szervezeti szintű webhook-titokkal
(GET /v1/webhook) írjuk alá — nem a
/v1/developer/webhook-endpoints bármely végpontonkénti titkával. Használja újra
ugyanazt a segédfüggvényt, de ügyeljen arra, hogy az eszközútvonalakon a szervezeti
titkot adja át neki.
Lekérdezési karakterláncok a GET/DELETE eszközökön
A V2 pontosan az elküldött formában írja alá a lekérdezési karakterláncot. A régi fejléc az üres törzset írja alá, a lekérdezési karakterláncot soha, ezért az URL kivonatolása a régi fejléchez soha nem fog egyezni.
Duplikátumok az időablakon belül
Az időbélyeg az 5 percnél régebbi ismételt lejátszásokat akadályozza meg, nem pedig az
ezen időablakon belüli második példányt. A végpontokra történő kézbesítés legalább egyszeri,
ezért ugyanazon esemény újrapróbálkozása is megérkezhet; szüntesse meg a duplikációt
a burkoló event_id mezője alapján.
Nem 401-es válasz eltérés esetén
Ha sikertelen ellenőrzéskor 200-as választ ad vissza, a kezelő ismétlési célponttá válik. Ha az ellenőrzés sikertelen, mindig 2xx-től eltérő választ adjon.