Wednesday, 10 December 2014

Spring MVC : Preventing Duplicate Form Submission without Spring Security



Spring MVC so far has no out of box solution to prevent duplicate form submission yet , while below are the possible solutions :

Option 1 , javascript : disable submit button 


Option 2 , Post-Redirect-Get pattern : send a redirect after submit 


Option 3 , tokening : unique token between client and server 


Both option 1 and option 2 have drawbacks , let's see how to implement option 3.


 


Step 1 : token handler

This class is used to generate a unique token and save in the cache :


package org.junjun.util.spring.token;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.Cache;
import org.springframework.stereotype.Component;
/**
* This is a helper class used for HTTP request token generation and save in the
* cache.
*
* @author junjun
* @since 28 NOV 2014
*
*/
@Component
public class TokenHandler {
@Autowired
private org.springframework.cache.CacheManager cacheManager;
public String generate() {
Cache cache = cacheManager.getCache("tokens");
String token = UUID.randomUUID().toString();
cache.put(token, token);
return token;
}
}







Step 2 : token tag lib

To use "TokenHandler" with jstl below is the tag lib class :create spring-token.tld as below and put under src/main/resources/META-INF/spring-token.tld

<?xml version="1.0" encoding="UTF-8"?>
<taglib xmlns="http://java.sun.com/xml/ns/j2ee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-jsptaglibrary_2_0.xsd"
version="2.0">
<description>Spring MC Util</description>
<tlib-version>4.0</tlib-version>
<short-name>token</short-name>
<uri>http://www.junjun-dachi.org/spring/mvc/token</uri>
<tag>
<name>token</name>
<tag-class>org.junjun.util.spring.token.tags.TokenTag</tag-class>
<body-content>empty</body-content>
</tag>
</taglib>






Step 3 : check token annotation and interceptor

This annotation can be put on any method in "Controller" for the request that needs token validation.
package org.junjun.util.spring.token.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* This class is used as annotation for http requests that needed to have a
* token.
*
* parameter : remove if it is true then remove from cache.
*
* @author junjun
*
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckToken {
boolean remove() default true;
}
Define a Spring interceptor for token validation purpose :

package org.junjun.util.spring.token.annotations;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.Cache;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
/**
* This class is used as an interceptor for token validation. If token is not
* valid set error to http request.
*
* @author junjun
*
*/
@Component("tokenInterceptor")
public class TokenInterceptor extends HandlerInterceptorAdapter {
private static final Logger logger = Logger.getLogger(TokenInterceptor.class);
@Autowired
private org.springframework.cache.CacheManager cacheManager;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
boolean valid = true;
HandlerMethod method = (HandlerMethod) handler;
CheckToken annotation = method.getMethodAnnotation(CheckToken.class);
synchronized (this) {
if (annotation != null) {
String token = request.getParameter("token");
logger.info("token in the request [" + token + "].");
Cache cache = cacheManager.getCache("tokens");
if (StringUtils.isEmpty(token)) {
valid = false;
logger.info("token not found in the request.");
} else if (cache.get(token) == null) {
valid = false;
logger.info("token not exist in the cache");
} else {
if (annotation.remove()) {
cache.evict(token);
}
}
if (!valid) {
logger.info("token is not valid , set error to request");
request.setAttribute("error", "invalid token");
response.sendRedirect(request.getContextPath() + "/error.htm");
}
}
}
return valid;
}
}





 


Step 4 : generate token on JSP

Just put <junjun:token/> under a <form> tag as below:



