防止表单重复提交的方法
一. 概述
我们在平时项目开发中可能会出现下面这些情况:
- 由于用户误操作,多次点击表单提交按钮。
- 由于网速等原因造成页面卡顿,用户重复刷新提交页面。
- 黑客或恶意用户使用postman等工具重复恶意提交表单(攻击网站)。
这些情况都会导致表单重复提交,造成数据重复。因此,我们需要对这些情况进行一定的预防。
二. 解决方案
1. 通过JavaScript防止表单重复提交
使用js防止表单重复提交的方式,其实就是设置一个全局的标记。在表单提交后,修改该标记的值。从而对重复提交进行预防。但是,并不推荐这种方式,该方法存在存在下列优缺点:
优点:
- 简单,方便
缺点:
- 如果客户端禁用JS,这种方法就会失效
- 用户通过刷新页面方式,或使用postman等工具绕过前段页面仍能重复提交表单
代码示例:
<script type="text/javascript">
//默认提交状态为false
var commitStatus = false;
function submit(){
if(commitStatus==false){
//提交表单后,讲提交状态改为true
commitStatus = true;
return true;
}else{
return false;
}
}
</script>
我们还可以在修改标记后, 设置提交按钮的禁用:
<script type="text/javascript">
//默认提交状态为false
var commitStatus = false;
function submit(){
if(commitStatus==false){
//提交表单后,讲提交状态改为true
commitStatus = true;
//设置disabed属性
$("input[type='submit']").attr("disabled",true);
// 或者 $("input[type='submit']").attr("disabled","disabled");
// 移除disabed属性
//$("input[type='submit']").attr("disabled",false);
//或者 $("input[type='submit']").attr("disabled","");
return true;
}else{
return false;
}
}
</script>
2. 在数据库里添加唯一约束
在数据库里添加唯一约束或创建唯一索引,防止出现重复数据。这是最有效的防止重复提交数据的方法。但是,通过数据库加唯一键约束能有效避免数据库重复插入相同数据。但无法阻止恶意用户重复提交表单(攻击网站),服务器大量执行sql插入语句,增加服务器和数据库负荷。
数据库加唯一性约束sql:
alter table tableName_xxx add unique key uniq_xxx(field1, field2)
service及时捕捉插入数据异常:
try {
xxxMapper.insert(user);
} catch (DuplicateKeyException e) {
logger.error("user already exist");
}
3. Redirect-After-Post模式
在提交后执行页面重定向,这就是所谓的Post-Redirect-Get (PRG)模式。简言之,当用户提交了表单后,你去执行一个客户端的重定向,转到提交成功信息页面。这能避免用户按F5导致的重复提交,而其也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退按导致的同样问题。
(1). 用sendRedirect()函数转向
用户提交表单之后,执行重定向,转到成功信息页面。可避免用户按F5刷新页面和点击浏览器前进或后退导致的重复提交。
public void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException,IOException{
response.setContentType("text/html; charset=utf-8");
response.sendRedirect("/success.jsp");
}
(2). 用forward()函数转向
除此之外,当用户提交表单,服务器端调用forward()方法,转发到其他页面。
public void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException,IOException{
response.setContentType("text/html; charset=utf-8");
ServletContext sc = getServletContext();
sc.getRequestDispatcher("/success.jsp").forward(request, response);
}
4. 利用Session防止表单重复提交
在服务器端生成一个唯一的随机标识号,专业术语称为Token,同时在当前用户的Session域中保存这个Token。然后将Token发送到客户端的Form表单中,在Form表单中使用隐藏域来存储这个Token,表单提交的时候连同这个Token一起提交到服务器端,然后在服务器端判断客户端提交上来的Token与服务器端生成的Token是否一致,如果不一致,那就是重复提交了,此时服务器端就可以不处理重复提交的表单。如果相同则处理表单提交,处理完后清除当前用户的Session域中存储的标识号。
在下列情况下,服务器程序将拒绝处理用户提交的表单请求:
- 存储Session域中的Token(令牌)与表单提交的Token(令牌)不同。
- 当前用户的Session中不存在Token(令牌)。
- 用户提交的表单数据中没有Token(令牌)。
示例代码如下:
- 创建FormServlet,用于生成Token(令牌)和跳转到form.jsp页面
public class FormServlet extends HttpServlet {
private static final long serialVersionUID = -884689940866074733L;
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String token = UUID.randomUUID().toString();//创建令牌
System.out.println("在FormServlet中生成的token:"+token);
request.getSession().setAttribute("token", token); //在服务器使用session保存token(令牌)
request.getRequestDispatcher("/form.jsp").forward(request, response);//跳转到form.jsp页面
}
public void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}
- 在form.jsp中使用隐藏域来存储Token(令牌)
<%@ page language="java" import="java.util.*" pageEncoding="UTF-8"%>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<title>form表单</title>
</head>
<body>
<form action="${pageContext.request.contextPath}/servlet/DoFormServlet" method="post">
<%--使用隐藏域存储生成的token--%>
<%--
<input type="hidden" name="token" value="<%=session.getAttribute("token") %>">
--%>
<%--使用EL表达式取出存储在session中的token--%>
<input type="hidden" name="token" value="${token}"/>
用户名:<input type="text" name="username">
<input type="submit" value="提交">
</form>
</body>
</html>
- DoFormServlet处理表单提交
public class DoFormServlet extends HttpServlet {
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
boolean b = isRepeatSubmit(request);//判断用户是否是重复提交
if(b==true){
System.out.println("请不要重复提交");
return;
}
request.getSession().removeAttribute("token");//移除session中的token
System.out.println("处理用户提交请求!!");
}
/**
* 判断客户端提交上来的令牌和服务器端生成的令牌是否一致
* @param request
* @return
* true 用户重复提交了表单
* false 用户没有重复提交表单
*/
private boolean isRepeatSubmit(HttpServletRequest request) {
String client_token = request.getParameter("token");
//1、如果用户提交的表单数据中没有token,则用户是重复提交了表单
if(client_token==null){
return true;
}
//取出存储在Session中的token
String server_token = (String) request.getSession().getAttribute("token");
//2、如果当前用户的Session中不存在Token(令牌),则用户是重复提交了表单
if(server_token==null){
return true;
}
//3、存储在Session中的Token(令牌)与表单提交的Token(令牌)不同,则用户是重复提交了表单
if(!client_token.equals(server_token)){
return true;
}
return false;
}
public void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
doGet(request, response);
}
}
5. 使用AOP自定义切入实现
实现步骤:
- 自定义防止重复提交标记(@AvoidRepeatableCommit)。
- 对需要防止重复提交的Congtroller里的mapping方法加上该注解。
- 新增Aspect切入点,为@AvoidRepeatableCommit加入切入点。
- 每次提交表单时,Aspect都会保存当前key到reids(须设置过期时间)。
- 重复提交时Aspect会判断当前redis是否有该key,若有则拦截。
(1)自定义注解
import java.lang.annotation.*;
/**
* 避免重复提交
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AvoidRepeatableCommit {
/**
* 指定时间内不可重复提交,单位毫秒
* @return
*/
long timeout() default 30000 ;
}
(2)自定义切入点Aspect
/**
* 重复提交aop
*/
@Aspect
@Component
public class AvoidRepeatableCommitAspect {
@Autowired
private RedisTemplate redisTemplate;
/**
* @param point
*/
@Around("@annotation(com.xwolf.boot.annotation.AvoidRepeatableCommit)")
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;
}
}
6. 使用Cookie对表单状态进行判断
以User(假设有一个user类)举例说明,将用户id和"ok" + id分别放到cookie里面,根据需要设置cookie存活时间,然后放到response里面。在每次提交form表单时,先判断cookie中的name是否是已经提交过的表单名称,如果是就重定向到error页面。
示例代码如下:
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (String.valueOf(user.getId()).equals(cookie.getValue())) {
response.sendRedirect("error.jsp");
} else {
Cookie cookie2 = new Cookie("ok" + user.getId(), String.valueOf(user.getId()));
response.addCookie(cookie2);
}
}
注意:如果客户端禁止了Cookie,该方法将不起任何作用,这点请注意。
7. 拦截器+注解方式
7.1 先自定义防止重复的注解
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventRepeat {
}
7.2 自定义拦截器
import com.fasterxml.jackson.databind.ObjectMapper;
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;
import java.util.HashMap;
import java.util.Map;
/**
* 相同url和数据拦截器 为了防止重复提交等操作
* 继承拦截器适配器
*/
public class SameUrlDataInterceptor extends HandlerInterceptorAdapter {
/**
* 覆盖父类的preHandle方法
* 预处理回调方法,实现处理器的预处理,验证是否为重复提交,第三个参数为响应的处理器,自定义Controller
* 返回值:true表示继续流程(如调用下一个拦截器或处理器);false表示流程中断(如登录检查失败),不会继续调用其他的拦截器或处理器,此时我们需要通过response来产生响应;
* @param request
* @param response
* @param handler
* @return
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 判断handler参数是否为HandlerMethod类的实例
if (handler instanceof HandlerMethod) {
// 2. 获取方法注解查看方式是否有PreventRepeat注解
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
PreventRepeat annotation = method.getAnnotation(PreventRepeat.class);
if (annotation != null) {
// 3. 调用重复数据验证方法
boolean result = repeatDataValidator(request);
if(result){
return false;
}
else{
return true;
}
}else{
return true;
}
} else {
// 4. 如果参数不是HandlerMethod类的实例则调用父类的preHandle方法
return super.preHandle(request, response, handler);
}
}
/**
* 验证同一个url数据是否相同提交,相同返回true
* @param httpServletRequest
* @return
*/
public boolean repeatDataValidator(HttpServletRequest httpServletRequest) throws Exception{
try {
// 1. 将请求参数转换为json字符串 需要在pom内引用jackson-databind
ObjectMapper objectMapper = new ObjectMapper();
String params = objectMapper.writeValueAsString(httpServletRequest.getParameterMap());
// 2. 获取当前请求的url地址 并以url为key 参数为值存在map内
String url=httpServletRequest.getRequestURI();
Map<String,String> map=new HashMap(4);
map.put(url, params);
String nowUrlParams=map.toString();
// 3. 获取session中上一次请求存储的url和参数字符串
Object preUrlParams=httpServletRequest.getSession().getAttribute("oldUrlParams");
// 4. 如果上一个数据为null,表示还没有访问页面 将当前方位的url和请求参数存储到session中
if(preUrlParams == null) {
httpServletRequest.getSession().setAttribute("oldUrlParams", nowUrlParams);
return false;
} else {
// 5. 判断上一次访问的url和参数与本次是否相同 如相同则表示重复数据
if(preUrlParams.toString().equals(nowUrlParams))
{
return true;
}
else
{
httpServletRequest.getSession().setAttribute("oldUrlParams", nowUrlParams);
return false;
}
}
} catch (Exception e) {
e.printStackTrace();
// 此处是我自定义异常
throw new BusinessException("验证是否为重复请求时出错了!");
}
}
7.3 配置拦截器
xml:
<!-- 自定义相同url和数据的拦截器 拦截所有的url -->
<mvc:interceptors>
<mvc:interceptor>
<!-- 拦截url -->
<mvc:mapping path="/**"/>
<!-- 自定义拦截器类 -->
<bean class="com.engraver.framework.interceptor.SameUrlDataInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
springboot:
- 新建的 config 包,用来装初始化文件,在配置之下新建 WebConfigurer
- WebConfigurer需要实现 WebMvcConfigurer 这个接口,并实现里面的两个方法。(在老版本的 spring-boot 中使用的是WebMvcConfigurerAdapter,新版本中已过时!!!还有不能通过继承 WebMvcConfigurationSupport 这个类来实现,这样会在某些情况下失效!!!),第二个 addInterceptors 方法用来注册添加拦截器。
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfigurer implements WebMvcConfigurer {
// 这个方法是用来配置静态资源的,比如html,js,css,等等
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
}
// 这个方法用来注册拦截器,我们自己写好的拦截器需要通过这里添加注册才能生效
@Override
public void addInterceptors(InterceptorRegistry registry) {
}
}
- 在WebConfigurer中添加拦截器
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfigurer implements WebMvcConfigurer {
@Autowired
private SameUrlDataInterceptor sameUrlDataInterceptor ;
// 这个方法是用来配置静态资源的,比如html,js,css,等等
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
}
// 这个方法用来注册拦截器,我们自己写好的拦截器需要通过这里添加注册才能生效
@Override
public void addInterceptors(InterceptorRegistry registry) {
// addPathPatterns("/**") 表示拦截所有的请求,
// excludePathPatterns("/login", "/register") 表示除了登陆之外,都可以防止表单重复提交
registry.addInterceptor(sameUrlDataInterceptor ).addPathPatterns("/**").excludePathPatterns("/login");
}
}
- 使用方法
在controller类的方法上增加@PreventRepeat注解
部分内容转自博客:
https://blog.csdn.net/Huozhiwu_11/article/details/78742886
https://www.cnblogs.com/huanghuizhou/p/9153837.html
https://blog.csdn.net/qq_30745307/article/details/80974407
https://www.jianshu.com/p/e8e51b3a9371