84
66

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

MDCAdvent Calendar 2019

Day 20

絶対に二重サブミットを許さない友の会

Last updated at Posted at 2019-12-21

MDC Advent Calendar 2019 の20日目です。投稿が21日になってすみません。割腹します。
「MDC」がどういう意味なのかよくわからなかったので、Majide Double-submit-ni Curushinderuの略だと信じてこの記事を書いています。間違ってたら教えてください。
おふざけ枠として「API Gatewayで高輪ゲートウェイ作ってみた」とかやろうとしたんですが、
高輪のことをよく知らなかったのでやめました。二重サブミットの話をします。

二重サブミットとわたし

早速ですが。
人生、誰でも一度は二重サブミットと真剣に向き合う時期があると思います。

二重サブミットはその名の通り二重でsubmitをすることですが
「二重登録」とか「二重更新」とか「二重リクエスト」とかの言葉も概念としては同じです。
<input type="submit">を二連打するとか、POSTのリクエストを二連送信するとかの手段はどうでもよく
本質的には「二重で実行されるとシステムとして困る操作を二重ですること」と考えてよいと思います。

困る操作」は、DB更新・セッション更新・ファイル出力・他システム連携のような別レイヤへはみ出す処理や、
その処理に伴って内部で不整合が発生し、適切な画面表示やデータ返却ができないパターンが多いでしょうか。
うっかり操作で簡単に発生し得るので無対策だと痛い目を見るかもしれません。二重徴収とかね。怖いな〜〜〜

以下ではシンプルなWebアプリで二重サブミットを防止する小手先実装に触れますが
OpenAPIのようないつ何者からどれだけ叩かれるかわからないエンドポイントであれば、
べき等にするとかアーキテクチャで対応するとか、「そもそも困らないように作る」ことが肝要かと思います。
リリース前モンキーテストで発覚して、一日で急遽対応しないといけない時だけ参考にしてください。

許さないために

今回はゴリゴリの新技術の話ではなく、きわめて普通のWebアプリで対策する前提で考えますので
テキストボックスがあって、送信ボタンがあって、サーバで受けてDBに突っ込んで、完了画面を返すような
古式ゆかしい登録フォームなんかを想像して。肩肘張らないで。足なんかも崩して。お願いします。

なお、方針としては「させない」対策と「されても耐える」対策に大別され、
前者はフロント側実装、後者はサーバ側実装が多めになります。

させない

させない方です。

ボタンを押せなくする

disabled.mov.gif

formがボタンのクリックイベント発火を受けてsubmitされるので、
押した瞬間にボタンを非活性にして、二回目のクリックをさせないようにしようという発想です。
わかりやすい。

index.js
$(function() {
  $('button').on('click', function() {
     $(this).prop('disabled', true);
     $('form').submit();
  });
});

見た目にもピンと来やすいですし採用しているサイトも多いです。
スピナー(ローディング中のぐるぐる)をボタンに載せるパターンも見かけますね。
ただ回線状況等々によっては画面遷移に失敗してボタン非活性だけ発動みたいなケースもあり
非活性化という強めの処理を行っているだけあって、考慮ポイントもままあることは注意したいところ。

確認ダイアログを出す

confirm.mov.gif

古式ゆかしすぎる。モダンなサイトでは全く見かけないですね。
window.confirm()というなんかすっごいネイティブな機能を使ったものです。
https://developer.mozilla.org/ja/docs/Web/API/Window/confirm

index.js
$(function() {
  $('button').on('click', function() {
	var confirmMessage = '登録しますか?';
    if (window.confirm(confirmMessage)) {
		// OK押下時の挙動
    } else {
		// キャンセル押下時の挙動
    }
  });
});

ボタンを押してもform.submit()は呼ばれず、確認ダイアログが表示されるだけなので
html上のボタン連打をしてもほとんど副作用がないのが嬉しいと思います。
なおかつ確認ダイアログはブラウザ機能で表示しているものなので、
開発者ツールなどで挙動を変えられることがなく、防御としては堅めな気がしています。
ただいかんせん古臭いですし、ワンクリック増えるのでUXとしては一段落ちるかもしれません。

PRGパターンを使う

prg.mov.gif

(※フォームの文字列が消えてるのは消してるんじゃなくリダイレクトで再描画されてるからです!!!)

Post - Redirect - Getの略でPRGです。こちらはサーバ側実装です。
こんなんたまに出ますよね。
スクリーンショット 2019-12-20 23.57.44.png
REST周りの話はそれだけで記事一本分になりそうなので割愛しますが、
POSTリクエストに対して返却された画面でリロードを行うと、
直前のPOSTリクエストが再度サーバに対して送信されてしまいます(フォーム再送信)。

