Xác thực chữ ký
Ai cũng có thể gửi một POST giả tới URL webhook của bạn và nói “đơn DH-1024 đã thanh toán”. Chữ ký là cách duy nhất để biết request thật sự đến từ Hensa và không bị sửa.
Định dạng
Phần tiêu đề “Định dạng”Hensa-Signature: t=1762156992,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt– thời điểm ký (Unix, giây).v1–HMAC-SHA256(secret, "<t>.<raw body>"), dạng hex. Trong 24 giờ sau khi đổi secret sẽ có hai giá trịv1.
Cách xác thực
Phần tiêu đề “Cách xác thực”- Đọc body thô (bytes) của request – trước khi parse JSON.
- Tách
tvà mọi giá trịv1từ headerHensa-Signature. - Từ chối nếu
tlệch quá 5 phút so với giờ máy chủ (chống phát lại). - Tính
HMAC-SHA256(secret, t + "." + body)và so sánh hằng thời gian với từngv1. Khớp một giá trị là hợp lệ.
import crypto from 'node:crypto'
/** Returns true when the request really comes from Hensa and is fresh (≤ 300 s). */export function verifyHensaSignature(rawBody, header, secret, toleranceSec = 300) { const parts = (header ?? '').split(',').map((p) => p.trim().split('=')) const t = parts.find(([k]) => k === 't')?.[1] const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v) // 2 signatures while a secret rotates if (!t || signatures.length === 0) return false if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false // replay protection
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex') return signatures.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s, 'hex'), Buffer.from(expected, 'hex')) )}<?php// webhooks/hensa.php – plain PHP (works in Laravel/WordPress too: read the raw body, not the parsed array)$payload = file_get_contents('php://input');$header = $_SERVER['HTTP_HENSA_SIGNATURE'] ?? '';$secret = getenv('HENSA_WEBHOOK_SECRET');
$t = null; $signatures = [];foreach (explode(',', $header) as $part) { [$k, $v] = array_pad(explode('=', trim($part), 2), 2, null); if ($k === 't') { $t = $v; } if ($k === 'v1') { $signatures[] = $v; }}if (!$t || abs(time() - (int) $t) > 300) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);$valid = false;foreach ($signatures as $s) { if (hash_equals($expected, $s)) { $valid = true; break; } }if (!$valid) { http_response_code(400); exit; }
$event = json_decode($payload, true);// Idempotent: skip if $event['id'] was already processed.if ($event['type'] === 'payment_request.paid') { mark_order_paid($event['data']['externalOrderRef'], $event['data']['id']);}http_response_code(200);# Flaskimport hashlib, hmac, os, timefrom flask import Flask, request, abort
app = Flask(__name__)SECRET = os.environ["HENSA_WEBHOOK_SECRET"].encode()
def verify(raw: bytes, header: str, tolerance: int = 300) -> bool: items = [p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p] t = next((v for k, v in items if k == "t"), None) signatures = [v for k, v in items if k == "v1"] if not t or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, s) for s in signatures)
@app.post("/webhooks/hensa")def hensa_webhook(): raw = request.get_data() # raw bytes, before any JSON parsing if not verify(raw, request.headers.get("Hensa-Signature", "")): abort(400) event = request.get_json() # Idempotent: skip if event["id"] was already processed. if event["type"] == "payment_request.paid": mark_order_paid(event["data"]["externalOrderRef"], event["data"]["id"]) return "", 200Lỗi hay gặp
Phần tiêu đề “Lỗi hay gặp”| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Chữ ký luôn sai | Framework đã parse JSON rồi stringify lại – bytes đã khác | Lấy body thô: Express express.raw(), Laravel $request->getContent(), Next.js await req.text() |
| Chữ ký sai sau khi đổi secret | Vẫn dùng secret cũ | Cập nhật secret mới; trong 24 giờ hai chữ ký cùng hợp lệ |
| Lúc đúng lúc sai | Đồng hồ máy chủ lệch | Bật đồng bộ giờ (NTP) |
Dùng == để so sánh |
Dễ bị tấn công đo thời gian | Dùng timingSafeEqual / hash_equals / hmac.compare_digest |
Đổi secret
Phần tiêu đề “Đổi secret”Dashboard › Developer › Webhook › Đổi secret (hoặc POST /webhook_endpoints/{id}/rotate_secret). Secret cũ còn hiệu lực 24 giờ; trong thời gian đó mỗi sự kiện mang hai chữ ký v1 – triển khai secret mới lúc nào cũng được, không mất sự kiện nào.