【第四篇】ASP.NET MVC 快速入门之完整示例(MVC5+EF6)
请关注三石的博客:
在讲解 MVC 提供的安全策略之前,还是先看下 WebForms 中常见的表单身份验证(Forms Authentication),这种身份验证的过程也很简单:
1. 用户提供登录信息(比如用户名和密码)。
2. 登录信息验证通过后,会创建一个包含用户名的 FormsAuthenticationTicket 对象。
3. 对此 Ticket 对象进行加密,并将加密结果以字符串的形式保存到浏览器 Cookie 中。
后会的所有 HTTP 请求,都会带上这个 Cookie 并由 WebForms 进行比对,同时对外公开如下两个属性:
1. HttpContext.User.Identity.IsAuthenticated
2. HttpContext.User.Identity.Name
在 Web.config 中,我们一般需要配置登录页面(loginUrl)、登录后的跳转页面(defaultUrl),
登录后的保持时间(timeout)等信息:
- <system.web>
- <authentication mode="Forms">
- <forms loginUrl="~/default.aspx" timeout="120"
- defaultUrl="~/main.aspx" protection="All" path="/" />
- </authentication>
- <authorization>
- <deny users="?" />
- </authorization>
- </system.web>
上面这个配置拒绝了所有用户的匿名访问,当然我们在 <system.web> 节的外面更改指定目录的访问权限,比如:
- <location path="res">
- <system.web>
- <authorization>
- <allow users="*" />
- </authorization>
- </system.web>
- </location>
这个配置允许匿名用户对 res 目录的访问(一般是静态资源)。
MVC 对验证模型进行了重写,但是基本的原理没有变化,我们更关注的是不同点:
1. WebForms 中基于目录进行权限控制。
2. MVC 中对控制器或者控制器的方法进行权限控制。
理解这一点也不难,因为 MVC 中没有和物理目录对应的 URL,并且同一个控制器方法可能会对应多个访问 URL,这一过程是由路由引擎配置的,在第一篇文章中有简单介绍。
在 MVC 中,我们要保护的资源不是文件夹目录,而是控制器和控制器方法,所以 MVC 提供了授权过滤器(Authorize Filter)对此进行保护,它是以数据注解的形式提供的。
- [Authorize]
- public class StudentsController : Controller
- {
- ...
- }
这里是对整个控制器进行了保护,防止匿名用户访问,这时访问会得到一个错误的页面:
现在添加配置信息:
- <system.web>
- <authentication mode="Forms">
- <forms loginUrl="~/Home/Login" defaultUrl="~/Students" timeout="120" protection="All"
- path="/" />
- </authentication>
- </system.web>
指定了登录页面~/Home/Login,登录后的页面是~/Students,现在再来浏览页面:
http://localhost:55654/Students
这次访问有两个 HTTP 请求,并且浏览器地址栏的 URL 改变了:
http://localhost:55654/Home/Login?ReturnUrl=/Students
这样的地方我们很熟悉,ReturnUrl 参数指定了登录成功后需要调整的页面,而~/Home/Login 则是我们刚刚在 Web.config 中配置的登录页面。
两个 HTTP 请求中的第一个,响应码是 302,这是一个重定向响应,浏览器会自动识别 302 响应并跳转到响应头中 Location 指定的网址。所以第二个请求是由浏览器发起的,但是我们尚未定义 Login 页面,所以返回 404 未找到。
定义 Home/Login 控制器方法:
- public class HomeController : Controller
- {
- public ActionResult Login()
- {
- return View();
- }
- }
在操作方法内部点击右键,选择 [添加视图…] 菜单项:
在弹出的向导对话框中,选择 [Empty(without Model)],我们来手工创建视图内容:
完成的视图页面:
- @{
- ViewBag.Title = "Login";
- }
- <h2>Login</h2>
- @using (html.BeginForm())
- {
- @Html.AntiForgeryToken()
- <input type="text" name="UserName" />
- <input type="password" name="Password" />
- <input type="submit" value="登录" />
- }
点击 [登录] 按钮,表单会通过 POST 请求提交到 Login 方法:
- [HttpPost]
- [ValidateAntiForgeryToken]
- public ActionResult Login(string UserName, string Password)
- {
- if(UserName == "sanshi" && Password == "pass")
- {
- FormsAuthentication.RedirectFromLoginPage("sanshi", false);
- }
- return View();
- }
这里硬编码了管理员的用户名和密码,在实际应用中可能需要从数据库中读取。
接下来,我们需要在布局页面(Shared/_Layout.cshtml)中放置登录后的信息以及 [退出系统] 按钮:
- @if (User.Identity.IsAuthenticated)
- {
- using (Html.BeginForm("Logout", "Home", FormMethod.Post, new { id = "logoutForm" }))
- {
- @Html.AntiForgeryToken()
- class="nav navbar-nav navbar-right">
- "javascript:;">Hello, @User.Identity.Name
- "javascript:;" id="logout">退出系统
- }
- }
- else
- {
- class="nav navbar-nav navbar-right">
- @Html.ActionLink(
- "登录", "Login", "Home")
- }
这段代码有两层逻辑:
2. 如果是匿名用户,则显示 [登录] 的超链接。
- <script>
- $(function () {
- $('#logout').click(function () {
- $('#logoutForm').submit();
- });
- });
- </script>
[退出系统] 按钮的后台逻辑,需要先清空客户端 Cookie,然后执行客户端跳转:
- [HttpPost]
- [ValidateAntiForgeryToken]
- public ActionResult Logout()
- {
- FormsAuthentication.SignOut();
- return RedirectToAction("Index", "Home");
- }
来看下页面运行效果,首先是登录页面:
登录成功后,直接跳转到~/Students 页面:
在前面的 HTTP POST 请求中,我们多次在 View 和 Controller 中看下如下代码:
1. View 中调用了 Html.AntiForgeryToken()。
2. Controller 中的方法添加了 [ValidateAntiForgeryToken] 注解。
这样看似一对的写法其实是为了避免引入跨站请求伪造(CSRF)攻击。
这种攻击形式大概在 2001 年才为人们所认知,2006 年美国在线影片租赁网站 Netflix 爆出多个 CSRF 漏洞,2008 年流行的视频网址 YouTube 受到 CSRF 攻击,同年墨西哥一家银行客户受到 CSRF 攻击,杀毒厂商 McAfee 也曾爆出 CSRF 攻击(引自 wikipedia)。
之所以很多大型网址也遭遇 CSRF 攻击,是因为 CSRF 攻击本身的流程就比较长,很多开发人员可能在几年的时间都没遇到 CSRF 攻击,因此对 CSRF 的认知比较模糊,没有引起足够的重视。
我们这里将通过一个模拟的示例,讲解 CSRF 的攻击原理,然后再回过头来看下 MVC 提供的安全策略。
假设我们是银行的 Web 开发人员,现在需要编写一个转账页面,客户登录后在此输入对方的账号和转出的金额,即可实现转账:
- [Authorize]
- public ActionResult TransferMoney()
- {
- return View();
- }
- [HttpPost]
- [Authorize]
- public ActionResult TransferMoney(string ToAccount, int Money)
- {
- // 这里放置转账业务代码
- ViewBag.ToAccount = ToAccount;
- ViewBag.Money = Money;
- return View();
- }
由于这个过程需要身份验证,所以我们为 TransferMoney 的两个操作方法都加上了注解 [Authorize],以阻止匿名用户的访问。
如果直接访问 http://localhost:55654/Home/TransferMoney,会跳转到登录页面:
登录后,来到转账页面,我们看下转账的视图代码:
- @{
- ViewBag.Title = "Transfer Money";
- }
- Transfer Money
- @if (ViewBag.ToAccount == null)
- {
- using (Html.BeginForm())
- {
- "text" name="ToAccount" />
- "text" name="Money" />
- "submit" value="转账" />
- }
- }
- else
- {
- @:您已经向账号 [@ViewBag.ToAccount] 转入 [@ViewBag.Money] 元!
- }
视图代码中有一个逻辑判断,根据 ViewBag.ToAccount 是否为空来显示不同内容:
1. ViewBag.ToAccount 为空,则表明是页面访问。
2. ViewBag.ToAccount 不为空,则为转账成功,需要显示转账成功的提示信息。
来看下页面运行效果:
功能完成!看起来没有任何问题,但是这里却又一个 CSRF 漏洞,隐蔽而难于发现。
这里就有两个角色,银行的某个客户 A,黑客 B。
黑客 B 发现了银行的这个漏洞,就写了两个简单的页面,页面一(click_me_please.html):
- <!DOCTYPE html>
- <html>
- <head>
- <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
- </head>
- <body>
- 哈哈,逗你玩的!
- <iframe frameborder="0"
- style="display:none;" src="./click_me_please_iframe.html"></iframe>
- </body>
- </html>
- <!DOCTYPE html>
- <html>
- <head>
- <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
- </head>
- <body onload="document.getElementById('myform1').submit();">
- <form method="POST" id="myform1"
- action="http://localhost:55654/Home/TransferMoney">
- <input type="hidden" name="ToAccount" value="999999999">
- <input type="hidden" name="Money" value="3000">
- </form>
- </body>
- </html>
现在黑客把这两个页面放到公网:
http://fineui.com/demo_mvc/csrf/click_me_please.html
然后批量向用户发送带有攻击链接的邮件,而银行的客户 A 刚好登录了银行系统,并且手贱点击了这个链接:
然后你将看到这个页面:
你可能会在心里想,谁这么无聊,然后郁闷的关闭了这个页面。之后客户 A 会更加郁闷,因为黑客 B 的银行账号 [999999999] 已经成功多了 3000 块钱!
是的。转账的确是需要身份验证,现在的问题是你登录了银行系统,已经完成了身份验证,并且在浏览器新的 Tab 中打开了黑客的链接,我们来看下到底发生了什么:
这里有三个 HTTP 请求,第一个就是 [逗你玩] 页面,第二个是里面的 IFrame 页面,第三个是 IFrame 加载完毕后发起的 POST 请求,也就是具体的转账页面。因为 IFrame 是隐藏的,所以用户并不知道发生了什么。
我们来具体看下第三个请求:
明显这次转账是成功的,并且 Cookie 中带上了用户身份验证信息,所有后台根本不知道这次请求是来自黑客的页面,转账成功的返回内容:
从上面的实例我们可以看出,CSRF 源于表单身份验证的实现机制。
由于 HTTP 本身是无状态的,也就是说每一次请求对于 Web 服务器来说都是全新的,服务器不知道之前请求的任何状态,而身份验证需要我们在第二次访问时知道是否登录的状态(不可能每次请求都验证账号密码),这本身就是一种矛盾!
解决这个矛盾的办法就是 Cookie,Cookie 可以在浏览器中保存少量信息,所以 Forms Authentication 就用 Cookie 来保存加密过的身份信息。而 Cookie 中保存的全部值在每次 HTTP 请求中(不管是 GET 还是 POST,也不管是静态资源还是动态资源)都会被发送到服务器,这也就给 CSRF 以可乘之机。
所以,CSRF 的根源在于服务器可以从 Cookie 中获知身份验证信息,而无法得知本次 HTTP 请求是否真的是用户发起的。
Referer 是 HTTP 请求头信息中的一部分,每当浏览器向服务器发送请求时,都会附带上 Referer 信息,表明当前发起请求的页面地址。
一个正常的转账请求,我们可以看到 Referer 和浏览器地址栏是一致的:
我们再来看下刚才的黑客页面:
可以看到 Referer 的内容和当前发起请求的页面地址一样,注意对比:
1. 浏览器网址:click_me_please.html
2. HTTP 请求地址:Home/TransferMoney
3. Referer:click_me_please_iframe.html,注意这个是发起请求的页面,而不一定就是浏览器地址栏显示的网址。
基于这个原理,我们可以简单的对转账的 POST 请求进行 Referer 验证:
- [HttpPost]
- [Authorize]
- public ActionResult TransferMoney(string ToAccount, int Money)
- {
- if(Request.Url.Host != Request.UrlReferrer.Host)
- {
- throw new Exception("Referrer validate fail!");
- }
- // 这里放置转账业务代码
- ViewBag.ToAccount = ToAccount;
- ViewBag.Money = Money;
- return View();
- }
此时访问 http://fineui.com/demo_mvc/csrf/click_me_please.html,恶意转账失败:
MVC 默认提供的 CSRF 验证方式更加彻底,它通过验证当前请求是否真的来自用户的操作。
在视图页面,表单内部增加对 Html.AntiForgeryToken 函数的调用:
- @if (ViewBag.ToAccount == null)
- {
- using (Html.BeginForm())
- {
- @Html.AntiForgeryToken()
- <input type="text" name="ToAccount" />
- <input type="text" name="Money" />
- <input type="submit" value="转账" />
- }
- }
- else
- {
- @:您已经向账号 [@ViewBag.ToAccount] 转入 [@ViewBag.Money] 元!
- }
然后添加 [ValidateAntiForgeryToken] 注解到控制器方法中:
- [HttpPost]
- [Authorize]
- [ValidateAntiForgeryToken]
- public ActionResult TransferMoney(string ToAccount, int Money)
- {
- // 这里放置转账业务代码
- ViewBag.ToAccount = ToAccount;
- ViewBag.Money = Money;
- return View();
- }
在服务器端,会验证这两个 Token 是否一致(不是相等),如果不一致就会报错。
下面手工修改表单中这个隐藏字段的值,来看下错误提示:
类似的道理,运行黑客页面 http://fineui.com/demo_mvc/csrf/click_me_please.html,恶意转账失败:
此时,虽然 Cookie 中的__RequestVerificationToken 提交到了后台,但是黑客无法得知表单字段中的__RequestVerificationToken 值,所以转账失败。
在编辑 Student 的控制器方法中,有一个 Bind 特性注解,我们来回顾一下:
- [HttpPost]
- [ValidateAntiForgeryToken]
- public ActionResult Edit([Bind(Include = "ID,Name,Gender,Major,EntranceDate")] Student student)
- {
- if (ModelState.IsValid)
- {
- db.Entry(student).State = EntityState.Modified;
- db.SaveChanges();
- return RedirectToAction("Index");
- }
- return View(student);
- }
这是为了防止 Over-Posting 攻击,这个理解起来相对简单一点,Bind 特性的 Include 属性用来指定一个白名单,所有在白名单中的属性都会参与模型绑定。
假设在 Student 模型中增加一个 [职务] 的字段:
- public string Job {
- get;
- set;
- }
如果没有 Bind 特性,那么在更新 Student 信息时,恶意用户可以通过模拟 POST 请求(第二篇文章有介绍)来提交 Job 的值,从而导致数据库中用户的 Job 改变。而 Bind 特性就是为了避免这种情况的发生。
Bind 特性还提供了黑名单的设置方式,类似如下所示:
- [Bind(Exclude = "Job")]
但是,一般我们推荐使用白名单,这样即使模型发生改变,也不会影响到现有的功能。
本篇文章首先介绍了 MVC 下 Forms Authentication 的实现方式以及与 WebForms 下表单身份验证的区别。然后重点讲解了跨站请求伪造攻击(CSRF),由于这种攻击流程比较长,理解起来比较晦涩,我们特地制作了一个攻击案例,希望能够引起开发人员的重视。Over-Posting 攻击相对比较简单,但是需要我们在实际编码中严格遵守安全指引,不能存在侥幸心里。当然还有其他类型的攻击,比如跨站脚本攻击(XSS),Cookie 盗取,开放重定向攻击等等,限于篇幅原因就不一一介绍。
从下一篇文章开始,我们将逐渐丰富示例的功能,先为表格页面增加一个搜索表单,可以根据不同的查询条件显示表格数据。
来源: