Агуулгыг алгасах

Webhook-ийн гарын үсэг шалгах

Таны endpoint-д ирсэн хүсэлт үнэхээр Gerege-ээс ирсэн, body нь замдаа өөрчлөгдөөгүй, хуучин хүсэлтийг дахин тоглуулаагүй гэдгийг нотолно. SDK-тай бол нэг мөр; SDK-гүй бол доорх 25 мөрийн функцийн аль нэгийг хуулна.

  • Хугацаа: 15 минут (тест вектороор шалгасан нь).
  • Хэрэгтэй: webhook secret (whsec_…, Тойм), endpoint-ийн түүхий (raw) body-г авах боломж.

Гарын үсэггүй, шалгаагүй webhook-оор хэзээ ч бараа олгохгүй

Хэн ч таны URL руу JSON илгээж чадна. Гарын үсэг таарахгүй, эсвэл толгой алга бол 400 буцаагаад юу ч бүү хий.

1. Толгойнууд

Толгой Утга
Gerege-Signature t=<unix секунд>,v1=<hex>[,v1=<hex>]
OpenPay-Signature Gerege-Signature-тэй яг ижил утга (OMAS стандартын нэр)
Gerege-Event-Id · OpenPay-Event-Id Event-ийн id (evt_…), body-гийн id-тэй ижил
Content-Type application/json
User-Agent GeregeApp-Webhooks/1

Gerege-Signature-ийг уншиж, байхгүй бол OpenPay-Signature-ийг ав. Жишээ:

Gerege-Signature: t=1792659600,v1=01ecc504ae234412067d656d62f44e6358f4d0871b7dc6c63a66885fc25b5f50,v1=e4a4a3fe050cfd0b6a23062c60cd28882e497aebe6639600345e55adbaf0cc57
  • t — энэ оролдлогыг илгээсэн агшин. Retry бүр шинэ t-тэй, шинээр гарын үсэг зурагдана; body нь хэзээ ч өөрчлөгдөхгүй.
  • v1 — тухайн endpoint-ийн хүчинтэй secret тус бүрд нэг. Ердийн үед нэг; secret солисноос хойш 24 цагт хоёр (эхэнд шинэ, дараа нь хуучин). Endpoint бүр өөрийн secret-тэй — нэг event хоёр endpoint руу очвол гарын үсэг нь өөр (Тойм).
  • Мэдэхгүй түлхүүрийг (v0, v2, …) үл тоомсорло; таслалын хойтох зайг тэвчинэ.

2. Алгоритм

signed_payload = t + "." + raw_body
expected       = lowercase_hex( HMAC-SHA256( key = webhook_secret, message = signed_payload ) )
  1. Толгойноос t ба бүх v1-ийг ав. t алга эсвэл тоо биш, эсвэл v1 нэг ч алга бол татгалз.
  2. |now − t| ≤ 300 секунд (хоёр чиглэлд, хил оролцоно) биш бол татгалз — replay хамгаалалт. Серверийн цагаа NTP-ээр тааруул.
  3. Таны хүлээн авах secret тус бүрээр expected-ийг бод. Түлхүүр нь whsec_ угтвартай бүтэн мөр, UTF-8 byte (base64 decode хийхгүй).
  4. Аль нэг v1 аль нэг expected-тэй constant-time харьцуулалтаар тэнцвэл хүлээн ав. ==, strcmp бүү хэрэглэ.
  5. Дараа нь л body-г JSON болгож parse хий, id-аар давхардлыг арилга (Idempotent handler).

Нарийн зүйлс:

  • Түүхий body. Framework-ийн parse хийсэн объектыг дахин JSON.stringify / json.dumps хийвэл гарын үсэг таарахгүй: Gerege-ийн илгээгч <, >, &-ийг \u003c, \u003e, \u0026 гэж бичдэг, түлхүүрийн дараалал, зай таных биш. Body-г byte-аар нь ав.
  • Жижиг үсэгтэй hex. Платформ үргэлж жижиг үсгээр бичнэ; лавлах шалгагч том үсэгтэй v1-ийг хүлээн авахгүй — та ч бас.
  • Хүлээн авах secret-ийн жагсаалт: ердийн үед [одоогийн], солих үед [шинэ, хуучин].

