【问题标题】:Prevent Double Submit / Post on ASP.NET MVC page防止在 ASP.NET MVC 页面上重复提交/发布
【发布时间】:2015-03-11 17:36:48
【问题描述】:

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

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

通过帖子提交字段后,在使用新的 Guid 作为主键将数据记录在数据库表中之后,成功/失败页面将重定向到不同的页面。

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

如果发生repost,或者用户多次按save,数据库服务器会因为key重复而拒绝记录的第二次post,我可以检查并处理服务器端。

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

【问题讨论】:

  • 有什么问题?
  • 将主键 ID 放在隐藏字段中唯一存在的问题是用户可以进入该字段并更改主键并更新数据库中的每条记录。现在,因为它们是 Guid,所以几乎不可能猜测已经使用过的 Guid 并更新以前用户的表单,如果你使用的是整数,那么我会这样,这是个坏主意
  • 你用过 Bootstrap 吗?因为它会在单击后自动禁用按钮,如果不使用 jquery 或某些加载器或单击后隐藏按钮,直到您收到对发布请求的响应'
  • 目前,当客户网络繁忙且用户期望更快的响应时,如果没有此新记录,则最多重复七次。
  • 我开始禁用保存按钮,但有时它会让用户无路可走。表格卡住了,服务器没有收到帖子,他们必须重新输入所有内容。问题是浏览器永远不知道是服务器没有收到 POST 还是浏览器没有收到响应。

标签: c# jquery asp.net asp.net-mvc asp.net-mvc-4


【解决方案1】:

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

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

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

更新

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

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

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

【讨论】:

    【解决方案2】:

    如果您愿意,可以使用一些 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 调用那样轻松地跟踪进度。

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

    【讨论】:

    • 很有趣,谢谢,我没有看过 ajax,因为我使用的是 ASP.NET MVC 4 模式,并希望保留 MVC 4,越来越多地看到离开会更好MVC4。仍然希望得到 MVC4 的答案
    • 嘿没问题! :) 我在日常中广泛使用 MVC4 和 MVC5,但我几乎总是在我的 MVC 应用程序中使用这种模式来提交表单。它提供了更大的灵活性,而且我总是可以使用其他 MVC 选项来验证“服务器端”数据。看看JsonResult:您可以在 MVC 应用程序中创建适合这些 jQuery / ajax 调用的操作。
    【解决方案3】:

    这实际上是 MVC(可能还有其他 Web 框架)中相当普遍的问题,所以我将对其进行一些解释,然后提供解决方案。

    问题

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

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

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

    横轴代表时间(不按比例)。也就是说,三个请求是按顺序触发的,但只有第一个响应返回给浏览器;其他的都被丢弃了。

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

    解决方案

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

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

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

              ┌────────────────────┐
    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),那么当第一个响应返回到浏览器时,它将执行该重定向而不是它应该执行的任何操作。这让用户忘记了他们的请求是否真正成功完成,因为第一个请求的结果丢失了。

    显然这不太理想。

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

    这是使用过滤器属性在代码中的外观。

    /// <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,我将其suggestions用作我的解决方案的基础。

    【讨论】:

    • 我喜欢这样,因为它将用户返回到一个明智的地方。在我们的例子中,我们有许多操作队列的前端服务器,任何请求都可以按任何顺序到达,因为用户或负载均衡器已经重新提交了一个请求,并且每个前端服务器都有一个队列。所以缓存值有漏洞,只有数据库(单点写入)可以决定记录是否已提交,但可以提取 PK 异常,然后与此概念一起使用。
    【解决方案4】:

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

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

    【讨论】:

    • 您发布了一个问题的链接,该问题是关于 Guid 与 int 作为主键聚集索引的性能 - 这与这个问题几乎没有关系,除了他们都提到 Guid
    • 撇开我知道并接受的 Guid PK 性能问题不谈。安全问题也让我担心,但我无法确定它的真正问题,除了伪造条目的人你有任何链接可以帮助解决这个问题吗?
    • 除了性能问题和Guid被篡改的可能性之外,这种方法没有任何问题。实际上,您甚至不需要将 Guid 保留为 PK。您可以将 Guid 与记录一起插入,并在插入之前检查具有此 Guid 的记录是否已经存在。
    【解决方案5】:

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

    算法类似于:

    1- 用户发帖

    2- 生成帖子的哈希

    3- 检查缓存中的哈希

    4- 帖子已在缓存中?抛出异常

    5- 帖子不在缓存中?将新哈希保存在缓存中并将帖子保存在数据库中

    一个样本:

                //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;
            }
        }
    

    【讨论】:

      【解决方案6】:

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

      创建一个覆盖 div、一个不显示的 css 类和一个大的 z-index 和一个 jquery 脚本,当用户按下提交按钮时显示该 div。

      【讨论】:

      • 有时它会让用户无路可走。表格被卡住了,服务器没有收到帖子,他们必须重新输入所有内容。问题是浏览器永远不知道是服务器没有收到 POST 还是浏览器没有收到响应。
      • 如果 display 设置为 none,则不需要大量的 z-index。
      • @Porschiey 是的,但最终你会显示 div,当你这样做时,它应该在顶部
      • @pdeane - 当然,但你说的是你有很多层的场景 - OP 没有指出是这种情况,也不应该是标准。跨度>
      猜你喜欢
      • 1970-01-01
      • 2016-03-18
      • 1970-01-01
      • 1970-01-01
      • 2017-06-26
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多