Webhook ба signature

Үр дүнг таны серверт хүргэх мэдэгдэл, түүний HMAC signature-ийг шалгах, дахин илгээлтийг зөв боловсруулах арга.

Webhook бол үйл явдлын мэдэгдлийг таны серверт автоматаар илгээх HTTPS POST хүсэлт. 2fa.mn баталгаажуулалт болон rule-ийн үр дүнг ийм хүсэлтээр таны сервер рүү илгээнэ. Мессеж ирснээс хойш 1 секундээс богино хугацаанд хүргэхийг зорьдог.

Хаяг бүртгэх

Горим (тест, бодит) бүрд нэг webhook хаяг байна. Аль горимд бүртгэх нь ашигласан API түлхүүрээс хамаарна.

curl -X PUT https://api.2fa.mn/v1/webhook \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.mn/webhooks/2fa" }'
{ "url": "https://example.mn/webhooks/2fa", "secret": "whsec_..." }

secret-ийг орчны хувьсагчид хадгална уу. secret алдагдсан гэж сэжиглэвэл POST /v1/webhook/rotate-secret дуудаж шинийг авна.

Үйл явдлын бүтэц

{
  "id": "evt_2k9d7x",
  "type": "verification.succeeded",
  "mode": "live",
  "created_at": "2026-12-01T10:00:41Z",
  "data": { }
}
typedata
verification.succeededsession_id, phone, operator, verified_at, metadata
rule.matchedrule_id, prefix, phone, text, operator, received_at
session.expiredsession_id

Signature

Хүсэлт бүр X-2FA-Signature толгойтой ирнэ:

X-2FA-Signature: t=1764583241,v1=5f2b9c...e81a
  • t нь Unix секундээр заасан цаг.
  • v1 нь "<t>.<raw body>" мөрийн HMAC-SHA256 утга (hex хэлбэртэй). Түлхүүр нь таны whsec_... secret.

Шалгах дараалал:

Толгойг задлах

Толгойг задалж t, v1-ийг авна.

Цагийг шалгах

|одоо − t| > 300 секунд бол татгалзана. Энэ нь хуучин хүсэлтийг дахин илгээх (replay) халдлагаас хамгаална.

HMAC тооцох

t, цэг, боловсруулаагүй түүхий body-г залгаж HMAC-SHA256 тооцно.

Харьцуулах

Тооцсон утгыг v1-тэй тогтмол хугацааны (constant-time) харьцуулалтаар тулгана.

Түүхий body ашиглана

JSON-ийг задлаад дахин сериалчилбал зай, дараалал өөрчлөгдөж signature таарахгүй. Framework-ийнхөө JSON middleware-ийг энэ замд унтрааж, түүхий байтыг ашиглана уу.

verify.js
import crypto from "node:crypto";

