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

Phi*_*ale 50 asp.net-mvc

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

Jim*_*bro 58

首先,确保您在表单上使用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;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

在您的控制器操作上,您只需......

[HttpPost]
[ValidateAntiForgeryToken]
[PreventDuplicateRequest]
public ActionResult CreatePost(InputModel input) {
   ...
}
Run Code Online (Sandbox Code Playgroud)

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


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

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

新的表单标记助手现在自动包含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);
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

根据要求,我还写了一个异步版本,可以在这里找到.

这是自定义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);
}
Run Code Online (Sandbox Code Playgroud)

关于测试的注意事项:只需在浏览器中回击就不会使用相同的AntiForgeryToken.在速度较快的计算机上,您无法在物理上双击按钮两次,您需要使用像Fiddler这样的工具多次使用相同的令牌重放您的请求.

关于设置的注意事项:Core MVC默认情况下没有启用会话.您需要将Microsoft.AspNet.Session包添加到项目中,并Startup.cs正确配置.请阅读这篇文章了解更多详情.

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

services.AddDistributedMemoryCache();
services.AddSession();
Run Code Online (Sandbox Code Playgroud)

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

app.UseSession();
Run Code Online (Sandbox Code Playgroud)

  • 在我看来,这是这个问题的最佳答案.无论出于何种原因,当客户端防止双重提交不起作用时,这提供了一种在服务器端捕获它的简洁方法.感谢使用action属性并重新利用防伪帮助器来提供一次性令牌,这使得应用它非常容易. (8认同)
  • 只是要添加到这个很棒的答案中。上面的评论询问如何处理让用户知道第一篇文章成功的问题。我更改了属性以使用键“DuplicatePost”将错误添加到 ModelState。然后在我的 PostAction(受 preventDuplicateRequest 保护)中,我输入: if(ModelState.ContainsKey("DuplicatePost") return new EmptyResult(); 这会强制服务器不为后续帖子返回任何页面,它只从第一的! (5认同)
  • @JimYarbro 即使您的解决方案效果很好并且可以防止重复提交,但我仍然可以看到一个问题。让我们假设,我正在更新用户配置文件,当第一个请求在数据库中进行了所有更改但就在它完成之前(重定向到另一个页面,在我的情况下),第二个请求已经发出,最终用户将看到错误消息关于重复提交但他永远不会知道他的个人资料在他提出第一个请求时已经更新?是否可以通知用户在第一次请求期间所做的更改? (2认同)
  • 这是一个有效解决多表单提交问题的好方法。这怎么可能不是公认的答案? (2认同)
  • 如果用户点击“保存”按钮两次,抛出错误并不理想……对我来说,阻止用户点击按钮两次更有意义 (2认同)

Dar*_*rov 36

我已经尝试了几种使用Javascript的方法,但很难在所有浏览器中使用它

你尝试过使用jquery吗?

$('#myform').submit(function() {
    $(this).find(':submit').attr('disabled', 'disabled');
});
Run Code Online (Sandbox Code Playgroud)

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

  • 你可以标记为答案,所以我会在另一个之前读到这个;) (4认同)
  • 我发现有必要检查是否$("form").valid()否则当验证错误时它会被卡在禁用状态 (4认同)
  • 虽然它阻止了第二次点击,但它也正在吃点击事件,所以我的表单提交不再有效:​​( (2认同)

Vin*_*nyG 30

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

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

  • 谢谢,仅供参考:此解决方案还可以进行不显眼的验证(包括服务器端).我试图快速点击upvote按钮给你更多积分,但StackOverflow阻止了多个提交...... :-) (10认同)

aam*_*jad 10

如果我们使用$(this).valid()怎么办?

 $('form').submit(function () {
            if ($(this).valid()) {
                $(this).find(':submit').attr('disabled', 'disabled');
            }
        });
Run Code Online (Sandbox Code Playgroud)


Mar*_*ese 5

战略

事实上,您需要采取多条路线来解决这个问题:

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

我不打算讨论 PRG,但这是我对其他两个主题的回答。他们建立在此处的其他答案之上。仅供参考,我正在使用 .NET Core 3.1。

客户端

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

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

禁用提交按钮的另一种方法是在提交期间在表单前面显示覆盖层,以 1) 阻止与表单的任何进一步交互,2) 传达页面正在“做某事”。请参阅这篇文章了解更多详细信息。

服务器端

我从上面吉姆·亚布罗(Jim Yarbro)的精彩回答开始,但后来我注意到马克·巴特勒(Mark Butler)的回答指出,如果有人通过多个浏览器选项卡提交表单,吉姆的方法会失败(因为每个选项卡都有不同的令牌,并且来自不同选项卡的帖子可以交错)。我确认确实存在这样的问题,然后决定从仅跟踪最后一个令牌升级为跟踪最后 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);
}
Run Code Online (Sandbox Code Playgroud)

遗憾的是,这种方法的致命缺陷是第一篇帖子(在任何重复之前)的反馈会丢失。更好(但更复杂)的解决方案是按 GUID 存储每个唯一请求的结果,然后通过不仅跳过再次执行工作而且从第一个请求返回相同结果来处理重复请求,从而为用户提供无缝体验。这篇详尽的文章详细介绍了爱彼迎避免重复付款的方法,将使您了解这些概念。

防止重复表单提交属性.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();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

旋转历史记录.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);
    }
}
Run Code Online (Sandbox Code Playgroud)

会话扩展.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);
    }
}
Run Code Online (Sandbox Code Playgroud)


Lor*_*nzo 0

不要重新发明轮子:)

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

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

  • PRG模式确实非常好,我也会推荐它,但我不知道如何解决这个问题.让我们以一个缓慢的POST动作来处理付款.用户单击提交按钮,可能需要一些时间才能处理他的请求并将其重定向到成功页面.他可能想知道发生了什么以及他是否点击了提交按钮.因此,他第二次点击它,导致第二个HTTP请求被发送到具有相同数据的服务器,并且在不知情的情况下他订购了产品两次. (62认同)
  • 根据链接的维基百科文章,PRG模式无法阻止重复表单提交"如果Web用户在服务器响应加载之前多次单击提交按钮" (9认同)
  • 如果它需要使用javascript,不确定为什么它被标记为答案 (5认同)
  • @Phil Hale:是的.然后它继续说`(可以通过使用JavaScript在第一次点击后禁用按钮来阻止.)你不能简单地保护自己免受所有可能导致仅使用一种方法的双重提交的情况.PRG模式确实为您提供了一种经验丰富的方法来防范大多数陷阱.添加javascript只会在特定时刻保护您 (2认同)
  • 或者在我目前遇到的情况下,公司代理“有帮助”,并决定如果 1 分钟内没有响应,则应该重新提交请求。 (2认同)