TT Lab
はじめる
学ぶ 学習パス コース

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

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

TT Labで続きを見る

目標

原文の署名・時間の窓・イベントの重複排除を、1つのWebhook処理の経路につなげます。

なぜ重要なのか

決済プロバイダーが応答を受け取れず、同じイベントを再送しました。サーバーは、署名が合っているので正常なリクエストだと考え、売上を再び加算しました。署名は、誰が送ったかに関する証拠であって、初めて処理するリクエストだという証拠ではありません。再送を許可する時間の窓と、保存されたイベントidを、それぞれ確認する必要があります。

ステップ

  1. /root/work/idem-webhook-lab/service.pyで、signed_bytes(timestamp, body)は、boolを除くintのtimestampと、bytesのbodyだけを受け取り、str(timestamp).encode()+b'.'+bodyを返します。不正な型はValueErrorです。

最初に1回だけ準備してください。既存のファイルは上書きしません。

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です。

最初に1回だけ準備してください。既存のファイルは上書きしません。

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で確認してください。

実際のWebhookリクエストを処理する

/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で確認してください。