日常开发中,表单的提交是无法避免的,而我们必须熟知的一点是“在做后退或刷新操作时,post会重新提交请求是有害的,而get虽然会重新获取数据但却是无害的”。所以我们要禁止用户重复提交表单。
首先,我们要知道在什么情况下表单会重复提交:
- 点击提交按钮两次。
- 点击刷新按钮。
- 回退,然后重复操作。
- 进行一些恶意操作。
那么,如何防止表单重复提交呢?
其实很简单,根据数据流向的过程,可以从三个层面进行控制:
1、前端层面
用户点击按钮触发submit时,前端js控制提交按钮的状态,将按钮的disabled属性为true,防止重复点击。
//监听submit事件,触发后将提交按钮设置为不可用
$('form').submit(function() {
$('button[type=submit]').attr('disabled', true);
});
基本上禁用按钮后,前端不用管了,因为数据如果通不过laravel的验证或发生异常,在laravel中的做法是back()->withInput()->withErrors();
这时页面会刷新,按钮会自行恢复状态。
如果是ajax方式
提交,直接在function里发送ajax请求前禁用就行了,然后根据请求的结果来恢复按钮的状态或跳转页面就可以了。
2、服务端层面
思路::
在显示表单页面时,服务端生成一个随机字符串并以该字符串为key保存在session中并将其回显在表单一个隐藏的input中,当提交表单时,服务端根据这个隐藏input的值(即session中的key)去session中取值,如果该key存在于session中表示正常提交,并立即从session中删除该key,若发生重复提交,session中的这个key已经被删除了,就可以给前端相应的提示“表单重复提交”。
缺点:
-
刷新界面,会导session中存放了多个key,数据冗余且存在漏洞,因为存在多个key即意味着同一时间可以使用不同key来提交同一份数据;
【补充】laravel中可以通过flash方法来存储只在下个请求有效的session数据,即在下一请求之后,该数据会被自动从session中清除,这样确实能解决刷新界面后session中保存多个key的问题,但会带来一个新的问题,列举一个场景加以说明:假如某用户正在写评论,写到一半被旁边推荐的一篇文章吸引,就先去看文章了,等看完回来继续写完评论提交,会发生什么事?会被当做表单重复提交处理,因为查看文章时,已经将flash方式保存的session清空了。
-
不够简洁,要知道这里解决的问题是要防止表单重复提交,完全没有必要生成一个动态的类似token东西,针对某一类表单提交(如注册)将存储在session中的key固定就好了,这样就可以省去form中那个隐藏的input了。
优化后的思路:
针对不同类型的表单(这里定义登陆、注册为不同类型的表单)服务端维护多个不同的key(比如登陆表单在session中对应的key固定为‘login’,注册表单的key固定为’register’),在显示表单页面时将key保存进session(对应的value可以存1,也可以存当前时间,存当前时间的话,你可以根据在提交表彰时根据时间间隔来作进一步的控制),表单提交时将其删除,若出现重复提交,session中不存在这个key,你就可以提示用户“不要重复提交”了。
具体实现:
1、在controller中显示注册界面的方法里保存session
public function showRegistrationForm(Request $request)
{
$request->session()->put('register',time());
return view('auth.register');
}
2、在处理表单提交方法中判断是否重复提交
public function register(Request $request)
{
if($this->request->session()->has(‘register’)){
//存在则表示是首次提交,清空session中的'register'
$this->request->session()->forget(‘register’);
}else{
//否则抛http异常,跳转到403页面
throw new HttpException(403,'请忽重复注册');
}
//省略下面的验证、注册逻辑等代码
}
【补充】如果是参数验证失败,比如手机号已注册之类的,你back()->withInput()->withErrors();
是会重新执行showRegistrationForm()
方法的,所以出错后再次提交是不会被当做重复提交处理的
简单对其进行封装
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
/**
* 基础控制器,封装了web及api请求的一些公共方法
* @author 94505
*
*/
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
/**
* 请求
*
* @var Request
*/
protected $request;
public function __construct()
{
$this->request = app('request');
}
/**
* 防止表单重复提交的key前缀
* @var string
*/
private $formResubmitPrefix = 'f_';
/**
* 将key加个前缀
* @param unknown $key
* @return string
*/
private function formResubmitKeyProcess($key){
if(empty($key)){
//默认使用当前路由的uri为key
return $this->formResubmitPrefix.Route::current()->uri;
}else{
return $this->formResubmitPrefix.$key;
}
}
/**
* 在初始化表单前调用(如上面分步实现中的showRegistrationForm()方法中)
* @param unknown $key
*/
protected function formInit($key = null){
$key = $this->formResubmitKeyProcess($key);
$this->request->session()->put($key,time());
}
/**
* 在处理表单提交的方法中调用(如上面分步实现中的register()方法)
* @param string $message
* @param unknown $key
* @throws HttpException
*/
protected function formSubmited(string $message = '请忽重复提交!',$key = null){
$key = $this->formResubmitKeyProcess($key);
if($this->request->session()->has($key)){
$this->request->session()->forget($key);
}else{
throw new HttpException(403,$message);
}
}
/**
* 在处理表单提交的方法中调用(如上面分步实现中的register()方 法),该方法方便自定义重复提交时的提示页面,可以在子类中if判断一下,如果发生重复提交,响应自定义的界面
* @param string $message
* @param unknown $key
*/
protected function formSubmitIsRepetition(string $message = '请勿重复提交!',$key = null){
$key = $this->formResubmitKeyProcess($key);
if($this->request->session()->has($key)){
$this->request->session()->forget($key);
return false;
}else{
return response()->view('errors.403',['message'=>$message],403);
}
}
/**
* 该方法用于ajax请求,返回的数据是数组
* @param string $message
* @param unknown $key
*/
protected function formSubmitedForAjax(string $message = '请勿重复提交!',$key = null){
$key = $this->formResubmitKeyProcess($key);
if($this->request->session()->has($key)){
$this->request->session()->forget($key);
return false;
}else{
return ['result'=>'fail','message'=>$message];
}
}
}
在需要防止表单重复提交的控制器内,继承上面封装的Controller就可以直接调用里面的方法了,记得在子类构造方法中调用parent::__construct();
,不然$this->request会为null,当然你也可以改成用全局Session辅助函数session()。
3、数据库层面
数据库加unique索引的话只能根据实际情况权衡决定。
比如用户表的手机号(列phone)可用来登陆,必须要求唯一,但在大多数情况下你无法加这个索引,因为现在一般都支持多种登陆方式,如微信登陆、微博登陆,这个手机号可能会没有值,除非程序自动生成一个,但是否有必要?再比如一个varchar类型的列,虽然数据是唯一的,也不会出现空的情况,考虑到varchar类型插入与修改数据时更新索引的性能消耗,可能会放弃加这个索引。
作为一个严谨的程序员,防止表单重复提交处理是必须的!