Preskočiť na obsah

Ukážky overenia

Každá ukážka robí rovnakých päť krokov opísaných vo Webhookoch: čerstvosť, kontrola podpisu v konštantnom čase, nonce, parsovanie, deduplikácia podľa ID udalosti. Úložisko nonce je ukázané ako Redis; funguje akékoľvek úložisko s atomickým zápisom, ak kľúč neexistuje, a s TTL.

import hmac, hashlib, json, time
from flask import Flask, request, abort
import redis
app = Flask(__name__)
r = redis.Redis()
SECRET = os.environ["ROLCHAT_WEBHOOK_SECRET"].encode()
@app.post("/rolchat/webhook")
def webhook():
ts = request.headers.get("X-RolChat-Timestamp", "")
sig = request.headers.get("X-RolChat-Signature", "")
nonce = request.headers.get("X-RolChat-Nonce", "")
event_id = request.headers.get("X-RolChat-Event-Id", "")
body = request.get_data() # raw bytes
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(400, "timestamp_out_of_window")
expected = "sha256=" + hmac.new(SECRET, f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401, "invalid_signature")
if not r.set(f"webhook_nonce:{nonce}", "1", nx=True, ex=600):
abort(409, "duplicate_nonce")
event = json.loads(body)
if not r.set(f"webhook_event:{event_id}", "1", nx=True, ex=7 * 24 * 3600):
return "", 200 # already processed
enqueue(event) # do the work off the request thread
return "", 200
import crypto from 'node:crypto';
import express from 'express';
import Redis from 'ioredis';
const app = express();
const redis = new Redis();
const SECRET = process.env.ROLCHAT_WEBHOOK_SECRET;
// Keep the raw body: express.json() would re-serialise it.
app.post('/rolchat/webhook', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const ts = req.get('X-RolChat-Timestamp') ?? '';
const sig = req.get('X-RolChat-Signature') ?? '';
const nonce = req.get('X-RolChat-Nonce') ?? '';
const eventId = req.get('X-RolChat-Event-Id') ?? '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.status(400).send('timestamp_out_of_window');
}
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET)
.update(`${ts}.`).update(req.body).digest('hex');
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('invalid_signature');
}
if (!(await redis.set(`webhook_nonce:${nonce}`, '1', 'EX', 600, 'NX'))) {
return res.status(409).send('duplicate_nonce');
}
const event = JSON.parse(req.body.toString('utf8'));
if (!(await redis.set(`webhook_event:${eventId}`, '1', 'EX', 7 * 86400, 'NX'))) {
return res.sendStatus(200); // already processed
}
await queue.add('rolchat-event', event);
res.sendStatus(200);
});
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"strconv"
"time"
)
var secret = []byte(os.Getenv("ROLCHAT_WEBHOOK_SECRET"))
func webhook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
ts := r.Header.Get("X-RolChat-Timestamp")
sig := r.Header.Get("X-RolChat-Signature")
nonce := r.Header.Get("X-RolChat-Nonce")
eventID := r.Header.Get("X-RolChat-Event-Id")
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 {
http.Error(w, "timestamp_out_of_window", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(ts + "."))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) {
http.Error(w, "invalid_signature", http.StatusUnauthorized)
return
}
// SET NX EX 600 - any store with an atomic set-if-absent works.
if !nonceStore.SetNX("webhook_nonce:"+nonce, 10*time.Minute) {
http.Error(w, "duplicate_nonce", http.StatusConflict)
return
}
if !nonceStore.SetNX("webhook_event:"+eventID, 7*24*time.Hour) {
w.WriteHeader(http.StatusOK) // already processed
return
}
go process(body)
w.WriteHeader(http.StatusOK)
}
require "openssl"
require "json"
SECRET = ENV.fetch("ROLCHAT_WEBHOOK_SECRET")
def rolchat_webhook(request)
body = request.raw_post # raw bytes, not params
ts = request.headers["X-RolChat-Timestamp"].to_s
sig = request.headers["X-RolChat-Signature"].to_s
nonce = request.headers["X-RolChat-Nonce"].to_s
event_id = request.headers["X-RolChat-Event-Id"].to_s
return [400, "timestamp_out_of_window"] unless ts =~ /\A\d+\z/ && (Time.now.to_i - ts.to_i).abs <= 300
expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{ts}.#{body}")
return [401, "invalid_signature"] unless ActiveSupport::SecurityUtils.secure_compare(sig, expected)
return [409, "duplicate_nonce"] unless REDIS.set("webhook_nonce:#{nonce}", "1", nx: true, ex: 600)
event = JSON.parse(body)
return [200, ""] unless REDIS.set("webhook_event:#{event_id}", "1", nx: true, ex: 7 * 86_400)
RolchatEventJob.perform_later(event)
[200, ""]
end
<?php
$secret = getenv('ROLCHAT_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // raw bytes
$ts = $_SERVER['HTTP_X_ROLCHAT_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_ROLCHAT_SIGNATURE'] ?? '';
$nonce = $_SERVER['HTTP_X_ROLCHAT_NONCE'] ?? '';
$eventId = $_SERVER['HTTP_X_ROLCHAT_EVENT_ID'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int) $ts) > 300) {
http_response_code(400); exit('timestamp_out_of_window');
}
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401); exit('invalid_signature');
}
$redis = new Redis(); $redis->connect('127.0.0.1');
if (!$redis->set("webhook_nonce:$nonce", '1', ['nx', 'ex' => 600])) {
http_response_code(409); exit('duplicate_nonce');
}
$event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
if (!$redis->set("webhook_event:$eventId", '1', ['nx', 'ex' => 7 * 86400])) {
http_response_code(200); exit; // already processed
}
enqueue($event);
http_response_code(200);

Požiadajte RolChat, aby na váš endpoint poslal syntetickú podpísanú udalosť:

POST /api/v1/webhooks/test-signature
Authorization: Bearer pat_…
X-Account-Id: <account uuid>

Ak váš príjemca na toto volanie odpovie 200 a 401, keď zmeníte jeden bajt tela, funguje správne.