SpringBoot | 登録後更新処理による二重送信防止のメモ

2022.08.03

二重送信防止の方法についてトークン管理を行うことでSpring側で防止してみます。ただし色々と端折っているのであくまで考え方のみ。参考書籍を参考に簡単バージョンで実装しました。

登録処理完了後に更新ボタンを押した際の二重送信を防止します。

完全版や詳しい内容につきましては参考書籍かそのgithubをご参照ください。

参考

  • 現場至上主義 Spring Boot2 徹底活用
  • https://spring-boot-doma2-sample.readthedocs.io/ja/master/double-submit-check.html

また依存モジュールとしてSpringSecurityを導入しておきます→ csrfと共存させるため。

セッションの利用

セッションに対してトークンの読み書きを行えるクラスを作成します。

import java.util.UUID;

import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class SessionSystem {
	
	private static HttpSession httpSession;
	
	@Autowired
	public void setHttpSession(HttpSession httpSession) {
		SessionSystem.httpSession = httpSession;
	}
	
	public static String getToken(String key) {
		return (String) SessionSystem.httpSession.getAttribute(key);
	}
	
	public static void setToken(String key, String value) {
		SessionSystem.httpSession.setAttribute(key, value);
	}
	
	public static String generateToken() {
		return String.valueOf(UUID.randomUUID());
	}
	
	/**
	 * トークンを再生成してセッションに保存します
	 * 
	 * @param key
	 */
	public static void regenerateToken(String key) {
		SessionSystem.setToken(key, generateToken());
	}

}

トークンの埋め込み

参考にあるように画面に対して自動的に埋め込む必要があるので、RequestDataValueProcessorを利用します。

RequestDataValueProcessorの実装クラスを作成して必要な処理をオーバーライドします

import java.util.Map;

import javax.servlet.http.HttpServletRequest;

import org.springframework.security.web.servlet.support.csrf.CsrfRequestDataValueProcessor;
import org.springframework.web.servlet.support.RequestDataValueProcessor;

public class DoubleSubmitReqeustDataValueProcessor implements RequestDataValueProcessor{

	private final CsrfRequestDataValueProcessor processor = new CsrfRequestDataValueProcessor();
	
	public DoubleSubmitReqeustDataValueProcessor() {
	
	}

	@Override
	public String processAction(HttpServletRequest request, String action, String httpMethod) {
		return processor.processAction(request, action, httpMethod);
	}

	@Override
	public String processFormFieldValue(HttpServletRequest request, String name, String value, String type) {
		return processor.processFormFieldValue(request, name, value, type);
	}

	@Override
	public Map<String, String> getExtraHiddenFields(HttpServletRequest request) {
		// csrfとの共存を行う
		Map<String, String> map = processor.getExtraHiddenFields(request);
		if(!map.isEmpty()) {
			String token = SessionSystem.getToken("token");
			// トークンが取得できない場合は生成する
			if(token == null) {
				// ここの処理は一つにしてしまうのが良い
				token = SessionSystem.generateToken();
				// セッションにトークンを設定する
				SessionSystem.setToken("token", token);
			}
			map.put("token", token);
		}
		return map;
	}

	@Override
	public String processUrl(HttpServletRequest request, String url) {
		return processor.processUrl(request, url);
	}

}

次にこれをBean登録する設定クラスを作成します。必ずrequestDataValueProcessorという名前で登録する必要があるそうです

import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.support.RequestDataValueProcessor;

@Configuration
@AutoConfigureAfter(SecurityAutoConfiguration.class)
public class ReqeustConfig {

	@Bean
	public RequestDataValueProcessor requestDataValueProcessor() {
		return new DoubleSubmitReqeustDataValueProcessor();
	}
}

  • 以下参考記事に記載の通りです
    • Spring Boot上でCsrfRequestDataValueProcessorと独自RequestDataValueProcessorを共存させる方法

またspring.factoriesに上記の設定ファイルを識別させるためにEnableAutoConfigurationに作成クラスを紐づけます

# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.volkruss.uzuisama.ReqeustConfig

またapplication.yamlも修正します

spring: 
  main:
    allow-circular-references: true
    allow-bean-definition-overriding: true

