1.背景与介绍:
平时开发的项目中可能会出现下面这些情况:
由于用户误操作,多次点击表单提交按钮。
由于网速等原因造成页面卡顿,用户重复刷新提交页面。
黑客或恶意用户使用postman等工具重复恶意提交表单(攻击网站)。
这些情况都会导致表单重复提交,造成数据重复,增加服务器负载,严重甚至会造成服务器宕机。因此有效防止表单重复提交有一定的必要性。
2.解决方案
2.1 通过JavaScript屏蔽提交按钮(不推荐)
通过js代码,当用户点击提交按钮后,屏蔽提交按钮使用户无法点击提交按钮或点击无效,从而实现防止表单重复提交。
ps:js代码很容易被绕过。比如用户通过刷新页面方式,或使用postman等工具绕过前段页面仍能重复提交表单。因此不推荐此方法。
<%@ page language="java" import="java.util.*" pageEncoding="UTF-8"%> | |
<html> | |
<head> | |
<title>表单</title> | |
<script type="text/javascript"> | |
//默认提交状态为false | |
var commitStatus = false; | |
function dosubmit(){ | |
if(commitStatus==false){ | |
//提交表单后,讲提交状态改为true | |
commitStatus = true; | |
return true; | |
}else{ | |
return false; | |
} | |
} | |
</script> | |
</head> | |
<body> | |
<form action="/path/post" onsubmit="return dosubmit()" method="post"> | |
用户名:<input type="text" name="username"> | |
<input type="submit" value="提交" id="submit"> | |
</form> | |
</body> | |
</html> |
2.2 给数据库增加唯一键约束(简单粗暴)
在数据库建表的时候在ID字段添加主键约束,用户名、邮箱、电话等字段加唯一性约束。确保数据库只可以添加一条数据。
数据库加唯一性约束sql:
alter table tableName_xxx add unique key uniq_xxx(field1, field2)
服务器及时捕捉插入数据异常:
try { | |
xxxMapper.insert(user); | |
} catch (DuplicateKeyException e) { | |
logger.error("user already exist"); | |
} |
通过数据库加唯一键约束能有效避免数据库重复插入相同数据。但无法阻止恶意用户重复提交表单(攻击网站),服务器大量执行sql插入语句,增加服务器和数据库负荷。
2.3 利用Session防止表单重复提交(推荐)
实现原理:
服务器返回表单页面时,会先生成一个subToken保存于session,并把该subToen传给表单页面。当表单提交时会带上subToken,服务器拦截器Interceptor会拦截该请求,拦截器判断session保存的subToken和表单提交subToken是否一致。若不一致或session的subToken为空或表单未携带subToken则不通过。
首次提交表单时session的subToken与表单携带的subToken一致走正常流程,然后拦截器内会删除session保存的subToken。当再次提交表单时由于session的subToken为空则不通过。从而实现了防止表单重复提交。
使用:
mvc配置文件加入拦截器配置
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**"/>
<bean class="xxx.xxx.interceptor.AvoidDuplicateSubmissionInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
拦截器
package xxx.xxxx.interceptor; | |
import xxx.xxx.SubToken; | |
import xxx.xxx.utils.Constants; | |
import org.apache.struts.util.TokenProcessor; | |
import org.springframework.web.method.HandlerMethod; | |
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; | |
import javax.servlet.http.HttpServletRequest; | |
import javax.servlet.http.HttpServletResponse; | |
import java.lang.reflect.Method; | |
public class AvoidDuplicateSubmissionInterceptor extends | |
HandlerInterceptorAdapter { | |
public AvoidDuplicateSubmissionInterceptor() {} | |
public boolean preHandle(HttpServletRequest request, | |
HttpServletResponse response, Object handler) throws Exception { | |
if (handler instanceof HandlerMethod) { | |
HandlerMethod handlerMethod = (HandlerMethod) handler; | |
Method method = handlerMethod.getMethod(); | |
SubToken annotation = method.getAnnotation(SubToken.class); | |
if (annotation != null) { | |
boolean needSaveSession = annotation.saveToken(); | |
if (needSaveSession) { | |
request.getSession(false) | |
.setAttribute( | |
"subToken", | |
TokenProcessor.getInstance().generateToken(request)); | |
} | |
boolean needRemoveSession = annotation.removeToken(); | |
if (needRemoveSession) { | |
if (isRepeatSubmit(request)) { | |
return false; | |
} | |
request.getSession(false).removeAttribute("subToken"); | |
} | |
} | |
} | |
return true; | |
} | |
private boolean isRepeatSubmit(HttpServletRequest request) { | |
String serverToken = (String) request.getSession(false).getAttribute("subToken"); | |
if (serverToken == null) { | |
return true; | |
} | |
String clinetToken = request.getParameter("subToken"); | |
if (clinetToken == null) { | |
return true; | |
} | |
if (!serverToken.equals(clinetToken)) { | |
return true; | |
} | |
return false; | |
} | |
} | |
控制层 controller | |
//开启一个Token | |
public String form() { | |
return "/test/form"; | |
} | |
//开启Token验证,并且成功之后移除当前Token | |
public String postForm(String userName) { | |
System.out.println(System.currentTimeMillis()); | |
try{ | |
System.out.println(userName); | |
Thread.sleep(1500);//暂停1.5秒后程序继续执行 | |
}catch (InterruptedException e) { | |
e.printStackTrace(); | |
} | |
System.out.println(System.currentTimeMillis()); | |
return "1"; | |
} |
表单页面
<%@ page contentType="text/html;charset=UTF-8" language="java" %> | |
<html> | |
<head> | |
<title>Title</title> | |
</head> | |
<body> | |
<form method="post" action="/postForm"> | |
<input type="text" name="userName"> | |
<input type="hidden" name="subToken" value="${subToken}"> | |
<input type="submit" value="提交"> | |
</form> | |
</body> | |
</html> |
2.4使用AOP自定义切入实现
实现原理:
自定义防止重复提交标记(@AvoidRepeatableCommit)。
对需要防止重复提交的Congtroller里的mapping方法加上该注解。
新增Aspect切入点,为@AvoidRepeatableCommit加入切入点。
每次提交表单时,Aspect都会保存当前key到reids(须设置过期时间)。
重复提交时Aspect会判断当前redis是否有该key,若有则拦截。
自定义标签
import java.lang.annotation.*; | |
/** | |
* 避免重复提交 | |
* @author hhz | |
* @version | |
* @since | |
*/ | |
public AvoidRepeatableCommit { | |
/** | |
* 指定时间内不可重复提交,单位毫秒 | |
* @return | |
*/ | |
long timeout() default 30000 ; | |
} | |
自定义切入点Aspect | |
/** | |
* 重复提交aop | |
* @author hhz | |
* @version | |
* @since | |
*/ | |
public class AvoidRepeatableCommitAspect { | |
private RedisTemplate redisTemplate; | |
/** | |
* @param point | |
*/ | |
public Object around(ProceedingJoinPoint point) throws Throwable { | |
HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest(); | |
String ip = IPUtil.getIP(request); | |
//获取注解 | |
MethodSignature signature = (MethodSignature) point.getSignature(); | |
Method method = signature.getMethod(); | |
//目标类、方法 | |
String className = method.getDeclaringClass().getName(); | |
String name = method.getName(); | |
String ipKey = String.format("%s#%s",className,name); | |
int hashCode = Math.abs(ipKey.hashCode()); | |
String key = String.format("%s_%d",ip,hashCode); | |
log.info("ipKey={},hashCode={},key={}",ipKey,hashCode,key); | |
AvoidRepeatableCommit avoidRepeatableCommit = method.getAnnotation(AvoidRepeatableCommit.class); | |
long timeout = avoidRepeatableCommit.timeout(); | |
if (timeout < 0){ | |
timeout = Constants.AVOID_REPEATABLE_TIMEOUT; | |
} | |
String value = (String) redisTemplate.opsForValue().get(key); | |
if (StringUtils.isNotBlank(value)){ | |
return "请勿重复提交"; | |
} | |
redisTemplate.opsForValue().set(key, UUIDUtil.uuid(),timeout,TimeUnit.MILLISECONDS); | |
//执行方法 | |
Object object = point.proceed(); | |
return object; | |
} | |
} |