そのため、POSTリクエストを受け付けた後にリダイレクトを行って
GETリクエストに対して画面を返却するようにします。
そうすることでリロードはGETリクエストに対してのみ行われ、POSTリクエストは再送信されなくなります。
(GIF画像でも、POSTの/createに飛んだ後にGETの/indexに転送されているのがわかるかと思います)

サーバでPOSTリクエストを受け取って、リダイレクトして、画面を返却して・・・という順序なので
最初のPOSTリクエストを二連打された日にゃ何もできませんが、後のリロードは防げます。そういうものです。

されても耐える

されても耐えます。男の子だから・・・

セッションと画面にトークンを格納する

ここら辺から急にじゃばじゃばしてきます。Spring使います。
登録画面をクライアントに返却する際に、セッションと画面に同じ値を格納しておき、
画面に載せた値はsubmitで送信させ、
POSTリクエストを受け取った直後、二つの値を比較してリクエストの真正性を確認する方法です。
確認して正しいと判断した場合はトークンを削除or上書きし、2回目以降のリクエストが来ても弾くようにします。

Controller.java
    @GetMapping("/index")
    public String index(Model model, SessionDto session, UserForm userForm) {
        String randomStr = RandomString.make(10);
        session.setToken(randomStr);
        userForm.setToken(randomStr);
        // 後略
    }

    @PostMapping("/create")
    public String create(Model model, SessionDto session, @ModelAttribute UserForm userForm) {
        if (!session.getToken().equals(userForm.getToken())) {
            return "/error";
        }
        session.setToken("");
        // 後略
    }

CSRF対策と発想は似ていますが、同一人物でも2回目以降は弾くという点では異なります。
ちなみにみんな大好きSpring SecurityのCSRFトークンは同セッション中は不変です(たぶん)。
なので戻る遷移から再送信されても弾けず、二重サブミット対策にはなりません。

ちなみにこちらの方法ですが・・・
検証中にjsのform.submit()複数回実行で動作確認していたところ
Controllerのメソッド内で画面を返却するまでは、セッションに対するsetが反映されないような雰囲気がありました。
(あくまで雰囲気なので詳しい方補足ください)

要するに、
リクエスト1 : セッションと画面からトークンを取得し比較 -> "token1"が返ってくる
リクエスト1 : セッションのトークンを"token2"で上書き -> この時点でセッション内トークンは"token2"のはず
リクエスト2 : セッションと画面からトークンを取得し比較 -> なぜかここも"token1"が返ってくる
という挙動です。
この時はRedisにセッションを格納していたので、Springboot+Redis特有のやつ?
SpringbootからRedisに書き込まれるタイミングの問題? とか思ってたんですが・・・

トークンをRedisに直接格納する

Springの挙動がよくわからないのでRedisに直接詰めることにしました。
この方法ならredisTemplateを呼び出したタイミングで確実にredisへのアクセスが行われ
データの読み取り・書き込みが即時実行されます。
プロシュート兄貴も「『直』は素早いんだぜ」って言ってたし・・・

    @Autowired
    private StringRedisTemplate redisTemplate;

    @GetMapping("/index")
    public String index(Model model, SessionDto session, UserForm userForm) {
        String userUniqueKey = session.getUserId() + session.getUserName();
        String randomStr = RandomString.make(10);
        redisTemplate.opsForValue().set(userUniqueKey, randomStr);
        // 後略
    }

    @PostMapping("/create")
    public String create(Model model, SessionDto session, @ModelAttribute UserForm userForm) {
        String userUniqueKey = session.getUserId() + session.getUserName();
        String value = redisTemplate.opsForValue().get(userUniqueKey);
        if (value == null) {
            return "/error";
        }
        redisTemplate.delete(userUniqueKey);
        // 後略
    }

Spring sessionを無視して自分でRedisアクセスを行うことになります。
Springboot+Redisの構成の場合、Controllerのメソッド引数に値を適当に詰めたりして持ち回していれば
特に意識せずとも、Springbootが発行したSessionIdをキーにRedisへのデータ登録が行われます。

ですが直アクセスなので、そのユーザのセッションを一意に特定するようなキーを自分で設定せねばなりません。
DB的にprimaryな値がよいかと思いますが、ここは防ぎたいパターンに応じてチューンしてください。

DBのレコードの存在チェックをする

