ASP.Net MVC 客户端/服务器机器人陷阱
使用客户端计时器和隐藏元素在 Asp.Net MVC 中捕获机器人提交
引言
最近注意到我的 Joomla 网站,即使有验证码机制,每月仍提交大约 5000 条垃圾留言到我的访客留言簿,我有一天在网上随意浏览,偶然看到一篇博客文章 [1],博主也在抱怨他的博客评论中的垃圾信息,以及他如何通过在评论提交时仅使用客户端 JavaScript 计时器和隐藏字段检查器来彻底根除它们。
这让我开始思考,鉴于我对 ASP.Net MVC 的兴趣,我想知道如何实现同样的功能,并进行了一些学习,我启动了 VS2010 并开始着手。
注意:我还在学习 MVC,所以如果有些地方看起来有点粗糙,请原谅,我更关心的是这个概念,以及我能否自行解决问题并得到一个可用的原型。
要求
用户输入表单数据通常需要一些时间来填写信息,然后才点击提交按钮。另一方面,机器人几乎会立即填写表单并提交。我们需要的是:
- 一个在客户端运行的计时器
- 一种在服务器端检查计时器是否已过期的机制
- 如果帖子被认为是机器人条目,则向客户端发送错误信息
对于本文,我将使用一个简单的访客留言簿条目页面作为测试用例。这将基于 MVC3,使用 C# 构建,我正在使用 Visual Studio 2010 Pro。此演示不需要任何数据库,我们将仅使用内存中的访客留言簿条目列表来存储提交的内容并读回给用户。
创建基础项目
我们需要做的第一件事是创建基础项目来构建我们的演示。打开 Visual Studio,在项目类型列表中,导航到 Visual C#,Web,ASP.Net MVC 3 Web Application,提供您的项目名称并单击“确定”。Visual Studio 现在将为此项目类型创建默认应用程序,并提供 MVC3 项目所需的基本框架。

如果您查看下面的解决方案资源管理器图像,您将看到我们将在项目中添加和修改的内容。

模型和数据存储
正如我在文章开头所述,我们不会使用任何数据库来存储访客留言簿条目。为了进行测试,我们将创建一个内存中的访客留言簿条目列表。我们要做的第一件事是创建代表模型的对象。右键单击解决方案资源管理器中的 Models 文件夹,然后选择 Add Class。将此文件命名为 GuestbookModels
。我们定义代表访客留言簿条目的对象,如下所示。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.ComponentModel.DataAnnotations; // <- We need to add this reference to make use of the data annotations
namespace FormSubmitBotTest.Models
{
public class GuestbookModels
{
[Required]
[DataType(DataType.Text)]
[Display(Name = "Name")]
[StringLength(50,ErrorMessage= "Name must be under 50 characters.")]
public virtual string Name { get; set; }
[Required]
[DataType(DataType.EmailAddress)]
[Display(Name = "Email Address (For Admin only)")]
[RegularExpression("^[a-z0-9_\\+-]+(\\.[a-z0-9_\\+-]+)*@[a-z0-9-]+(\\.[a-z0-9-]+)*\\.([a-z]{2,4})$", ErrorMessage = "Email does not appear to be valid format.")]
[StringLength(256, ErrorMessage="Email Address Length too long.")]
public virtual string Email { get; set; }
[Required]
[DataType(DataType.Html)]
[Display(Name = "Message")]
[StringLength(255, ErrorMessage = "Message must be under 255 characters.")]
public virtual string Message { get; set; }
public virtual DateTime Date { get; set; }
}
}
这只是非常基础的访客留言簿条目。在实际应用中,您可能会有更多字段。如上所示,我还添加了一个引用,允许我们使用 DataAnnotations。这些提供了某些验证要求,例如字段是必需的、格式正确以及限制长度。这超出了本文的范围,此处不再进一步讨论,还有很多其他资源可供搜索。
接下来,我们需要一个地方来存储内存中的“数据库”,即一个合适的对象集合。为了达到演示目的,我们将将其添加到 globals.asax
文件中。在文件开头,您可以看到我们正在声明一个 List<GuestbookModels>
,并在 Application_Start
方法中实例化它。现在,这是一种非常粗糙的做法,不建议在生产环境中使用,您可能会遇到各种线程问题等麻烦,但这只是一个演示!
public class MvcApplication : System.Web.HttpApplication
{
//In memory Guestbook entry store
public static List<FormSubmitBotTest.Models.GuestbookModels> Guestbook;
public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
filters.Add(new HandleErrorAttribute());
}
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(
"Default", // Route name
"{controller}/{action}/{id}", // URL with parameters
new { controller = "Home", action = "Index", id = UrlParameter.Optional } // Parameter defaults
);
}
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
RegisterGlobalFilters(GlobalFilters.Filters);
RegisterRoutes(RouteTable.Routes);
//instantiate in memory Guestbook Entries
Guestbook = new List<FormSubmitBotTest.Models.GuestbookModels>();
}
}
控制器
接下来,我们将创建访客留言簿的控制器。这仍然是一个简单的控制器,只允许查看条目和添加条目。右键单击 Controllers 文件夹,然后选择 Add Controller。输入名称 GuestbookController
,如下所示,然后按“确定”。

