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

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

署名は正しいがWebhookが二度届いた:設計原理

TT Labで続きを見る

一言でいうと

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

なぜ必要なのか

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

どう動くのか

署名の対象は、タイムスタンプの文字列と、原文のbodyのバイト列です。JSONを再シリアライズしたあとで署名を比較すると、空白やキーの順序が変わって、正常なリクエストを拒否してしまいます。HMAC-SHA256とcompare_digestを使い、時間の差は両方向で検査します。有効なイベントは、idと本文のフィンガープリントをinboxに記録し、売上の合計と同じトランザクションでコミットします。途中の例外は、2つの書き込みをどちらもロールバックします。

원문+시각 → 서명·시간 검사 → inbox id+본문지문 → 합계 반영 → 동일 트랜잭션

契約を読んで失敗を予測するワークシート

以下は、実装を丸ごと暗記するための解答ではなく、ステップごとのコードレビューです。各変更の断片は、意図的に契約を壊しています。変更後も、正常なケースは通ることがある点に注意してください。実行する前に、どの入力・例外・状態を観測すれば違いが現れるかを予想し、実装したあとで、その予想と結果を比べます。

1. 署名する原文を保持する

signed_bytes(timestamp, body)は、boolを除くintのtimestampと、bytesのbodyだけを受け取り、str(timestamp).encode()+b'.'+bodyを返します。不正な型はValueErrorです。

判断の根拠: 原文のbodyをJSONとしてパースしてから作り直すと、署名の対象が変わります。

レビューする誤った変更の断片:

+ b":" + body

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

2. HMACを計算する

signature(secret, timestamp, body)は、bytesのsecretで、signed_bytesにHMAC-SHA256を適用したhex文字列です。

判断の根拠: 通常のハッシュに秘密を付け足す方式の代わりに、標準のHMACを使います。

レビューする誤った変更の断片:

body, hashlib.sha256

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

3. 不正な署名を拒否する

verify(secret, timestamp, body, supplied)は、suppliedがstrで、計算した署名とcompare_digestで同じときにTrue、そうでなければFalseです。

判断の根拠: 署名があるかどうかと、署名が合っているかどうかは、別の検査です。

レビューする誤った変更の断片:

signature(secret,timestamp,body), signature(secret,timestamp,body)

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

4. 過去と未来のリプレイを制限する

fresh(timestamp, now, tolerance=300)は、timestampとnowがboolを除くintで、abs(now-timestamp)<=toleranceならTrueです。それ以外はFalseです。toleranceは、呼び出し側が渡す正のintです。

判断の根拠: 未来のタイムスタンプを無条件に許可すると、攻撃者が有効期間を延ばせます。

レビューする誤った変更の断片:

(now-timestamp)

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

5. イベントと効果を一緒に保存する準備をする

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トランザクションにある必要があります。

レビューする誤った変更の断片:

VALUES (1,1)

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

6. 記録と売上の間の失敗をロールバックする

apply(path, event, fault=lambda:None)は、eventのidが空でないstr、amountがboolを除く正のintであるかを検証します。idとamountを正規JSONにして、フィンガープリントを計算します。同じid/フィンガープリントはFalse、別のフィンガープリントはValueError、新しいイベントはinboxへの挿入→fault()→合計の増加のあとにTrueです。

判断の根拠: faultで例外が起きたら、inboxも合計も残っていてはいけません。

レビューする誤った変更の断片:

db.commit()
        fault()

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

7. 別の接続で合計を読む

total(path)は、totalのid=1のamountの整数を返します。

判断の根拠: コールバックの実行回数の代わりに、DBに実際に残った業務上の効果を確認します。

レビューする誤った変更の断片:

SELECT id FROM total WHERE id=1

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

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

create_app(path, secret, clock)は、POST /webhookで、原文のbody、X-Timestamp、X-Signatureを読みます。時刻の形式・時間の窓・署名の失敗は401、JSONのパースの失敗は400、applyのValueErrorは409です。正常は、200 {accepted:True, duplicate:初回処理ならFalse}です。

判断の根拠: 署名を先に検証してからJSONを読み、重複も、正常な確認応答として返します。

レビューする誤った変更の断片:

"duplicate":False

この断片が入った関数の公開契約と比べてみてください。成功ケース1つでは区別できないなら、拒否されるべき入力や、失敗のあとの状態を観測の対象に選びます。

現場での姿

この署名の形式は教育用のプロトコルであり、実際の決済プロバイダーの規格の代わりにはなりません。学習用のsecretだけを使います。サーバーの時計の信頼性、キーのローテーション、許容する本文のサイズと永久保管の期間は、別の運用上の課題です。重複排除は、同じイベントidと同じ本文に適用され、別の本文で同じidを再利用すれば衝突です。

次のラボですること

8つのステップが、1つの実行可能な成果物につながります。署名する原文を保持する → HMACを計算する → 不正な署名を拒否する → 過去と未来のリプレイを制限する → イベントと効果を一緒に保存する準備をする → 記録と売上の間の失敗をロールバックする → 別の接続で合計を読む → 実際のWebhookリクエストを処理する。

各ステップは、関数やファイルが存在するという事実ではなく、実際の戻り値・例外・状態の変化を検査します。正解を見たあとは、わざと境界の比較や後始末のコードを変えて、どの試験が失敗するかを確認してください。前の試験が次のステップでも維持される理由を説明し、このラボが保証しない運用上の条件を1つ書いてみてください。