3. Код

SDK-тай:

import express from "express";
import { verifyWebhook, signatureFromHeaders, GeregeWebhookError } from "@gerege/miniapp-node";

const secrets = process.env.GEREGE_WEBHOOK_SECRETS!.split(","); // солих үед "шинэ,хуучин"

app.post("/webhooks/gerege", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verifyWebhook(req.body, signatureFromHeaders(req.headers), secrets);
    queue.add(event); // id-аар dedupe, дараа нь ажил
    res.sendStatus(204);
  } catch (err) {
    if (err instanceof GeregeWebhookError) return res.sendStatus(400); // err.reason
    throw err;
  }
});
import miniapp "github.com/gerege-systems/app-gerege-nexus/sdk/go"

secrets := strings.Split(os.Getenv("GEREGE_WEBHOOK_SECRETS"), ",")

http.HandleFunc("POST /webhooks/gerege", func(w http.ResponseWriter, r *http.Request) {
    ev, _, err := miniapp.VerifyRequest(r, secrets, time.Now())
    if err != nil {
        http.Error(w, "invalid webhook", http.StatusBadRequest)
        return
    }
    enqueue(ev) // ev.ID-аар dedupe
    w.WriteHeader(http.StatusNoContent)
})

SDK-гүй — доорх функц бүр sdk/testdata/vectors.json-ийн webhook.verification-ийн 20 тохиолдлыг бүгдийг зөв шийддэг (Node, Go, Python-ийг ажиллуулж шалгасан; PHP-ийг ижил логикоор):

import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: Buffer (express.raw), header: Gerege-Signature, secrets: string[]
export function verifyGerege(rawBody, header, secrets, now = Math.floor(Date.now() / 1000), tolerance = 300) {
  let t = -1;
  const sigs = [];
  for (const part of String(header ?? "").split(",")) {
    const p = part.trim();
    const i = p.indexOf("=");
    const key = i < 0 ? p : p.slice(0, i);
    const value = i < 0 ? "" : p.slice(i + 1);
    if (key === "t" && /^[0-9]+$/.test(value)) t = Number(value);
    else if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) sigs.push(Buffer.from(value, "hex"));
  }
  if (t < 0 || sigs.length === 0) return false;
  if (Math.abs(now - t) > tolerance) return false;
  let ok = false;
  for (const secret of secrets) {
    const want = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
    for (const sig of sigs) ok = timingSafeEqual(sig, want) || ok; // эрт гарахгүй
  }
  return ok;
}
func Verify(body []byte, header string, secrets []string, now time.Time) bool {
    t := int64(-1)
    var sigs []string
    for _, part := range strings.Split(header, ",") {
        k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
        switch k {
        case "t":
            if n, err := strconv.ParseInt(v, 10, 64); err == nil && n >= 0 {
                t = n
            }
        case "v1":
            sigs = append(sigs, v)
        }
    }
    if t < 0 || len(sigs) == 0 {
        return false
    }
    if d := now.Sub(time.Unix(t, 0)); d > 5*time.Minute || d < -5*time.Minute {
        return false
    }
    ok := false
    for _, s := range secrets {
        m := hmac.New(sha256.New, []byte(s))
        fmt.Fprintf(m, "%d.", t)
        m.Write(body)
        want := []byte(hex.EncodeToString(m.Sum(nil)))
        for _, sig := range sigs {
            ok = hmac.Equal([]byte(sig), want) || ok
        }
    }
    return ok
}
import hashlib, hmac, re, time

def verify(raw_body: bytes, header: str, secrets: list[str], now: int | None = None, tolerance: int = 300) -> bool:
    t, sigs = None, []
    for part in (header or "").split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and re.fullmatch(r"[0-9]+", value):
            t = int(value)
        elif key == "v1":
            sigs.append(value.encode())
    if t is None or not sigs:
        return False
    now = int(time.time()) if now is None else now
    if abs(now - t) > tolerance:
        return False
    ok = False
    for secret in secrets:
        want = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest().encode()
        for sig in sigs:
            ok = hmac.compare_digest(sig, want) or ok
    return ok

