# Webhook қауіпсіздігі және қолтаңбаны тексеру

> Қолтаңба қалай құралады, неге raw body міндетті, timestamp-ты қалай тексеру керек, Express, Laravel, Django және таза Node үшін код мысалдары, идемпотентті өңдеу және жиі кездесетін қателер.

## Қысқаша

Webhook адресі интернетте ашық тұрады, сондықтан кез келген адам сізге «счёт төленді» деген сұрау жібере алады. Одан қорғайтын жалғыз нәрсе — **қолтаңбаны тексеру**.

Қолтаңба былай құралады:

```
signature = "sha256=" + hex( HMAC-SHA256( secret, timestamp + "." + rawBody ) )
```

Мұндағы `timestamp` — `X-Webhook-Timestamp` тақырыбындағы мән, `rawBody` — сұраудың денесі **өзгертілмеген байт күйінде**. Нәтижені `X-Webhook-Signature` тақырыбындағы мәнмен салыстырасыз.

Үш тексеру міндетті: қолтаңба сәйкес келсін, `timestamp` 5 минуттан ескі болмасын, өңдеу идемпотентті болсын.

## Raw body неге міндетті

Ең жиі кездесетін қате осы. Фреймворктердің көбі денені автоматты түрде JSON-ға айналдырады, ал сіз оны қайтадан жолға айналдырғанда байттар өзгереді: бос орындар, өрістердің реті, Unicode таңбаларының жазылуы.

HMAC байт бойынша есептеледі. Бір байт өзгерсе, қолтаңба мүлдем басқа шығады.

| Қате тәсіл | Дұрыс тәсіл |
|---|---|
| `JSON.stringify(req.body)` | `req.body` — Buffer күйінде |
| `json.dumps(request.json)` | `request.body` немесе `request.get_data()` |
| `json_encode($data)` | `file_get_contents('php://input')` |

Ереже: **денені JSON-ға айналдырғанға дейін** қолтаңбаны тексеріңіз, содан кейін ғана талдаңыз.

## Timestamp тексеру

`X-Webhook-Timestamp` — жіберілген уақыт, Unix секунд. Ол 5 минуттан ескі болса, сұрауды қабылдамаңыз.

Бұл қайта жіберу шабуылынан қорғайды: біреу бұрын жарамды болған сұрауды ұстап алып, кейін қайта жіберсе, қолтаңбасы дұрыс болғанымен уақыты ескіріп қалады.

Серверіңіздің сағаты дұрыс жүріп тұрғанына көз жеткізіңіз — сағат бірнеше минутқа ауытқыса, жарамды сұраулар да қабылданбай қалады.

## Express (Node.js)

Маңыздысы — `express.json()` осы маршрутқа дейін қолданылмауы керек.

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

const app = express();

