Petnow LogoPetnow
Webhooks

서명 검증

Standard Webhooks 헤더와 엔드포인트의 서명 시크릿으로, 요청이 Petify에서 온 것인지 검증하는 방법

왜 검증해야 하나요?

엔드포인트 URL을 아는 사람은 누구나 그 주소로 요청을 보낼 수 있습니다. 모든 요청의 서명을 검증하고, 실패한 요청은 거부하세요. Petify는 Standard Webhooks 방식으로 이벤트에 서명하므로, 이미 만들어진 라이브러리를 그대로 쓸 수 있습니다.

헤더

모든 요청에는 세 개의 헤더가 포함됩니다.

헤더설명
webhook-id이벤트 ID. 본문의 id 필드와 같고, 재시도와 재전송에서도 바뀌지 않습니다.
webhook-timestamp이번 전송 시도의 Unix 시간(초)
webhook-signaturev1,<signature> 형식. 서명 시크릿을 키로 {webhook-id}.{webhook-timestamp}.{원본 본문}에 HMAC-SHA256을 적용한 뒤 Base64로 인코딩한 값

서명 시크릿은 whsec_... 형태입니다. Console에서 엔드포인트를 열고 시크릿 보기를 클릭해 복사하세요.

원본(raw) 본문으로 검증하세요. 받은 바이트 그대로 서명을 계산해야 합니다. 프레임워크가 JSON을 먼저 파싱하고 다시 직렬화하면 공백과 키 순서가 바뀌어 검증이 실패할 수 있습니다. 원본 본문을 읽어 검증한 뒤에 파싱하세요.

Node.js

standardwebhooks 패키지를 설치합니다.

npm install standardwebhooks express
import express from "express";
import { Webhook } from "standardwebhooks";

const app = express();
const wh = new Webhook(process.env.PETIFY_WEBHOOK_SECRET); // whsec_...

// express.raw keeps the body as a Buffer, exactly as Petify sent it.
app.post(
  "/webhooks/petify",
  express.raw({ type: "application/json" }),
  (req, res) => {
    let event;
    try {
      event = wh.verify(req.body.toString("utf8"), {
        "webhook-id": req.header("webhook-id"),
        "webhook-timestamp": req.header("webhook-timestamp"),
        "webhook-signature": req.header("webhook-signature"),
      });
    } catch {
      return res.status(400).send("invalid signature");
    }

    // Deduplicate on the webhook-id header before you act on the event.
    switch (event.type) {
      case "verification_job.succeeded":
        // event.data.jobId, event.data.petId, event.data.isVerified, event.data.score
        break;
      default:
        // Ignore event types you do not handle.
        break;
    }

    res.sendStatus(200);
  },
);

app.listen(3000);

Python

standardwebhooks 패키지를 설치합니다.

pip install standardwebhooks flask
import os

from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
wh = Webhook(os.environ["PETIFY_WEBHOOK_SECRET"])  # whsec_...


@app.post("/webhooks/petify")
def petify_webhook():
    # get_data() returns the raw bytes, exactly as Petify sent them.
    payload = request.get_data()
    headers = {
        "webhook-id": request.headers.get("webhook-id", ""),
        "webhook-timestamp": request.headers.get("webhook-timestamp", ""),
        "webhook-signature": request.headers.get("webhook-signature", ""),
    }
    try:
        event = wh.verify(payload, headers)
    except WebhookVerificationError:
        return "invalid signature", 400

    # Deduplicate on the webhook-id header before you act on the event.
    if event["type"] == "verification_job.succeeded":
        data = event["data"]  # jobId, petId, isVerified, score
    # Ignore event types you do not handle.

    return "", 200

라이브러리는 webhook-timestamp도 확인해서, 서버 시계와 5분 넘게 차이 나는 요청을 거부합니다. 서버 시계를 NTP로 맞춰 두세요.

직접 검증하기

라이브러리를 쓸 수 없다면 서명을 직접 검증하세요. 순서는 다음과 같습니다.

  1. webhook-timestamp가 현재 시각과 5분 넘게 차이 나면 요청을 거부합니다. 오래된 요청의 재생(replay)을 막습니다.
  2. 서명 대상 문자열 {webhook-id}.{webhook-timestamp}.{원본 본문}을 만듭니다.
  3. 시크릿에서 whsec_ 뒤의 부분을 Base64 디코딩합니다. 이 값이 HMAC 키입니다.
  4. 서명 대상 문자열의 HMAC-SHA256을 계산하고 Base64로 인코딩합니다.
  5. webhook-signature에 들어 있는 각 서명과 상수 시간 비교로 비교합니다. 하나라도 일치하면 요청을 받아들입니다.
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]

    # 1. Reject old (or far-future) timestamps.
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    # 2-4. Sign "{id}.{timestamp}.{body}" with the decoded secret.
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
    digest = hmac.new(key, signed_content, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()

    # 5. The header can hold several space-separated "v1,<signature>" values.
    for part in headers["webhook-signature"].split(" "):
        version, _, signature = part.partition(",")
        if version == "v1" and hmac.compare_digest(signature, expected):
            return True
    return False

시크릿 교체

Console에서 엔드포인트를 열고 시크릿 교체를 클릭합니다. Petify가 새 시크릿을 만들고 바로 그 시크릿으로 서명합니다.

  • 교체 후 24시간 동안은 이전 시크릿도 계속 서명에 쓰입니다. 이 기간에는 webhook-signature 헤더에 새 서명과 이전 서명, 두 개의 서명이 공백으로 구분되어 들어 있습니다.
  • 하나라도 검증되면 요청을 받아들이세요. 위의 라이브러리와 직접 검증 예제는 이미 이렇게 동작합니다.
  • 24시간 안에 새 시크릿을 서버에 배포하세요. 기간이 지난 뒤의 요청에는 새 서명만 들어 있습니다.

시크릿을 교체해도 전송이 끊기지 않습니다. 새 시크릿을 먼저 배포한 뒤에 이전 시크릿 사용을 중단하세요.

문제 해결

증상가능한 원인
모든 요청의 검증이 실패함다른 엔드포인트의 시크릿처럼 잘못된 시크릿을 쓰고 있거나, 시크릿을 교체한 지 24시간이 지났는데 서버가 이전 시크릿을 쓰고 있습니다.
일부 요청만 검증이 실패함프레임워크가 본문을 읽기 전에 바꾸고 있습니다(JSON 파싱, 압축 해제, 공백 제거 등). 원본 바이트로 검증하세요.
타임스탬프가 거부됨서버 시계가 5분 넘게 어긋나 있습니다.

On this page