# Flask: verify(request.get_data(), request.headers.get("Gerege-Signature", ""), secrets)
# Django: verify(request.body, request.headers.get("Gerege-Signature", ""), secrets)
<?php
function gerege_verify(string $rawBody, string $header, array $secrets, ?int $now = null, int $tolerance = 300): bool
{
    $t = null;
    $sigs = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($key === 't' && ctype_digit($value)) {
            $t = (int) $value;
        } elseif ($key === 'v1') {
            $sigs[] = $value;
        }
    }
    if ($t === null || !$sigs) {
        return false;
    }
    $now ??= time();
    if (abs($now - $t) > $tolerance) {
        return false;
    }
    $ok = false;
    foreach ($secrets as $secret) {
        $want = hash_hmac('sha256', $t . '.' . $rawBody, $secret); // жижиг үсэгтэй hex
        foreach ($sigs as $sig) {
            $ok = hash_equals($want, $sig) || $ok;
        }
    }
    return $ok;
}

$raw = file_get_contents('php://input'); // $_POST биш — түүхий body
$header = $_SERVER['HTTP_GEREGE_SIGNATURE'] ?? ($_SERVER['HTTP_OPENPAY_SIGNATURE'] ?? '');
if (!gerege_verify($raw, $header, explode(',', getenv('GEREGE_WEBHOOK_SECRETS')))) {
    http_response_code(400);
    exit;
}

Java, C# — Бусад хэл.

4. Ажилласан жишээ

sdk/testdata/vectors.json-ийн эхний вектор (payment_intent.succeeded, one secret). Тестийн secret — бодит биш:

Утга
secret whsec_R2VyZWdlLXZlY3Rvci1jdXJyZW50LXNlY3JldC0wMDAwMDE
t 1792659600 (2026-10-22T09:00:00Z)
signed_payload 1792659600. + body (яг файлд байгаа byte, \u003croaming \u0026 data\u003e-тэй)
v1 aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821
толгой t=1792659600,v1=aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821

Shell-ээр шалгах (body.json нь вектор дахь body мөр, төгсгөлийн шинэ мөргүй):

printf '%s' "1792659600.$(cat body.json)" \
  | openssl dgst -sha256 -hmac 'whsec_R2VyZWdlLXZlY3Rvci1jdXJyZW50LXNlY3JldC0wMDAwMDE' -hex
# … aab41441cf937428aac0a577ff2e0236b4e487b82c8a725afd2b1791cc2c9821

5. Тест векторууд

vectors.json нь платформын өөрийн гарын үсэг зурагч кодоос үүсгэгддэг (гараар засахгүй), SDK-уудтай хамт нийтлэгдэнэ. Хоёр хэсэгтэй:

  • webhook.signing[] — event бүрийн бодит body, secret(ууд), t, signed_payload, хүлээгдэх signatures ба header. Өөрийн гарын үсэг зурах функцийг (тестдээ webhook дуурайхад) шалгана.
  • webhook.verification[] — {name, secrets, header, body, now, valid}: 20 тохиолдол — цонхны хил (±300 с яг хүчинтэй, ±301 хүчингүй), өөрчлөгдсөн body, дахин encode хийсэн body, буруу secret, солих үеийн хоёр v1 (зөвхөн хуучин эсвэл зөвхөн шинэ secret-тэй хүлээн авагч), таслалын хойтох зай, мэдэхгүй түлхүүр, том үсэгтэй hex (хүчингүй), v1-гүй, t-гүй, тоон бус t, өөр t-ийн гарын үсэг, хоосон толгой.

Өөрийн хэрэгжүүлэлтээ бүгдээр нь ажиллуул:

import json
cases = json.load(open("vectors.json"))["webhook"]["verification"]
for c in cases:
    got = verify(c["body"].encode(), c["header"], c["secrets"], c["now"])
    assert got == c["valid"], c["name"]

Файл одоогоор SDK-ийн хамт түгээгддэг (SDK нийтлэгдэх хүртэл хаалттай) — хэрэгтэй бол Тусламж-аас авна; эсвэл дээрх ажилласан жишээгээр эхэл.