콘텐츠로 이동

검증 예제

각 예제는 웹훅에서 설명한 다섯 가지를 똑같이 수행합니다: 유효 시간 확인, 상수 시간 서명 검사, 논스, 파싱, 이벤트 ID로 중복 제거. 논스 저장소는 Redis를 예로 들었지만, 원자적인 “없을 때만 설정”과 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);

RolChat이 여러분의 엔드포인트로 서명된 합성 이벤트를 보내도록 요청합니다.

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

수신기가 이 호출에 200으로 응답하고, 본문을 1바이트 바꿨을 때 401로 응답하면 올바르게 구현된 것입니다.