<%@taglib prefix="junjun"
uri="http://www.junjun-dachi.org/spring/mvc/token"%>
<div id="div-msg" width="100%"
style="font-family: Verdana, Geneva, sans-serif; font-size: 30px; text-align: center; padding-top: 100px;"></div>
<form id="form-test"
action="${pageContext.request.contextPath}/test.htm" method="post">
<junjun:token />
</form>
<script
src="https://code.jquery.com/jquery-3.2.1.js"
integrity="sha256-DZAnKJ/6XZ9si04Hgrsxu/8s717jcIzLy3oi35EouyE="
crossorigin="anonymous"></script>
<script>
$(document).ready(function() {
var token = $('#token').val();
$('#div-msg').append('<p>System has generated token :' + token + '.</p>');
$('#div-msg').append('<p> System is going to submit form twice with AJAX call.</p>');
$('#div-msg').append('<p> And one of the requests should fail.</p>');
var url = ${pageContext.request.contextPath} + '/test.htm';
$.ajax({
type: 'POST',
url: 'test.htm',
data: $('#form-test').serialize(),
success: function(data)
{
console.log(data);
$('#div-msg').append('<p> Response from 1st request ' + data + '.</p>');
},
error: function(msg){
console.log(msg);
}
});
$.ajax({
type: 'POST',
url: 'test.htm',
data: $('#form-test').serialize(),
success: function(data)
{
console.log(data);
$('#div-msg').append('<p> Respones from 2nd request ' + data + '.</p>');
},
error: function(msg){
console.log(msg);
}
});
});
</script>
view raw index.jsp hosted with ❤ by GitHub




 


Step 5 : Spring configuration for interceptor and cache manager
package org.junjun.util.spring.config;
import org.junjun.util.spring.token.annotations.TokenInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@EnableWebMvc
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {
@Bean
public TokenInterceptor getTokenInterceptor() {
return new TokenInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(getTokenInterceptor());
}
}
view raw WebConfig.java hosted with ❤ by GitHub


package org.junjun.util.spring.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCache;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.interceptor.CacheResolver;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.cache.interceptor.SimpleCacheErrorHandler;
import org.springframework.cache.interceptor.SimpleCacheResolver;
import org.springframework.cache.interceptor.SimpleKeyGenerator;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CachingConfig implements CachingConfigurer {
@Bean
@Override
public CacheManager cacheManager() {
SimpleCacheManager cacheManager = new SimpleCacheManager();
List<Cache> caches = new ArrayList<Cache>();
caches.add(new ConcurrentMapCache("tokens"));
cacheManager.setCaches(caches);
return cacheManager;
}
@Override
public CacheResolver cacheResolver() {
return new SimpleCacheResolver();
}
@Override
public KeyGenerator keyGenerator() {
return new SimpleKeyGenerator();
}
@Override
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler();
}
}







Step 6 : use @CheckToken annotation

 Whenever token is needed for request validation , token could be include in the page by , and @CheckToken can used to check if the token is valid :
package org.junjun.util.spring.controller;
import org.apache.log4j.Logger;
import org.junjun.util.spring.token.annotations.CheckToken;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.context.request.WebRequest;
@Controller
public class TestController {
private static final Logger log = Logger.getLogger(TestController.class);
@CheckToken
@RequestMapping(value = "/test.htm", method = RequestMethod.POST)
@ResponseBody
public String test(WebRequest request) {
String token = (String) request.getParameter("token");
log.info(token);
return token;
}
@RequestMapping(value = "/error.htm", method = RequestMethod.GET)
@ResponseBody
public String error(WebRequest request) {
return "error";
}
}


 


Step 7 : Verify


Run org.junjun.util.spring.AppLauncher , and open browser http://localhost:6060 , one of the requests should failed.


 


NOTE : 

1. source code of the project could be found here : 

Web App : https://github.com/junjun-dachi/spring-util/tree/master/prevent-duplicate-form-submission-web

Token : https://github.com/junjun-dachi/spring-util/tree/master/prevent-duplicate-form-submission-token-taglib

2. this solution does not support application that runs in distributed environment , please use and refer to ehcache document for cluster cache manager configuration 


3. Spring Security has one solution for CSRF we will see later





4 comments:

  1. This comment has been removed by the author.

    ReplyDelete
    Replies
    1. sorry only see your message today , please refer to the github source , thank you.

      Delete
  2. is it for double click or refresh issue(this code)

    ReplyDelete
  3. you defined org.junjun.util.spring.token.tags.TokenTag in tld file but where is TokenTag class file?

    ReplyDelete

Flag Counter