如何在不使用Javascript的情况下阻止.NET MVC中的多个表单提交?

时间:2010-11-22 21:59:12

标签: asp.net-mvc

我想阻止用户在.NET MVC中多次提交表单。我已经尝试了几种使用Javascript的方法,但很难在所有浏览器中使用它。那么,如何在我的控制器中防止这种情况呢?在某种程度上可以检测到多个提交?

14 个答案:

答案 0 :(得分:49)

首先,请确保您在表单上使用AntiForgeryToken。

然后你可以制作一个自定义的ActionFilter:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class PreventDuplicateRequestAttribute : ActionFilterAttribute {
    public override void OnActionExecuting(ActionExecutingContext filterContext) {
        if (HttpContext.Current.Request["__RequestVerificationToken"] == null)
            return;

        var currentToken = HttpContext.Current.Request["__RequestVerificationToken"].ToString();

        if (HttpContext.Current.Session["LastProcessedToken"] == null) {
            HttpContext.Current.Session["LastProcessedToken"] = currentToken;
            return;
        }

        lock (HttpContext.Current.Session["LastProcessedToken"]) {
            var lastToken = HttpContext.Current.Session["LastProcessedToken"].ToString();

            if (lastToken == currentToken) {
                filterContext.Controller.ViewData.ModelState.AddModelError("", "Looks like you accidentally tried to double post.");
                return;
            }

            HttpContext.Current.Session["LastProcessedToken"] = currentToken;
        }
    }
}

在您的控制器操作中,您只是......

[HttpPost]
[ValidateAntiForgeryToken]
[PreventDuplicateRequest]
public ActionResult CreatePost(InputModel input) {
   ...
}

您会注意到这并不能完全阻止该请求。相反,它会在模型​​状态中返回错误,因此当您的操作检查ModelState.IsValid时是否会发现它不是,并且将返回正常的错误处理。

更新:这是一个ASP.NET核心MVC解决方案

我将像以前一样坚持使用影响最小的用例,您只需要装饰那些您特别希望阻止重复请求的控制器操作。如果您希望在每个请求上运行此过滤器,或者想要使用异步,则还有其他选项。有关详细信息,请参阅this article

新的表单标记助手现在自动包含AntiForgeryToken,因此您不再需要手动将其添加到视图中。

像这个例子一样创建一个新的ActionFilterAttribute。您可以使用此功能执行许多其他操作,例如包括延时检查,以确保即使用户提供了两个不同的令牌,他们也不会每分钟多次提交。

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class PreventDuplicateRequestAttribute : ActionFilterAttribute {
    public override void OnActionExecuting(ActionExecutingContext context) {
        if (context.HttpContext.Request.Form.ContainsKey("__RequestVerificationToken")) {
            var currentToken = context.HttpContext.Request.Form["__RequestVerificationToken"].ToString();
            var lastToken = context.HttpContext.Session.GetString("LastProcessedToken");

            if (lastToken == currentToken) {
                context.ModelState.AddModelError(string.Empty, "Looks like you accidentally submitted the same form twice.");
            }
            else {
                context.HttpContext.Session.SetString("LastProcessedToken", currentToken);
            }
        }
    }
}

根据要求,我还写了一个异步版本which can be found here

以下是自定义PreventDuplicateRequest属性的设计用法示例。

[HttpPost]
[ValidateAntiForgeryToken]
[PreventDuplicateRequest]
public IActionResult Create(InputModel input) {
    if (ModelState.IsValid) {
        // ... do something with input

        return RedirectToAction(nameof(SomeAction));
    }

    // ... repopulate bad input model data into a fresh viewmodel

    return View(viewModel);
}

关于测试的说明:只需在浏览器中回击就不会使用相同的AntiForgeryToken。在速度较快的计算机上,您无法在物理上双击该按钮两次,您需要使用Fiddler之类的工具多次使用相同的令牌重播您的请求。

关于设置的说明:默认情况下,Core MVC没有启用会话。您需要将Microsoft.AspNet.Session包添加到项目中,并正确配置Startup.cs。有关详细信息,请阅读this article

会话设置的简短版本是: 在Startup.ConfigureServices()中,您需要添加:

services.AddDistributedMemoryCache();
services.AddSession();

Startup.Configure()中你需要添加(之前 app.UseMvc() !!):

app.UseSession();

答案 1 :(得分:35)

  

我尝试了几种使用Javascript的方法,但是难以让它在所有浏览器中运行

您是否尝试过使用jquery

$('#myform').submit(function() {
    $(this).find(':submit').attr('disabled', 'disabled');
});

这应该照顾浏览器的差异。

答案 2 :(得分:29)

只是为了完成@Darin的答案,如果你想处理客户端验证(如果表单有必填字段),你可以在禁用提交按钮之前检查是否有输入验证错误:

$('#myform').submit(function () {
    if ($(this).find('.input-validation-error').length == 0) {
        $(this).find(':submit').attr('disabled', 'disabled');
    }
});

