SpringBootで戻るボタン押下後の二重送信を防止する

なんとか自己解決したが、SpringBootでbuild.gradleを用いた場合に再現できる情報がWebで見当たらなかったので、似たようなことで困った方がいれば参考にしていただきたいと思う。
(※この記事はSpringBootでRequestMappingやGetMappingでの画面遷移を行うための実装について、事前知識がある前提で進めます。)

# 環境
* SpringBoot ver.2.6.7
* Java 17
* build.gradleを用いたプロジェクト

# 参考サイト
http://terasolunaorg.github.io/guideline/5.3.1.RELEASE/ja/ArchitectureInDetail/WebApplicationDetail/DoubleSubmitProtection.html#transactiontokencheck

https://fintan-contents.github.io/spring-crib-notes/latest/html/web/double-submission/index.html

# 経緯
* データベースへの更新を行うクエリを実行後、ユーザーがブラウザの戻るボタンを押下して再度そのクエリを送信してしまう場合があった。
* 一度実行したクエリを再送信してしまうと、データが予期しないものに書き換わってしまったため、戻るボタン押下後に同じクエリを送信する行為を制限したいと考えた。

# 実現方法
調べてみると、どうやらTransactionTokenCheckというライブラリを用いると良いらしい。

TransactionToKenCheckのアノテーションの概要↓

参考サイト：http://terasolunaorg.github.io/guideline/5.3.1.RELEASE/ja/ArchitectureInDetail/WebApplicationDetail/DoubleSubmitProtection.html#transactiontokencheck から引用
> 画面遷移毎にトークン値を払い出し、ブラウザから送信されたトークン値とサーバ上で保持しているトークン値を比較することで、トランザクション内で不正な画面操作が行われないようにする。
トランザクショントークンチェックを適用することで、ブラウザの戻るボタンを使ってページを移動した後の更新処理の再実行を防ぐことが出来る。
また、トークン値のチェックを行った後にサーバで管理しているトークン値を破棄することで、サーバ側の処理として二重送信を防ぐことも出来る。
(1)
購買者が、商品購入画面で注文ボタンをクリックする。
サーバ上で保持しているトランザクショントークンと、クライアントから送信されたトランザクショントークンが一致するため、商品を購入する処理を実行する。
このタイミングで、サーバ上で保持していたトランザクショントークの値が破棄され、新しいトークン値に更新される。
(2)
サーバは、(1)のリクエストで受けた商品の購入処理をDBに対して反映する。
(3)
サーバは、(1)のリクエストで受けた商品の購入完了画面を応答する。
(4)
購買者が、ブラウザの戻るボタンを使って購入画面を再度表示する。
(5)
購買者が、ブラウザの戻るボタンを使って表示した購入画面で注文ボタンを再度クリックする。
クライアントから送信されたトランザクショントークンは既に破棄された値のため、トランザクショントークンエラーとなる。
(6)
サーバは、トランザクショントークンエラーが発生した事を通知するエラー画面を応答する。

# 実装
TransactionTokenCheckアノテーションはSpringBootプロジェクトにもともと入っているライブラリではないため、
build.gradle内にTransactionTokenCheckアノテーションを使うためのライブラリを追加する。
```build.gradle
dependencies {
	compileOnly 'jp.fintan.keel:keel-spring-boot-starter-web:1.0.0'
}
```

詳細内容はコメントに記載
```controller.java
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import jp.fintan.keel.spring.web.token.transaction.TransactionTokenCheck;
import jp.fintan.keel.spring.web.token.transaction.TransactionTokenType;

@Controller
// 画面遷移を行うControllerにTransactionTokenCheckアノテーションを付与し、任意の名前を与える。
@TransactionTokenCheck("transactionTokenExample")
public class PageTransitionController {

	@GetMapping("/")
	//「クエリを実行する前」の画面を呼び出す処理でTokenを発行する。
	// type = TransactionTokenType.BEGIN を書くことで、Tokenの発行が行われる。
	@TransactionTokenCheck(type = TransactionTokenType.BEGIN)
	public String index(){
		return "index";
	}
	
	// index.htmlからPOSTリクエストが送られ、queryメソッドが呼び出される。
	// type = TransactionTokenType.IN を書くことで、Tokenの照合を実施。
	//「クエリを実行する」処理のタイミングでTokenの照合チェック→更新を行う。
		// Tokenが一致していればメソッドが呼び出され、メソッドの実行後にTokenが書き換わる。
		// Tokenが一致していなければエラーを吐き、メソッドは呼び出されない。
		// @TransactionTokenCheck(type = TransactionTokenType.IN)は@TransactionTokenCheckでも代用できる。
	@TransactionTokenCheck(type = TransactionTokenType.IN)
	@PostMapping("/query")
	public String query(){
		// hogeクエリ実行
		// queryComplete画面にredirectしている理由は、リロードした場合にリクエストが二重で送られないようにするため。
		return "redirect:/queryComplete";
	}
	
	@GetMapping("/queryComplete")
	public String queryComplete(){
		return "queryComplete";
	}

}
```

# 流れ
### 正規のパターン
① クエリを実行する直前の画面でTokenを発行し、リクエストを行う側とサーバー側両方にTokenを保持させる。
② クエリ実行のメソッドが呼ばれる直前にリクエスト側とサーバー側のTokenの一致を確認。サーバー側のTokenを別の値に書き換える。
③ クエリが実行される。
④ クエリ完了画面に遷移する。

### 戻るボタン押下時のパターン
⑤ クエリ完了画面で戻るボタンを押下する。
⑥ クエリ実行を行う前の画面に戻る。（Tokenは発行されず、リクエスト側は②のTokenのままになっている。）
⑦ クエリ実行のメソッドが呼ばれる直前にリクエスト側とサーバー側のTokenの一致を確認。今回はリクエスト側とサーバー側のTokenが異なるため、メソッドは実行されない。
⑧ Invalid Transaction Token Exceptionが発生。エラー画面に遷移する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2322421/ba72e722-bf7a-bda8-d552-3e47e7281e9c.png)