前項の方法は、「セッションを使って防ぐ」というよりは「インメモリデータベースを使って防ぐ」といった感じでした。
インメモリデータベースで防げるならオンディスクデータベースでも防げそうです。
(そもそもインメモリデータベースを採用していないケースも多いと思いますし)

こちらの場合は「排他テーブル」を作成しておき、以下のような流れで処理することになります。

(1) POSTリクエストを受ける
(2) ユーザを特定する一意な情報で排他テーブルを検索する
(3) 既にレコードが存在した場合はエラーに流す
(4) レコードが存在しない場合はレコードを新規に作成する
(5) 申込内容のDB登録やら他システム連携やらをする
(6) 全ての業務処理が完了した後、排他テーブルのレコードを削除する

トランザクション境界の設定や、(5)でエラーとなった場合の排他レコードの扱いなど
綿密な設計をしないと必要以上にロックが掛かりかねないので、難易度や影響範囲は上がります。

ちなみに、わざわざ排他テーブルを新設せずとも
POSTリクエストを受けてDBへの登録が走るシステムなのであれば、
POSTリクエストを受けた直後に、DBに既に登録があるかをチェックしにいき
問題ない場合だけDBへ申込内容のinsertを行う、という処理順序にすることで
同様の対策を行うことができます。

ただし、POSTリクエストを契機に他システムへの連携等を行って、実行結果を最終的にDBに詰めたい等で

(1) POSTリクエストを受ける
(2) 他システム連携を行う
(3) 申込内容と連携結果のDB登録を行う

という流れになっている場合は、

(1) POSTリクエストを受ける
(2) 申込内容のDB登録を行う
(3) 他システム連携を行う
(4) 申込内容レコードを、他システム連携結果で更新する

「先に空のハコを作っておいて二重登録を防ぎ、後から必要な情報を更新する」という流れにせざるを得なくなります。
こちらは排他テーブルの場合と同様、(3)でエラーとなったときの扱いを密に設計する必要があります。

DBの力を借りる

前項で「排他」という言葉を使いましたが、排他に関してのプロフェッショナルは言わずもがなDBです。
悲観ロックとか楽観ロックとか監獄ロックとかいろいろありますが、
基本思想レベルで真剣に二重登録と向き合っている世界なので、せっかくならそこに相乗りしましょう。楽ちん

select4update.mov.gif

今回はmysqlのselect for updateを使いました。
詳細な挙動は他で語り尽くされているので割愛しますが、
端的に言うと「commitするまで他のプロセスからのクエリ発行を待機させる」ものです。
(for updateが付随しない単なるselectであれば一応通りますが、古いデータが返ります)

この機能を利用して、POSTリクエストを受けた直後に排他を掛け始めて
全ての処理が完了したときにcommit(もしくはDB更新で自然体でcommit)する、という流れになります。

こちらのやり方はとにかく確実です。
長年積み重ねられてきた排他制御の叡智にあやかるのでとにかく守られます。

前項でも触れたロックかかりすぎちゃう問題はこちらにもありますが、
mysqlの行ロックはデフォルト50秒、設定によってセッションごとのロック時間変更もできるようなので
一度しくじったら一生排他、データ修正するまで触れませんゲームクリアさようなら〜ということにはならなさそうです。
https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout

mysql
mysql> set innodb_lock_wait_timeout = 30;

ちなみにOracleはクエリごとに設定できるって。

oracle
SQL> SELECT col FROM table WHERE col = 1 FOR UPDATE OF col WAIT 10 ;

なお他の方法とは異なり、二重サブミットされていることをアプリケーションレイヤで検知するのではなく
アプリケーションがおバカで二重サブミットを許容したとしても、DB側で弾くという方法なので
「二重サブミットされたらこういうハンドリングをしたい!」というニーズがある場合はもう一工夫必要になります。

2回目のリクエストはサーバまでは正常に疎通できるものの、GIF画像の通りDB接続で待たされるような挙動となるので
Java的に言うとSQLTimeoutExceptionあたりがthrowされます。
排他による想定通りのタイムアウトなのか、スロークエリやDB側不調によるタイムアウトなのか判別できないため
二重サブミットの防止はできても、二重サブミットの検知は他と比べると難しくなります。

まとめ

「処理を禁止する」という強い制御をかける話なので、どれも多少なりとも副作用があります。
またユーザビリティや処理難易度、アーキテクチャ等を考えると、どれか一案だけ採用して終わりということもないかなと思います。
この世から二重サブミットがなくなる日まで戦い続けるので、他にいい方法があったらぜひ教えてください。
あと記事投稿遅れまして誠に申し訳ございませんでした。。。。。。。。

参考

84
66
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
84
66

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?