在此控制器中,我们将添加一些操作。我们需要一个来处理 Index 页面,另一个来检索 AddEntry 页面。我们还将添加一个 AddEntry 的 Post 方法,它将处理用户表单的提交,将访客留言簿条目添加到数据库。控制器的代码如下所示。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace FormSubmitBotTest.Controllers
{
public class GuestbookController : Controller
{
//
// GET: /Guestbook/
public ActionResult Index()
{
//Get list of entries to pass to the view
var entries = from gb in MvcApplication.Guestbook
orderby gb.Date descending
select gb;
return View(entries.ToList());
}
//
// GET:/Guestbook/AddEntry
public ActionResult AddEntry()
{
return View();
}
//
// POST: /Guestbook/AddEntry
[HttpPost]
public ActionResult AddEntry(FormSubmitBotTest.Models.GuestbookModels model, FormCollection formCollection)
{
if (!formCollection["FormEntryBotWatch"].Equals("JS-GOT-ME"))
{
ModelState.AddModelError("", "The controller thinks you are a bot, please wait for message to say it is safe to submit entry (30 Seconds)");
}
else
{
if (ModelState.IsValid)
{
//Add the server Date/Time to the record
model.Date = DateTime.Now;
//Add the entry to the in memory guestbook
MvcApplication.Guestbook.Add(model);
//If we got here, everything ok, redirect back to the index
return RedirectToAction("Index", "GuestBook");
}
}
return View();
}
}
}
Index 操作只是返回数据存储中的条目列表,并将它们传递给视图。AddEntry 的 Get 操作是一个直接的视图。接下来是 [HttpPost]
,在此您会注意到构造函数中的模型和表单集合引用。这些参数负责视图和控制器之间的数据传递。我们感兴趣的是 FormCollection。在代码中,您会注意到我们正在检查表单元素 FormEntryBotWatch
是否不等于值“JS-GOT-ME”。此表单元素在视图中以不同的值初始化(我们稍后会看到),我们正在检查计时器更改其值后是否是预期值。
如果值不匹配预期值,则 JavaScript 未触发,并将 ModelError
注入 ModelState
并附带适当的消息。所有内容都将传回视图。这样,用户输入的表单数据就不会丢失,用户只需等待适当的时间即可重新提交。实际上,计时器可以是 5 或 10 秒,但为了演示目的,我将其设置为 30 秒,以便在浏览器调试器中跟踪更改。
如果值匹配表单元素的预期值,我们只需检查模型状态是否有效,然后将其添加到数据存储,然后将用户重定向回索引。同样,如果模型有问题,即模型状态无效,我们只需返回视图。
视图
首先,右键单击 **Views** 文件夹,然后添加一个 **Guestbook** 文件夹,其中将包含访客留言簿控制器返回的视图。右键单击此文件夹,然后为 AddEntry
和 Index
添加视图。

首先,让我们快速看一下 **Index** 视图。
@model List<FormSubmitBotTest.Models.GuestbookModels>
@{
ViewBag.Title = "Guestbook Index";
}
<h2>Guestbook Index</h2>
@Html.ActionLink("Add Entry","AddEntry") to the guestbook <br /><hr />
@{
if (Model.Count > 0)
{
foreach (var item in Model)
{
<table>
<tr><td>From:</td><td>@item.Name</td></tr>
@if (User.IsInRole("Administrator"))
{
<tr><td>Email:</td><td>@item.Email</td></tr>
}
<tr><td>Message:</td><td>@item.Message</td></tr>
<tr><td>Date:</td><td>@item.Date.ToLongDateString(), @item.Date.ToLongTimeString()</td></tr>
</table>
<hr/>
}
}
else
{
<p>The guestbook contains no entries.</p>
}
}
首先,我们传递对模型的引用,在本例中是访客留言簿条目的列表,然后我们为每个条目构建一个表。您会注意到,如果当前用户是 Administrator 角色成员,他们还可以看到发帖人的电子邮件地址。还有一个指向 Add Entry 操作的链接,用于提交访客留言簿条目。索引视图将显示一个包含一个条目的已填充列表,如下所示。