// rawBody: Buffer эсвэл string, header: X-2FA-Signature
export function verifySignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    (header ?? "").split(",").map((kv) => kv.trim().split("="))
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.`)
    .update(rawBody)
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(parts.v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express жишээ
// app.post("/webhooks/2fa", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifySignature(req.body, req.get("X-2FA-Signature"), process.env.TWOFA_WEBHOOK_SECRET)) {
//     return res.sendStatus(400);
//   }
//   const event = JSON.parse(req.body);
//   res.sendStatus(200);
// });
verify.go
package webhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"math"
	"net/http"
	"strconv"
	"strings"
	"time"
)

func VerifySignature(rawBody []byte, header, secret string, tolerance time.Duration) bool {
	var ts, sig string
	for _, part := range strings.Split(header, ",") {
		k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
		if !ok {
			continue
		}
		switch k {
		case "t":
			ts = v
		case "v1":
			sig = v
		}
	}
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil || sig == "" {
		return false
	}
	if math.Abs(float64(time.Now().Unix()-t)) > tolerance.Seconds() {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts + "."))
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))

	// hmac.Equal нь тогтмол хугацаанд харьцуулна
	return hmac.Equal([]byte(expected), []byte(sig))
}

func Handler(secret string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		body, err := io.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "bad body", http.StatusBadRequest)
			return
		}
		if !VerifySignature(body, r.Header.Get("X-2FA-Signature"), secret, 5*time.Minute) {
			http.Error(w, "invalid signature", http.StatusBadRequest)
			return
		}
		// json.Unmarshal(body, &event) ... event.ID-аар давхардлыг шалгана
		w.WriteHeader(http.StatusOK)
	}
}
webhook.php
<?php

function verify_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        $pair = explode('=', trim($kv), 2);
        if (count($pair) === 2) {
            $parts[$pair[0]] = $pair[1];
        }
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
        return false;
    }
    $t = (int) $parts['t'];
    if (abs(time() - $t) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);

    // hash_equals нь тогтмол хугацаанд харьцуулна
    return hash_equals($expected, $parts['v1']);
}

$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_2FA_SIGNATURE'] ?? '';

if (!verify_signature($rawBody, $header, getenv('TWOFA_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

$event = json_decode($rawBody, true);
// $event['id']-аар давхардлыг шалгана
http_response_code(200);
webhook.py
import hashlib
import hmac
import os
import time

from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["TWOFA_WEBHOOK_SECRET"]


def verify_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(
        kv.strip().split("=", 1) for kv in (header or "").split(",") if "=" in kv
    )
    ts, sig = parts.get("t"), parts.get("v1")
    if not ts or not sig or not ts.isdigit():
        return False
    if abs(int(time.time()) - int(ts)) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()

    # compare_digest нь тогтмол хугацаанд харьцуулна
    return hmac.compare_digest(expected, sig)


@app.post("/webhooks/2fa")
def webhook():
    raw = request.get_data()
    if not verify_signature(raw, request.headers.get("X-2FA-Signature", ""), SECRET):
        abort(400)
    event = request.get_json()
    # event["id"]-аар давхардлыг шалгана
    return "", 200

Хариу ба давтан илгээлт

  • Хүсэлтэд 10 секундэд багтааж 2xx статусаар хариулна уу. Удаан ажлыг дараалалд оруулж, шууд хариулах нь зөв.
  • 2xx-ээс өөр хариу ирвэл эсвэл хугацаа хэтэрвэл 2fa.mn 5 хүртэл удаа дахин оролдоно. Оролдлого хоорондын завсар аажмаар уртасна: ойролцоогоор 10 секунд, 1 минут, 5 минут, 30 минут, 2 цаг.
  • Энэ нь бодит webhook-д хамаарна: тест webhook нэг л удаа илгээгдэх бөгөөд дахин оролдохгүй.
  • Бүх оролдлого бүтэлгүйтвэл хүргэлт failed төлөвтэй болно. Үүнийг Хянах самбараас харж болно.

Хүргэлтийн төлвийг Хянах самбар болон GET /v1/webhook/deliveries-ээс харна:

{
  "data": [
    {
      "event_id": "evt_2k9d7x",
      "type": "verification.succeeded",
      "status": "delivered",
      "attempts": 1,
      "last_status_code": 200,
      "created_at": "2026-12-01T10:00:41Z",
      "delivered_at": "2026-12-01T10:00:41Z"
    }
  ]
}

status нь pending, delivered, failed гурвын нэг.

Давхардлаас сэргийлэх

Дахин илгээлт бүрд ижил id ашиглагдана. Сүлжээний саатлаас болж нэг үйл явдал хоёр удаа ирж болох тул id-г хадгалж, өмнө боловсруулсан бол алгасна.

-- PostgreSQL жишээ
INSERT INTO processed_events (id) VALUES ($1)
ON CONFLICT (id) DO NOTHING;
-- 0 мөр нэмэгдсэн бол үйл явдлыг аль хэдийн боловсруулсан гэсэн үг

Аюулгүй байдлын зөвлөмж

  • Signature-гүй эсвэл буруу signature-тай хүсэлтийг хэзээ ч боловсруулахгүй.
  • Webhook хаягаа зөвхөн HTTPS-ээр нээнэ.
  • IP хаягаар нэмэлт хязгаар тавихын тулд hello@2fa.mn хаягаар холбогдоно уу. Гэхдээ signature шалгах нь хамгийн найдвартай арга хэвээр.
  • API түлхүүрийг серверийн IP хаягаар хязгаарлаж болно: API түлхүүр.
  • secret-ийг кодонд бичихгүй, орчны хувьсагч эсвэл нууцын сан ашиглана.

Дараагийн алхам

Энэ хуудас танд тус болсон уу?