答案 3 :(得分:9)

如果我们使用,该怎么办?  $(本).valid()

 $('form').submit(function () {
            if ($(this).valid()) {
                $(this).find(':submit').attr('disabled', 'disabled');
            }
        });

答案 4 :(得分:5)

不要重新发明轮子:)

使用Post/Redirect/Get设计模式。

在这里,您可以找到question和答案,提供有关如何在ASP.NET MVC中实现它的一些建议。

答案 5 :(得分:3)

战略

事实是,您需要针对此问题采取多种攻击方式:

  • 仅使用 Post/Redirect/Get (PRG) pattern 是不够的。不过,在使用返回、刷新等时,它应该始终用于为用户提供良好的体验。
  • 必须使用 JavaScript 来防止用户多次单击提交按钮,因为与服务器端解决方案相比,它提供的用户体验要少得多。
  • 仅在客户端阻止重复的帖子并不能防范不良行为者,也无助于解决瞬态连接问题。 (如果您的第一个请求发送到服务器,但响应没有返回到客户端,导致您的浏览器自动重新发送请求怎么办?)

我不打算介绍 PRG,但这里是我对其他两个主题的回答。他们建立在其他答案的基础上。仅供参考,我使用的是 .NET Core 3.1。

客户端

假设您正在使用 jQuery 验证,我相信这是防止表单提交按钮被双击的最干净/最有效的方法。请注意,submitHandler 仅在验证通过后调用,因此无需重新验证。

$submitButton = $('#submitButton');
$('#mainForm').data('validator').settings.submitHandler = function (form) {
  form.submit();
  $submitButton.prop('disabled', true);
};

禁用提交按钮的替代方法是在提交期间在表单前显示一个覆盖层,以 1) 阻止与表单的任何进一步交互,以及 2) 传达页面正在“做某事”。有关详细信息,请参阅 this article

服务器端

我从上面的 Jim Yarbro's great answer 开始,但后来我注意到 Mark Butler's answer 指出如果有人通过多个浏览器选项卡提交表单,Jim 的方法如何失败(因为每个选项卡都有不同的标记和来自不同选项卡的帖子可以交错)。我确认确实存在这样的问题,然后决定从只跟踪最后一个令牌升级到跟踪最后 x 个令牌。

为了方便起见,我创建了几个辅助类:一个用于存储最后的 x 个令牌,另一个用于轻松地将对象存储到会话存储中/从会话存储中检索对象。主代码现在检查在令牌历史记录中未找到当前令牌。除此之外,代码几乎相同。我只是做了一些小调整以适应我的口味。我包括了常规和异步版本。完整代码如下,但这些是关键行:

var history = session.Get<RotatingHistory<string>>(HistoryKey) ?? new RotatingHistory<string>(HistoryCapacity);

if (history.Contains(token))
{
    context.ModelState.AddModelError("", DuplicateSubmissionErrorMessage);
}
else
{
    history.Add(token);
}

遗憾的是,这种方法的致命缺陷是第一篇文章(在任何重复之前)的反馈丢失了。一个更好(但更复杂)的解决方案是通过 GUID 存储每个唯一请求的结果,然后通过不仅跳过再次执行工作而且还从第一个请求返回相同的结果来处理重复的请求,给用户一个无缝体验。这篇详尽的文章 detailing Air BnB's methods of avoiding duplicate payments 将让您了解这些概念。

PreventDuplicateFormSubmissionAttribute.cs

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Filters;

// This class provides an attribute for controller actions that flags duplicate form submissions
// by adding a model error if the request's verification token has already been seen on a prior
// form submission.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class PreventDuplicateFormSubmissionAttribute: ActionFilterAttribute
{
    const string TokenKey = "__RequestVerificationToken";
    const string HistoryKey = "RequestVerificationTokenHistory";
    const int HistoryCapacity = 5;

    const string DuplicateSubmissionErrorMessage =
        "Your request was received more than once (either due to a temporary problem with the network or a " +
        "double button press). Any submissions after the first one have been rejected, but the status of the " +
        "first one is unclear. It may or may not have succeeded. Please check elsewhere to verify that your " +
        "request had the intended effect. You may need to resubmit it.";

    public override void OnActionExecuting(ActionExecutingContext context)
    {
        HttpRequest request = context.HttpContext.Request;

        if (request.HasFormContentType && request.Form.ContainsKey(TokenKey))
        {
            string token = request.Form[TokenKey].ToString();

            ISession session = context.HttpContext.Session;
            var history = session.Get<RotatingHistory<string>>(HistoryKey) ?? new RotatingHistory<string>(HistoryCapacity);

            if (history.Contains(token))
            {
                context.ModelState.AddModelError("", DuplicateSubmissionErrorMessage);
            }
            else
            {
                history.Add(token);
                session.Put(HistoryKey, history);
            }
        }
    }

    public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        HttpRequest request = context.HttpContext.Request;

        if (request.HasFormContentType && request.Form.ContainsKey(TokenKey))
        {
            string token = request.Form[TokenKey].ToString();

            ISession session = context.HttpContext.Session;
            await session.LoadAsync();
            var history = session.Get<RotatingHistory<string>>(HistoryKey) ?? new RotatingHistory<string>(HistoryCapacity);

            if (history.Contains(token))
            {
                context.ModelState.AddModelError("", DuplicateSubmissionErrorMessage);
            }
            else
            {
                history.Add(token);
                session.Put(HistoryKey, history);
                await session.CommitAsync();
            }
            await next();
        }
    }
}