function verify(req) {
  const ts = req.get('X-Webhook-Timestamp') || '';
  const got = req.get('X-Webhook-Signature') || '';
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const mac = crypto
    .createHmac('sha256', process.env.QUTPAY_WEBHOOK_SECRET)
    .update(ts + '.')
    .update(req.body)          // Buffer, өзгертілмеген
    .digest('hex');
  const expected = 'sha256=' + mac;

  const a = Buffer.from(expected);
  const b = Buffer.from(got);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post('/qutpay-webhook', express.raw({ type: '*/*' }), (req, res) => {
  if (!verify(req)) return res.sendStatus(401);
  res.sendStatus(200);
  const { event, invoice } = JSON.parse(req.body.toString('utf8'));
  handle(event, invoice);
});
```

Салыстыруда қарапайым теңдіктің орнына `timingSafeEqual` қолданыңыз: әдеттегі салыстыру қолтаңбаны әріптеп табуға жол ашады.

## Laravel (PHP)

Laravel денені де оқып қояды, бірақ `$request->getContent()` бастапқы байттарды береді. Бұл маршрутты CSRF тексеруінен шығарыңыз.

```php
public function handle(Request $request)
{
    $raw = $request->getContent();
    $ts  = $request->header('X-Webhook-Timestamp', '');
    $got = $request->header('X-Webhook-Signature', '');

    if (abs(time() - (int) $ts) > 300) {
        return response('stale', 401);
    }

    $mac = hash_hmac('sha256', $ts . '.' . $raw, config('services.qutpay.webhook_secret'));
    if (!hash_equals('sha256=' . $mac, $got)) {
        return response('bad signature', 401);
    }

    $payload = json_decode($raw, true);
    dispatch(new HandleQutPayEvent($payload));   // жұмысты кезекке

    return response('', 200);
}
```

Таза PHP-де дене `file_get_contents('php://input')` арқылы алынады, тақырыптар — `getallheaders()`.

## Django (Python)

```python
import hmac, hashlib, time, json
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def qutpay_webhook(request):
    raw = request.body                      # bytes, өзгертілмеген
    ts = request.headers.get("X-Webhook-Timestamp", "")
    got = request.headers.get("X-Webhook-Signature", "")

    if abs(time.time() - int(ts or 0)) > 300:
        return HttpResponse("stale", status=401)

    mac = hmac.new(
        settings.QUTPAY_WEBHOOK_SECRET.encode(),
        ts.encode() + b"." + raw,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest("sha256=" + mac, got):
        return HttpResponse("bad signature", status=401)

    payload = json.loads(raw)
    handle(payload)
    return HttpResponse(status=200)
```

Flask-та дене `request.get_data()` арқылы алынады, қалғаны бірдей.

## Фреймворксіз Node

```js
import http from 'node:http';

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);     // өзгертілмеген байттар
    if (!verifyRaw(raw, req.headers)) {
      res.writeHead(401).end();
      return;
    }
    res.writeHead(200).end();
    handle(JSON.parse(raw.toString('utf8')));
  });
}).listen(3000);
```

## Идемпотентті өңдеу

Қолтаңба дұрыс болса да, **бір оқиға бірнеше рет келуі мүмкін**: желі үзілді, сіз 200 қайтара алмадыңыз, біз қайталадық. Жалпы қайталау саны — 11 рет.

Сондықтан өңдеуді `(invoice.id, status)` жұбы бойынша **бір рет** орындаңыз. Ең қарапайым тәсіл — сол жұпты өз базаңызға бірегей кілтпен жазу:

```sql
CREATE TABLE qutpay_events (
  invoice_id TEXT NOT NULL,
  status     TEXT NOT NULL,
  handled_at TIMESTAMPTZ DEFAULT now(),
  PRIMARY KEY (invoice_id, status)
);
```

```js
const ins = await db.query(
  'INSERT INTO qutpay_events (invoice_id, status) VALUES ($1, $2) ON CONFLICT DO NOTHING',
  [invoice.id, invoice.status],
);
if (ins.rowCount === 0) return;   // бұрын өңделген
await fulfil(invoice);
```

Толығырақ: [Идемпоттылық](/kb/idempotency).

## Жиі кездесетін қателер

| Не істелді | Нәтижесі | Шешімі |
|---|---|---|
| Дене JSON-ға айналдырылған соң қайта жолға айналдырылды | Қолтаңба ешқашан сәйкес келмейді | Raw body қолданыңыз |
| `timestamp` қолтаңбаға қосылмаған | Қолтаңба сәйкес келмейді | `timestamp + "." + rawBody` реті сақталсын |
| `sha256=` префиксі салыстыруда ескерілмеген | Қолтаңба сәйкес келмейді | Префиксті қосып салыстырыңыз немесе екеуінен де алып тастаңыз |
| Құпия қате көшірілген, соңында бос орын бар | Қолтаңба сәйкес келмейді | Құпияны қайта көшіріңіз |
| Сервер сағаты ауытқыған | Жарамды сұраулар «ескі» болып қабылданбайды | Уақытты синхрондаңыз |
| Қолтаңба мүлдем тексерілмейді | Кез келген адам жалған «төленді» жібере алады | Тексеруді қосыңыз |
| Адрес авторизациямен жабылған | Webhook мүлдем келмейді | Адресті ашық қалдырып, қолтаңбамен қорғаңыз |

Қолтаңба сәйкес келмей жатса: [Webhook қолтаңбасы сәйкес келмейді](/kb/webhook-signature-mismatch).

## Қосымша шаралар

- Адрес табуға қиын болсын: `/qutpay-webhook-8f3a…` сияқты. Бұл қолтаңбаны алмастырмайды, бірақ артық шу азаяды.
- HTTPS міндетті, продакшенде басқасы қабылданбайды.
- Құпияны кодқа жазбаңыз, ортаның құпия айнымалысында сақтаңыз.
- Қолтаңбасы дұрыс емес сұрауларды журналға жазыңыз — шабуылды да, өз қатеңізді де осыдан көресіз.
- Толық тізім: [Интеграция қауіпсіздігі: чек-парақ](/kb/security-checklist).

## Жиі қойылатын сұрақтар

**Қолтаңбаны тексермей-ақ қойсам бола ма?** Болмайды. Ондай адреске кез келген адам «счёт төленді» деп жіберіп, тауарыңызды тегін алып кете алады.

**IP бойынша сүзсем жеткілікті ме?** Жоқ. IP адрестер өзгереді, ал қолтаңба деректің өзін дәлелдейді.

**Құпия қашан ауысады?** Сіз өзіңіз ауыстырғанда ғана. Жаңа құпия жасалған сәттен бастап қолданылады, серверіңізді бір мезетте жаңартыңыз.

**Неге денеде төлемнің дәл уақыты жоқ?** Kaspi төлем уақытын бермейді, сондықтан біз тек өзіміз білген сәтті бере аламыз.

**Тексеруді қалай сынаймын?** Кабинеттің **Интеграциялар** бөліміндегі сынау батырмасымен, сосын sandbox-та төлемді симуляциялап көріңіз.
