gov-pay
Webhook гарын авлага

Гарын үсэг шалгах

Webhook хүргэлт бүрийн X-GovPay-Signature гарын үсгийг HMAC-SHA256-аар хэрхэн timing-safe аргаар баталгаажуулах.

gov-pay таны бүртгүүлсэн URL руу webhook илгээх бүрдээ дараах хоёр header-ийг хавсаргана:

HeaderУтга
X-GovPay-EventEvent-ийн нэр (payment.submitted эсвэл payment.confirmed).
X-GovPay-SignatureХүсэлтийн raw body-г таны secret-ээр тооцоолсон HMAC-SHA256 (hex).

secret нь та webhook бүртгэх үед (POST /webhooks) буцаагдсан утга. Энэ нь зөвхөн тухайн нэг удаа буцаагддаг тул найдвартай хадгална.

Гарын үсэг шалгаагүй webhook-ийг битгий боловсруул. Гарын үсэг шалгаснаар тухайн payload үнэхээр gov-pay-ээс ирсэн, замдаа өөрчлөгдөөгүй гэдгийг баталгаажуулна.

Гарын үсэг хэрхэн тооцоолох вэ

gov-pay тал дараах байдлаар гарын үсэг үүсгэдэг:

signature = HMAC-SHA256(rawBody, secret) → hex

rawBody нь HTTP биеийн түүхий байт (raw bytes) юм. JSON-г parse хийж, дахин JSON.stringify хийсэн утга биш гэдгийг анхаар — талбарын дараалал, зай зэрэг ялгаатай бол гарын үсэг таарахгүй. Тиймээс body-г parse хийхээс өмнө raw утгыг нь хадгалах ёстой.

Node.js дээр шалгах

crypto.createHmac ашиглан өөрийн талд гарын үсгийг дахин тооцоолж, ирсэн гарын үсэгтэй харьцуулна. Харьцуулалтыг timing-safe аргаар (crypto.timingSafeEqual) хийснээр timing attack-аас сэргийлнэ — энгийн === харьцуулалт ашиглаж болохгүй.

import crypto from 'node:crypto';

function verifySignature(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');

  // Урт ялгаатай бол timingSafeEqual алдаа өгөх тул эхэлж шалгана
  if (a.length !== b.length) {
    return false;
  }
  return crypto.timingSafeEqual(a, b);
}

timingSafeEqual нь хоёр buffer-ийн урт ижил байхыг шаарддаг тул урьдчилан уртыг нь шалгах хэрэгтэй. Урт зөрсөн тохиолдолд шууд false буцаа.

Бүрэн Express жишээ

Гол нь webhook route-д raw body хүрэх явдал. Express-ийн express.json() нь body-г parse хийгээд raw утгыг алддаг тул webhook route-д тусдаа express.raw() middleware ашиглана.

import express from 'express';
import crypto from 'node:crypto';

const app = express();

const WEBHOOK_SECRET = process.env.GOVPAY_WEBHOOK_SECRET;

function verifySignature(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(signature ?? '', 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// Webhook route — raw body хэрэгтэй тул express.json() БИШ
app.post(
  '/govpay/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('X-GovPay-Signature');
    const event = req.header('X-GovPay-Event');

    // req.body энд Buffer (raw bytes)
    if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
      return res.status(401).send('invalid signature');
    }

    const payload = JSON.parse(req.body.toString('utf8'));

    // Гарын үсэг зөв тул хурдан 2xx буцаа, дараа нь боловсруул
    res.status(200).send('ok');

    // payment id-аар давхардлыг шүүж idempotent байх
    switch (event) {
      case 'payment.submitted':
        // payload = { id, invoiceId }
        handleSubmitted(payload);
        break;
      case 'payment.confirmed':
        // payload = { id, amount }
        handleConfirmed(payload);
        break;
    }
  },
);

function handleSubmitted(payload) {
  /* ... */
}

function handleConfirmed(payload) {
  /* ... */
}

app.listen(3000);

gov-pay хүргэлтийн timeout нь 5 секунд. Тиймээс гарын үсэг шалгаад тэр даруй 200-г буцааж, бодит боловсруулалтыг (БД бичих, имэйл илгээх г.м) хариу буцаасны дараа эсвэл дараалалд хийнэ. Удаан боловсруулалтыг хариу буцаахаас өмнө хийвэл timeout болж дахин илгээгдэх эрсдэлтэй.

Хүргэлтийн дараалал

Шалгах хяналтын жагсаалт

  • Body-г parse хийхээс өмнө raw bytes-ийг авч ашигла.
  • Гарын үсгийг өөрийн secret-ээр дахин тооцоол.
  • Харьцуулалтыг crypto.timingSafeEqual-ээр хий, === БИШ.
  • Гарын үсэг буруу бол 401 буцаа, payload-г боловсруулж болохгүй.
  • Зөв бол хурдан 2xx буцаа (5 сек timeout).
  • payment id-аар давхардлыг шүүж idempotent бай.

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

Хүргэлт амжилтгүй болох, дахин оролдох, давхардлыг шийдвэрлэх талаар Найдвартай хүлээн авах хуудсыг үзнэ үү. Webhook-ийн ерөнхий ойлголтыг Webhook тойм-оос аваарай.