SpringSecurityを利用している場合はCSRFトークン生成処理と共存させる必要があるので上記のように記載します。参考

  • https://github.com/miyabayt/spring-boot-doma2-sample/blob/e5dd9cd08a64c7f95ffe6e3bd50ab18f05f5ff58/sample-web-base/src/main/java/com/sample/web/base/RequestDataValueProcessorAutoConfiguration.java
  • https://github.com/miyabayt/spring-boot-doma2-sample/blob/e5dd9cd08a64c7f95ffe6e3bd50ab18f05f5ff58/sample-web-base/src/main/java/com/sample/web/base/security/DoubleSubmitCheckingRequestDataValueProcessor.java#L10

この時点でhtmlを表示してトークンが自動設定されているかどうかを確認できます

画像

インターセプターの実装

Tokenホルダーの準備

public class DoubleSubmitTokenHolder {
	
	private static final ThreadLocal<String> expected = new ThreadLocal<>();
	
	private static final ThreadLocal<String> actual = new ThreadLocal<>();
	
	public static void set(String exp, String act) {
		expected.set(exp);
		actual.set(act);
	}
	
	public static String getExpectedToken() {
		return expected.get();
	}
	
	public static String getActualToken() {
		return actual.get();
	}
	
	public static void clear() {
		expected.remove();
		actual.remove();
	}

}

コントローラーの処理が実行される前後に処理を挟み込むことができるインターセプターを使ってTokenの保持とチェックを行います

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

@Component
public class SetDoubleSubmitCheckTokenInterceptor implements HandlerInterceptor{
	
    // コントローラーの動作前
    @Override
    public boolean preHandle (HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    	// リクエストのtokenを取得する
   		String exp = request.getParameter("token");
    	// セッションのtokenを取得する
		String act = SessionSystem.getToken("token");
    	// Tokenをホルダーに保持しておく
    	DoubleSubmitTokenHolder.set(exp, act);
    	
        return true;
    }

    // コントローラーの操作後
    @Override
    public void postHandle (HttpServletRequest request, HttpServletResponse response, Object handler,
                            @Nullable ModelAndView modelAndView) throws Exception {
    	// postメソッドの時に動作させる
    	if(request.getMethod().equals("POST")) {
    		// tokenのチェックを行う
    		String exp = request.getParameter("token");
    		String act = SessionSystem.getToken("token");
    		if(exp != null && act != null && exp.equals(act)) {
    			// 画面とセッションで同一の場合はセッションのトークンを再生成する
    			SessionSystem.regenerateToken("token");
    		}
    	}
    }
}
  • この辺は参考書籍だと非推奨のクラスを継承させる古いやりかたになっているので、修正しています

関連記事

リンク

インターセプターを利用するための設定クラスを作成します。

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Component
public class MvcConfig implements WebMvcConfigurer{
	
	@Autowired
	private SetDoubleSubmitCheckTokenInterceptor interceptor;
	
	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(interceptor)
			.addPathPatterns("/**");
	}
	
}

これで準備OKです。

2重送信をチェックする

適当なコントローラークラスを作成して、送信時に2重送信チェックを行っています

import javax.servlet.http.HttpServletRequest;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class SendController {
	
	
	@GetMapping("/newpost")
	public String view() {
		return "testpost";
	}
	
	@PostMapping("/store")
	public String store(HttpServletRequest request) {
		// ここで画面とセッションの整合性をチェックする(本来はコントローラーではやらないでください)
		// コントローラー発火前にインターセプターによってホルダークラスにそれぞれのTokenが格納されている
		String exp = DoubleSubmitTokenHolder.getExpectedToken();
		String act = DoubleSubmitTokenHolder.getActualToken();
		if(exp != null && act != null && !exp.equals(act)) {
			System.out.println("トークンが違います");
			// ここで例外処理などする
			throw new RuntimeException();
		}
		System.out.println("処理完了");
			
		return "done";
	}

}

実行して確認してみます

  • 普通にPost送信を行う

画像

  • 送信後に画面の更新を行う

画像

ログが出力されている

画像

本ではもっと軽量なトークンの生成方法を利用していたりします。今回は端折って実装したのですが、大まかな実装方法の確認が行えました。