LabHub
배우기 러닝패스 코스

冪等性 — 二度押しても決済は一度だけ

署名は正しいがWebhookが二度届いた

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

목표

원문 서명·시간 창·이벤트 중복 제거를 하나의 웹훅 처리 경로로 연결합니다.

왜 중요한가

결제 제공자가 응답을 받지 못해 같은 이벤트를 다시 보냈다. 서버는 서명이 맞으니 정상 요청이라고 생각하고 매출을 다시 더했다. 서명은 누가 보냈는지에 관한 증거이지 처음 처리하는 요청이라는 증거가 아니다. 재전송을 허용하는 시간 창과 저장된 이벤트 id를 각각 확인해야 한다.

단계

  1. /root/work/idem-webhook-lab/service.py에서 signed_bytes(timestamp, body)는 bool 제외 int timestamp와 bytes body만 받아 str(timestamp).encode()+b'.'+body를 반환합니다. 잘못된 타입은 ValueError입니다.

처음 한 번 준비하세요. 기존 파일은 덮어쓰지 않습니다.

mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab
  1. /root/work/idem-webhook-lab/service.py에서 signature(secret, timestamp, body)는 bytes secret으로 signed_bytes에 HMAC-SHA256을 적용한 hex 문자열입니다.

  2. /root/work/idem-webhook-lab/service.py에서 verify(secret, timestamp, body, supplied)는 supplied가 str이며 계산한 서명과 compare_digest로 같을 때 True, 아니면 False입니다.

  3. /root/work/idem-webhook-lab/service.py에서 fresh(timestamp, now, tolerance=300)는 timestamp와 now가 bool 제외 int이고 abs(now-timestamp)<=tolerance면 True입니다. 나머지는 False입니다. tolerance는 호출자가 주는 양의 int입니다.

  4. /root/work/idem-webhook-lab/service.py에서 init_db(path)는 inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)와 total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)을 만들고 total에 id=1,amount=0을 중복 없이 넣습니다.

  5. /root/work/idem-webhook-lab/service.py에서 apply(path, event, fault=lambda:None)는 event의 id가 비어 있지 않은 str, amount가 bool 제외 양의 int인지 검증합니다. id·amount를 정규 JSON으로 지문 계산합니다. 같은 id/지문은 False, 다른 지문은 ValueError, 새 이벤트는 inbox 삽입→fault()→합계 증가 후 True입니다.

  6. /root/work/idem-webhook-lab/service.py에서 total(path)는 total의 id=1인 amount 정수를 반환합니다.

  7. /root/work/idem-webhook-lab/service.py에서 create_app(path, secret, clock)는 POST /webhook에서 원문 body, X-Timestamp, X-Signature를 읽습니다. 시각 형식·시간 창·서명 실패는 401, JSON 파싱 실패는 400, apply의 ValueError는 409입니다. 정상은 200 {accepted:True, duplicate:첫처리면False}입니다.

참고

서명할 원문을 보존한다

/root/work/idem-webhook-lab/service.py에서 signed_bytes(timestamp, body)는 bool 제외 int timestamp와 bytes body만 받아 str(timestamp).encode()+b'.'+body를 반환합니다. 잘못된 타입은 ValueError입니다.

처음 한 번 준비하세요. 기존 파일은 덮어쓰지 않습니다.

mkdir -p /root/work/idem-webhook-lab
test -e /root/work/idem-webhook-lab/service.py || cp /opt/fixtures/ten_labs/idem-webhook-lab/service.py /root/work/idem-webhook-lab/service.py
cd /root/work/idem-webhook-lab

원문 body를 JSON으로 파싱했다가 다시 만드는 것은 서명 대상이 달라지는 일입니다.

저장 후 bash /opt/lab/checks/idem-webhook-lab/01-contract.sh로 확인하세요.

HMAC을 계산한다

/root/work/idem-webhook-lab/service.py에서 signature(secret, timestamp, body)는 bytes secret으로 signed_bytes에 HMAC-SHA256을 적용한 hex 문자열입니다.

일반 해시에 비밀을 덧붙이는 방식 대신 표준 HMAC을 사용합니다.

저장 후 bash /opt/lab/checks/idem-webhook-lab/02-contract.sh로 확인하세요.

잘못된 서명을 거절한다

/root/work/idem-webhook-lab/service.py에서 verify(secret, timestamp, body, supplied)는 supplied가 str이며 계산한 서명과 compare_digest로 같을 때 True, 아니면 False입니다.

서명이 있느냐와 서명이 맞느냐는 다른 검사입니다.

저장 후 bash /opt/lab/checks/idem-webhook-lab/03-contract.sh로 확인하세요.

과거와 미래의 재생을 제한한다

/root/work/idem-webhook-lab/service.py에서 fresh(timestamp, now, tolerance=300)는 timestamp와 now가 bool 제외 int이고 abs(now-timestamp)<=tolerance면 True입니다. 나머지는 False입니다. tolerance는 호출자가 주는 양의 int입니다.

미래 타임스탬프를 무조건 허용하면 공격자가 유효 기간을 늘릴 수 있습니다.

저장 후 bash /opt/lab/checks/idem-webhook-lab/04-contract.sh로 확인하세요.

이벤트와 효과를 함께 저장할 준비를 한다

/root/work/idem-webhook-lab/service.py에서 init_db(path)는 inbox(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)와 total(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)을 만들고 total에 id=1,amount=0을 중복 없이 넣습니다.

중복 이벤트 기록과 업무 합계가 같은 DB 트랜잭션에 있어야 합니다.

저장 후 bash /opt/lab/checks/idem-webhook-lab/05-contract.sh로 확인하세요.

기록과 매출 사이의 실패를 롤백한다

/root/work/idem-webhook-lab/service.py에서 apply(path, event, fault=lambda:None)는 event의 id가 비어 있지 않은 str, amount가 bool 제외 양의 int인지 검증합니다. id·amount를 정규 JSON으로 지문 계산합니다. 같은 id/지문은 False, 다른 지문은 ValueError, 새 이벤트는 inbox 삽입→fault()→합계 증가 후 True입니다.

fault에서 예외가 나면 inbox도 합계도 남지 않아야 합니다.

저장 후 bash /opt/lab/checks/idem-webhook-lab/06-contract.sh로 확인하세요.

별도 연결에서 합계를 읽는다

/root/work/idem-webhook-lab/service.py에서 total(path)는 total의 id=1인 amount 정수를 반환합니다.

콜백의 실행 횟수 대신 DB에 실제로 남은 업무 효과를 확인합니다.

저장 후 bash /opt/lab/checks/idem-webhook-lab/07-contract.sh로 확인하세요.

실제 웹훅 요청을 처리한다

/root/work/idem-webhook-lab/service.py에서 create_app(path, secret, clock)는 POST /webhook에서 원문 body, X-Timestamp, X-Signature를 읽습니다. 시각 형식·시간 창·서명 실패는 401, JSON 파싱 실패는 400, apply의 ValueError는 409입니다. 정상은 200 {accepted:True, duplicate:첫처리면False}입니다.

서명을 먼저 검증한 뒤 JSON을 읽고, 중복도 정상 확인 응답으로 반환합니다.

저장 후 bash /opt/lab/checks/idem-webhook-lab/08-contract.sh로 확인하세요.