防止在 ASP.NET MVC 页面上重复提交/Post

Prevent Double Submit / Post on ASP.NET MVC page

我希望得到一些关于我打算用于防止 ASP.NET MVC 4 应用程序中的重复记录的方法的反馈,以及我没有想到的对用户体验的连锁反应。

Web 表单有六个输入字段和一个保存按钮(以及一个取消按钮),用户最多可能需要 10 分钟来填写表单。

一旦通过 post 提交字段,页面将在成功/失败时重定向到另一个页面,在数据被记录在数据库中后 table 使用新的 Guid 作为主要页面关键。

要阻止用户多次按下保存按钮,但允许浏览器重新post关闭连接的请求我打算为新记录主键提供 Guid 作为隐藏输入字段呈现表单时。

如果 repost 发生,或者用户多次按下保存,数据库服务器将拒绝记录的第二个 post 因为重复的键,我可以检查并应付服务器端。

但这会给我带来更大的问题吗?

您可以在客户端简单处理。

创建一个叠加层 div,一个 css class 显示 none 和一个大的 z-index 和一个 jquery 脚本,显示 div 当用户按下提交按钮时。

据我了解,您打算在最初呈现页面时将主键保留在隐藏输入中。显然这不是一个好主意。首先,如果您在 c# 中使用 Guid 实现,它是字符串并且将字符串作为主键不是一个好主意 (See the answer for this SO question)。

您可以通过两种方式解决此问题。首先,在第一次点击时禁用按钮。其次,在不依赖主键的情况下在代码隐藏中构建验证。

如果您愿意,可以使用一些 jQuery 来阻止客户端的双击。

在你的 HTML 中,你的提交按钮是这样的:

<a id="submit-button"> Submit Form </a> <span id="working-message"></span>

在JavaScript (jQuery):

$('#submit-button').click(function() {
  $(this).hide();
  $('#working-message').html('Working on that...');
  $.post('/someurl', formData, function(data) {
    //success 
    //redirect to all done page
  }).fail(function(xhr) {
    $('#submit-button').show();
    $('#working-message').html('Submit failed, try again?');
  });
}); // end on click

这会在按钮尝试提交之前隐藏该按钮,因此用户无法点击两次。这也显示了进度,如果失败,则允许他们重新提交。您可能想考虑在我上面的代码中添加超时。

另一种方法是使用 jquery 获取表单 $('#form-id').submit(),但您无法像我完成的 ajax 调用那样轻松跟踪进度.

编辑: 出于安全原因,我仍然建议从服务器端的角度寻找防止双重提交的方法。

如果您在表单中使用了隐藏的防伪标记(您应该这样做),您可以在第一次提交时缓存防伪标记,并在需要时从缓存中删除该标记,或者使缓存的条目过期在设定的时间后。

然后您将能够针对缓存检查每个请求是否已提交特定表单,如果已提交则拒绝。

您不需要生成自己的 GUID,因为在生成防伪令牌时已经完成了。

更新

在设计您的解决方案时,请记住每个请求都将在其自己单独的线程中异步处理,甚至可能在完全不同的服务器/应用程序实例中处理。

因此,即使在创建第一个缓存条目之前,也完全有可能处理多个请求(线程)。为了解决这个问题,将缓存实现为队列。在每次提交(post 请求时),将机器名称/id 和线程 id 写入缓存,连同防伪令牌...延迟几毫秒,然后检查最旧的条目是否在cache/queue对应那个防伪令牌。

此外,所有 运行 个实例必须能够访问缓存(共享缓存)。

有时候,仅仅在客户端处理是不够的。尝试生成表单的哈希码并保存在缓存中(设置过期日期或类似的东西)。

算法类似于:

1- 用户制作 post

2- 生成 post

的散列

3- 检查缓存中的哈希值

4- Post 已经在缓存中了吗?抛出异常

5- Post 不在缓存中?将新散列保存在缓存中并将 post 保存在数据库

样本:

            //verify duplicate post
            var hash = Util.Security.GetMD5Hash(String.Format("{0}{1}", topicID, text));
            if (CachedData.VerifyDoublePost(hash, Context.Cache))
                throw new Util.Exceptions.ValidadeException("Alert! Double post detected.");

缓存功能可能是这样的:

    public static bool VerifyDoublePost(string Hash, System.Web.Caching.Cache cache)
    {
        string key = "Post_" + Hash;

        if (cache[key] == null)
        {
            cache.Insert(key, true, null, DateTime.Now.AddDays(1), TimeSpan.Zero);
            return false;
        }
        else
        {
            return true;
        }
    }

这在 MVC(可能还有其他 Web 框架)中实际上是一个普遍存在的问题,所以我将稍微解释一下,然后提供解决方案。

问题

假设您在一个带有表单的网页上。你点击提交。服务器需要一段时间才能响应,因此您再次单击它。然后再次。此时您已经触发了三个独立的请求,服务器将同时处理所有这些请求。但是在浏览器中只会执行一个响应 - 第一个。

这种情况可以用下面的折线图表示。

          ┌────────────────────┐
Request 1 │                    │  Response 1: completes, browser executes response
          └────────────────────┘
            ┌────────────────┐
Request 2   │                │  Response 2: also completes!
            └────────────────┘
              ┌───────────────────┐
Request 3     │                   │  Response 3: also completes!
              └───────────────────┘

横轴代表时间(不按比例)。换句话说,三个请求按顺序发出,但只有第一个响应 returned 到浏览器;其他的被丢弃。