现在,让我们看一下 **Add Entry** 视图。
@model FormSubmitBotTest.Models.GuestbookModels
@{
ViewBag.Title = "Guestbook Add Entry";
}
<h2>Guestbook Add Entry</h2>
@using (Html.BeginForm())
{
@Html.ValidationSummary(true, "Unable to add entry. Please correct the errors and try again.")
<div>
<fieldset>
<legend>Guestbook Entry</legend>
<div>
@Html.Hidden("FormEntryBotWatch","MVC-IS-WATCHING-YOU")
</div>
<div class="editor-label">
@Html.LabelFor(m => m.Name)
</div>
<div class="editor-field">
@Html.TextBoxFor(m => m.Name, new { @MaxLength = "50" })
@Html.ValidationMessageFor(m => m.Name)
</div>
<div class="editor-label">
@Html.LabelFor(m => m.Email)
</div>
<div class="editor-field">
@Html.TextBoxFor(m => m.Email, new { @MaxLength = "256" })
@Html.ValidationMessageFor(m => m.Email)
</div>
<div class="editor-label">
@Html.LabelFor(m => m.Message)
</div>
<div class="editor-field">
@Html.TextBoxFor(m => m.Message,new {@MaxLength = "255"})
@Html.ValidationMessageFor(m => m.Message)
</div>
<script type="text/javascript">
setTimeout(function () {
var element = document.getElementById("FormEntryBotWatch");
element.setAttribute("value", "JS-GOT-ME");
var message = document.getElementById("submitMessage");
message.innerHTML = "You may submit the form when ready.";
}, 30000);
</script>
<div id="submitMessage"></div>
<p>
<input type="submit" value="Submit Entry" />
</p>
</fieldset>
</div>
}
GuestBookModels
被定义为视图的模型。使用模型中的 DataAnnotations,在表单上建立各种标签和字段。此外,还有用于验证过程的错误消息容器,用于向用户通知问题。在表单的顶部有一个隐藏字段 FormEntryBotWatch
,其值为“MVC-IS-WATCHING-YOU
”。视图中放置了一个脚本块,它首先建立计时器以及在预定时间间隔(在此情况下为 30 秒)后执行的函数。该函数将首先将隐藏字段的值更改为“JS-GOT-ME
”,然后显示一条消息,表明用户准备好后即可提交表单。
如果计时器执行之前提交了表单,控制器会将错误消息和预先填充的表单元素推送回给用户,如下所示。此外,如果模型中有任何字段无效,也会针对这些元素显示错误消息,如下图所示。

共享布局
需要做的另一件事是允许我们轻松访问访客留言簿(无需手动输入 URL),就是更新共享布局。_Layout.cshtml
文件只需在列表中添加另一个导航选项卡条目。附加代码如下所示。
<ul id="menu">
<li>@Html.ActionLink("Home", "Index", "Home")</li>
<li>@Html.ActionLink("About", "About", "Home")</li>
<li>@Html.ActionLink("Guestbook", "Index", "Guestbook")</li>
</ul>
C# vs VB
我决定将项目移植到 VB,只是为了看看 Razor 标记等会有多大不同。我惊讶地发现相当多的差异,而且似乎需要经过更多的折腾才能完成相同的事情。可以肯定地说,我将避免使用 VB 进行 MVC,而是坚持使用 C#。
标记中的一些差异是:
@model
vs @modeltype
@{...}
vs @code ... end Code
@Html.LabelFor(m => m.Name)
vs @Html.LabelFor(Function(model) model.Name)
摘要
总而言之,我们使用了一个简单的客户端计时器来尝试区分用户和机器人。当然,这可以与验证码结合使用。我发现这是一个很好的简单学习项目,它帮助我进一步理解了 MVC 和 Razor 的一些元素,特别是关于验证和错误消息。
您还可以做很多其他事情,而且这确实存在一些限制,您可以在原始博客文章的评论中阅读其中一些。然而,这并不是当时练习的重点。
希望您至少喜欢阅读,感谢您的阅读。
参考文献
- [1] - 我如何关闭本站的评论垃圾信息 - Jeff Croft
- [2] - Asp.Net MVC
历史
- 2012 年 2 月 6 日 - V1.2,添加等效 VB 项目,并添加 C# vs VB 部分
- 2012 年 2 月 5 日 - V1.1,已更正解决方案资源管理器图像。
- 2012 年 2 月 5 日 - V1.0,首次提交文章