RotatingHistory.cs

using System.Linq;

// This class stores the last x items in an array.  Adding a new item overwrites the oldest item
// if there is no more empty space.  For the purpose of being JSON-serializable, its data is
// stored via public properties and it has a parameterless constructor.
public class RotatingHistory<T>
{
    public T[] Items { get; set; }
    public int Index { get; set; }

    public RotatingHistory() {}

    public RotatingHistory(int capacity)
    {
        Items = new T[capacity];
    }

    public void Add(T item)
    {
        Items[Index] = item;
        Index = ++Index % Items.Length;
    }

    public bool Contains(T item)
    {
        return Items.Contains(item);
    }
}

SessonExtensions.cs

using System.Text.Json;
using Microsoft.AspNetCore.Http;

// This class is for storing (serializable) objects in session storage and retrieving them from it.
public static class SessonExtensions
{
    public static void Put<T>(this ISession session, string key, T value) where T : class
    {
        session.SetString(key, JsonSerializer.Serialize(value));
    }

    public static T Get<T>(this ISession session, string key) where T : class
    {
        string s = session.GetString(key);
        return s == null ? null : JsonSerializer.Deserialize<T>(s);
    }
}

答案 6 :(得分:2)

您可以在表单帖子中包含隐藏(随机或计数器)值,控制器可以在“打开”列表或类似内容中跟踪这些值;每当你的控制器发出一个表格时,它就会嵌入一个值,它跟踪一个值,允许一个帖子使用它。

答案 7 :(得分:0)

在它自身中,不,但是,取决于控制器实际上在做什么,你应该能够找到一种方法。

是否在数据库中创建了一条记录,您可以检查它是否已经提交了表单?

答案 8 :(得分:0)

您还可以在隐藏字段中传递某种令牌,并在控制器中对此进行验证。

或者您在提交值后使用重定向。但是,如果你充分利用ajax,这就变得很困难。

答案 9 :(得分:0)

您可以通过创建某种特定于用户或特定于您想要保护资源的方式的静态进入标志来实现。我使用ConcurrentDictionary跟踪入口。密钥基本上是我要保护的资源的名称以及用户ID。诀窍是弄清楚如何在知道当前正在处理的请求时阻止该请求。

public async Task<ActionResult> SlowAction()
{
    if(!CanEnterResource(nameof(SlowAction)) return new HttpStatusCodeResult(204);
    try
    {
        // Do slow process
        return new SlowProcessActionResult();
    }
    finally
    {
       ExitedResource(nameof(SlowAction));
    }
}

返回204是对双击请求的响应,该响应不会在浏览器端执行任何操作。完成缓慢的过程后,浏览器将收到对原始请求的正确响应并采取相应措施。

答案 10 :(得分:0)

使用发布/重定向/获取设计模式。

PS: 在我看来, Jim Yarbro 的答案可能存在基本缺陷,因为__RequestVerificationToken存储在HttpContext.Current.Session [“ LastProcessedToken”]中,该值提交第二个表单(从另一个浏览器窗口)时将被替换,此时可以重新提交第一个表单,并且不会将其识别为重复提交吗?为了使拟议的模型正常工作,不需要__RequestVerificationToken的历史记录(?),这是不可行的。

答案 11 :(得分:0)

使用此简单的jquery输入字段,即使您在单个表单中有多个提交按钮,它也能很好地工作。

$('input[type=submit]').click(function () {
    var clickedBtn = $(this)
    setTimeout(function () {
        clickedBtn.attr('disabled', 'disabled');
    }, 1);
});

答案 12 :(得分:0)

只需在页面末尾添加此代码。我正在使用“ jquery-3.3.1.min.js”和“ bootstrap 4.3.1”

<script type="text/javascript">
    $('form').submit(function () {
        if ($(this).valid()) {
            $(this).find(':submit').attr('disabled', 'disabled');
        }
    });
</script>

答案 13 :(得分:-1)

这适用于每个浏览器

 document.onkeydown = function () {
        switch (event.keyCode) {
            case 116: //F5 button
                event.returnValue = false;
                event.keyCode = 0;
                return false;
            case 82: //R button
                if (event.ctrlKey) {
                    event.returnValue = false;
                    event.keyCode = 0;
                    return false;
                }
        }
    }