防止在MVC页面上重复提交/发布 ASP.NET
本文关键字:提交 发布 NET ASP MVC | 更新日期: 2023-09-27 18:37:19
我希望得到一些关于我打算用来防止 ASP.NET MVC 4应用程序中重复记录的方法的反馈,以及我对用户体验没有的连锁反应。
Web表单有六个输入字段和一个保存按钮(以及一个取消按钮),用户最多可能需要10分钟填写表单。
通过帖子提交字段后,在使用新的 Guid 作为主键将数据记录在数据库表中后,页面将在成功/失败时重定向到不同的页面。
为了阻止用户多次按下保存按钮,但允许浏览器在关闭的连接上重新发布请求,我打算在呈现表单时提供新记录主键的 Guid 作为隐藏输入字段。
如果发生重发,或者用户多次按 save 键,数据库服务器会因为重复键而拒绝记录的第二个帖子,我可以检查并处理服务器端。
但这会给我带来更大的问题吗?
如果您在表单中使用隐藏的防伪令牌(您应该这样做),则可以在首次提交时缓存防伪令牌,并在需要时从缓存中删除令牌,或者在设置的时间量后使缓存的条目过期。
然后,您将能够根据缓存检查每个请求是否已提交特定表单,如果已提交,则拒绝它。
无需生成自己的 GUID,因为在生成防伪令牌时已执行此操作。
更新
在设计解决方案时,请记住,每个请求都将在其自己的单独线程中异步处理,甚至可能在完全不同的服务器/应用实例上异步处理。
因此,即使在第一个缓存条目之前,也完全有可能处理多个请求(线程)。 若要解决此问题,请将缓存实现为队列。 在每次提交(发布请求)时,将机器名称/ID 和线程 ID 以及防伪令牌一起写入缓存......延迟几毫秒,然后检查该防伪令牌的缓存/队列中最旧的条目是否对应。
此外,所有正在运行的实例都必须能够访问缓存(共享缓存)。
如果你愿意,你可以使用一些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 调用那样轻松地跟踪进度。
编辑:出于安全原因,我仍然建议从服务器端的角度寻找防止双重提交的方法。
这实际上是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,我把他的建议作为我解决方案的基础。
有时,仅在客户端处理是不够的。尝试生成表单的哈希代码并保存在缓存中(设置到期日期或类似的东西)。
该算法类似于:
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;
}
}
<</div>
div class="answers"> 据我了解,您计划在最初呈现页面时将主键保留在隐藏输入中。显然这不是一个好主意。首先,如果在 c# 中使用 Guid 实现,它是字符串,将字符串作为主键不是一个好主意(请参阅此 SO 问题的答案)。
您可以通过两种方式解决此问题。首先,在第一次单击时禁用该按钮。其次,在代码中构建验证,而不依赖于主键。
您可以简单地在客户端处理它。
创建一个覆盖div、一个带有显示 none 的 css 类、一个大的 z-index 和一个 jquery 脚本,该脚本在用户按下提交按钮时显示该div。