这是个问题。并非总是如此,但通常足以令人讨厌,此类请求有 side-effects。这些 side-effects 可能会有所不同,包括计数器递增、创建重复记录,甚至多次处理信用卡付款。

解决方案

现在,在 MVC 中,大多数 POST 请求(尤其是有副作用的请求)应该使用 built-in AntiForgeryToken 逻辑为每个表单生成和验证随机令牌。以下解决方案利用了这一点。

计划:我们丢弃所有重复的请求。这里的逻辑是:缓存每个请求的令牌。如果它已经在缓存中,那么 return 一些虚拟重定向响应,可能带有错误消息。

就我们的折线图而言,这看起来像...

          ┌────────────────────┐
Request 1 │                    │  Response 1: completes, browser executes the response [*]
          └────────────────────┘
            ┌───┐
Request 2   │ x │  Response 2: rejected by overwriting the response with a redirect
            └───┘
              ┌───┐
Request 3     │ x │  Response 3: rejected by overwriting the response with a redirect
              └───┘

[*] 浏览器执行了 错误的 响应,因为它已经被请求 2 和 3 替换了。

注意这里的几件事:因为我们根本不处理重复的请求,所以他们会快速执行结果。太快了 - 他们实际上通过先进入来替换第一个请求的响应。

因为我们实际上并没有处理这些重复的请求,所以我们不知道将浏览器重定向到哪里。如果我们使用虚拟重定向(如 /SameController/Index),那么当第一个响应 return 到浏览器时,它将执行该重定向而不是它应该做的任何事情。这让用户忘记了他们的请求是否真正成功完成,因为第一个请求的结果丢失了。

显然这不太理想。

因此,我们修改后的计划:不仅缓存每个请求的令牌,还缓存响应。这样,我们就可以将实际应该 returned 的响应分配给浏览器,而不是将任意重定向分配给重复的请求。

代码如下所示,使用过滤器属性。

/// <summary>
/// When applied to a controller or action method, this attribute checks if a POST request with a matching
/// AntiForgeryToken has already been submitted recently (in the last minute), and redirects the request if so.
/// If no AntiForgeryToken was included in the request, this filter does nothing.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class PreventDuplicateRequestsAttribute : ActionFilterAttribute {
    /// <summary>
    /// The number of minutes that the results of POST requests will be kept in cache.
    /// </summary>
    private const int MinutesInCache = 1;

    /// <summary>
    /// Checks the cache for an existing __RequestVerificationToken, and updates the result object for duplicate requests.
    /// Executes for every request.
    /// </summary>
    public override void OnActionExecuting(ActionExecutingContext filterContext) {
        base.OnActionExecuting(filterContext);

        // Check if this request has already been performed recently
        string token = filterContext?.HttpContext?.Request?.Form["__RequestVerificationToken"];
        if (!string.IsNullOrEmpty(token)) {
            var cache = filterContext.HttpContext.Cache[token];
            if (cache != null) {
                // Optionally, assign an error message to discourage users from clicking submit multiple times (retrieve in the view using TempData["ErrorMessage"])
                filterContext.Controller.TempData["ErrorMessage"] =
                    "Duplicate request detected. Please don't mash the submit buttons, they're fragile.";

                if (cache is ActionResult actionResult) {
                    filterContext.Result = actionResult;
                } else {
                    // Provide a fallback in case the actual result is unavailable (redirects to controller index, assuming default routing behaviour)
                    string controller = filterContext.ActionDescriptor.ControllerDescriptor.ControllerName;
                    filterContext.Result = new RedirectResult("~/" + controller);
                } 
            } else {
                // Put the token in the cache, along with an arbitrary value (here, a timestamp)
                filterContext.HttpContext.Cache.Add(token, DateTime.UtcNow.ToString("s"),
                    null, Cache.NoAbsoluteExpiration, new TimeSpan(0, MinutesInCache, 0), CacheItemPriority.Default, null);
            }
        }
    }

    /// <summary>
    /// Adds the result of a completed request to the cache.
    /// Executes only for the first completed request.
    /// </summary>
    public override void OnActionExecuted(ActionExecutedContext filterContext) {
        base.OnActionExecuted(filterContext);

        string token = filterContext?.HttpContext?.Request?.Form["__RequestVerificationToken"];
        if (!string.IsNullOrEmpty(token)) {
            // Cache the result of this request - this is the one we want!
            filterContext.HttpContext.Cache.Insert(token, filterContext.Result,
                null, Cache.NoAbsoluteExpiration, new TimeSpan(0, MinutesInCache, 0), CacheItemPriority.Default, null);
        }
    }
}

要使用此属性,只需将其粘贴在 [HttpPost][ValidateAntiForgeryToken]:

旁边的方法上
[HttpPost]
[ValidateAntiForgeryToken]
[PreventDuplicateRequests]
public ActionResult MySubmitMethod() {
    // Do stuff here
    return RedirectToAction("MySuccessPage");
}

...并随心所欲地向那些提交按钮发送垃圾邮件。我一直在几种操作方法上使用它,到目前为止没有任何问题 - 没有更多重复记录,无论我向提交按钮发送多少垃圾邮件。

如果有人对 MVC 实际处理请求的方式有任何更准确的描述(因为这完全是根据观察和堆栈跟踪编写的),欢迎来访,我会相应地更新此答案。

最后,感谢@CShark,我将其 作为解决方案的基础。