对于最常见的场景 — Web 页面中的 JavaScript 访问同一站点上的 Web API 服务,讨论 ASP.NET Web API 的安全性几乎是多余的。如果对用户执行身份验证和授权对 Web 窗体/视图(包含使用服务的 JavaScript)的访问均已设置,则服务可能已具备其所需的所有安全性了。这要归因于 ASP.NET,它会将其用于验证页面请求的 Cookie 和身份验证信息作为对服务方法的任意客户端 JavaScript 请求的一部分进行发送。但有一个非常重要的例外: ASP.NET 无法自动防御跨站点请求伪造 (CSRF/XSRF) 攻击(稍后详述)。 除 CSRF 以外,还有两个值得探讨 Web API 服务保护的场景。第一个场景是当服务的使用方为客户端,而非与 ApiControllers 处于同一个站点上的页面时。这些客户端可能未经过窗体身份验证的审核,也可能未获取 ASP.NET 用于控制服务访问的 Cookie 和令牌。 第二个场景是需要为服务添加超出 ASP.NET 安全功能范围的身份验证时。ASP.NET 提供的默认身份验证基于在身份验证期间 ASP.NET 分配给请求的标识。您可能希望扩展该标识,以授权进行基于标识名称或角色以外条件的访问。 Web API 提供了多种选择,以应对这两种场景。事实上,我将讨论的是接受 Web API 请求上下文的安全性,但由于 Web API 与 Web 窗体和 MVC 均以 ASP.NET 为基础,因而了解 Web 窗体或 MVC 安全性的读者一定会非常熟悉本文中介绍的工具。 有一点需要特别注意: 虽然 Web API 提供了多种身份验证和授权选项,但安全始于主机(IIS 或进行自托管时创建的主机)。例如,如果需要确保 Web API 服务与客户端之间通信的隐秘性,则至少应开启 SSL。但这是站点管理员而非开发者的职责。在本文中,我将忽略主机方面的内容,专注于开发者能够/应该为确保 Web API 服务的安全而进行的工作(无论 SSL 开启与否,我讨论的这些工具都能正常工作)。 抵御跨站点请求伪造攻击当用户访问使用窗体身份验证的 ASP.NET 网站时,ASP.NET 会生成一个 Cookie,表明该用户已经过身份验证。浏览器会在每次向该站点发出后续请求时发送该 Cookie,而不管请求来自何处。只要会导致浏览器自动发送之前收到的身份验证信息的任意身份验证方案存在,您的站点就有可能成为 CSRF 的攻击目标。在站点向浏览器提供安全 Cookie 后,如果用户访问了某个恶意站点,该站点即可向您的服务发送请求,利用浏览器之前收到的身份验证 Cookie 发动攻击。 为抵御 CSRF 攻击,需要在服务器端生成防伪令牌,并将之嵌入到要在客户端调用中使用的页面中。Microsoft 提供了 AntiForgery 类及一个 GetToken 方法(可生成特定于发出请求的用户的令牌,当然,此处的用户可以为匿名用户)。下面的代码生成了两个令牌,并将之嵌入至可在 View 中使用的 ASP.NET MVC ViewBag:
针对服务器的任意 JavaScript 调用都需要将该令牌作为请求的一部分予以返回(CSRF 站点没有此类令牌,也就无法返回它们)。下面的代码(位于 View 中)将动态生成一个将令牌加入请求标题中的 JavaScript 调用:
一个稍微复杂点的解决方案是将令牌嵌入至 View 中的隐藏字段,使得这段 JavaScript 代码不会太引人注目。该过程的第一步是将令牌添加到 ViewData 字典中:
生成的输入标记将使用 ViewData 键作为标记的名称和 ID 属性,并将从 ViewData 字典检索到的数据放入标记的值属性中。之前代码生成的输入标记将如下所示:
之后,即可用 JavaScript 代码(跟 View 保存在不同的文件中)从输入标记中检索值并在 ajax 调用中使用它们:
在 ASP.NET Web 窗体中,通过使用 ClientScriptManager 对象(可从 Page 的 ClientScript 属性检索)的 RegisterClientScriptBlock 方法插入包含嵌入式令牌的 JavaScript 代码,也可达到同一目的。
最后,需要在服务器端验证 JavaScript 调用返回的令牌。已应用 ASP.NET 和 Web Tools 2012.2 更新的 Visual Studio 2012 用户会发现,新的单页面应用程序 (SPA) 模板包含一个可在 Web API 方法上使用的 ValidateHttpAntiForgeryToken 筛选器。如果没有该筛选器,则需要检索令牌并将其传递给 AntiForgery 类的 Validate 方法(如果令牌无效,或是为其他用户生成的,则 Validate 方法将引发异常)。图 1 中的代码(用在 Web API 服务方法中)将从标头中检索令牌并对其进行验证。 图 1 在服务方法中验证 CSRF 令牌
使用 ValidateHttpAntiForgeryToken(而非方法内的代码)可将处理移至循环早期(例如,在模型绑定前),这是个很好的做法。 为何不用 OAuth?本文刻意忽略 OAuth。OAuth 规范定义了客户端如何从第三方服务器(向其发送服务并反过来用该第三方服务器来验证令牌)检索令牌。关于如何从客户端或服务器访问 OAuth 令牌提供程序的讨论超出了本文的论述范围。此外,OAuth 的最初版本与 Web API 的配合度不佳。使用 Web API 的主因之一可能是需要使用基于 REST 和 JSON 的更轻量级的请求。该目标使得 OAuth 的首个版本对于 Web API 服务来说毫无吸引力。OAuth 的首个版本指定的令牌基于 XML,非常笨重。幸运的是,OAuth 2.0 引入了更轻量级的 JSON 令牌规范,与前一版本的令牌相比,该令牌体积更小。本文讨论的方法可能适用于对发送至服务的任意 OAuth 令牌的处理。 基本身份验证 您在保护 Web API 服务安全方面担负着两个主要职责,第一个是身份验证(另一个是授权)。我假定其他问题(如隐私)在主机进行处理。 最好在 Web API 管道中尽可能靠前的位置执行身份验证和授权,因为这样可避免在打算拒绝的请求上浪费处理周期。本文的身份验证解决方案应用在管道中极为靠前的位置 — 几乎是在收到请求后就立即执行。此外,还可利用这些方法将身份验证与已维护的任意用户列表进行整合。所讨论的授权方法可应用在管道中的多个位置(包括在服务方法中使用),并可配合身份验证依据用户名或角色以外的其他条件授权请求。 可通过在自定义 HTTP 模块中提供自建身份验证方法的方式来为未接触过窗体身份验证的客户提供支持(此处,我仍假定您不是针对 Windows 帐户而是针对包含有效用户的列表执行身份验证)。使用 HTTP 模块有两大好处: 模块参与 HTTP 登录和审核;另外,模块在管道早期调用。虽然拥有这两大优势,但是模块同时带来了两个开销: 模块是全局的,并应用于到站点的所有请求,而不仅仅是 Web API 请求;另外,要使用身份验证模块,必须用 IIS 托管服务。本文稍后将讨论如何使用委派处理程序(仅针对 Web API 请求调用,与主机无关)。 对于下面使用 HTTP 模块的示例,我假定 IIS 使用的是基本身份验证,且用于对用户执行身份验证的凭据是客户端发送的用户名和密码(在本文中,我将忽略 Windows 证书,而专注于对客户端证书使用方法的探讨)。此外,我还假定要保护的 Web API 服务是借助 Authorize 属性来确保安全的,如下面的代码所示(指定了一名用户):
创建自定义身份验证 HTTP 模块的第一步是向实现 IHttpModule 和 IDisposable 接口的服务项目添加一个类。在该类的 Init 方法中,需要将从 HttpApplication 对象传递给该方法的两个事件连接起来。连接至 AuthenticateRequest 事件的方法将在出示客户端凭据时调用。但还必须连接 EndRequest 方法,以便生成导致客户端向您发送凭据的消息。此外,还需要一个 Dispose 方法,但无需在其中添加任何内容来支持此处所用的代码:
HttpClient 将发送凭据以响应包含在 HTTP 响应中的 WWW-Authenticate 标头。应在请求产生 401 状态码(如果客户端访问安全服务遭拒,则 ASP.NET 会产生 401 响应代码)时包含该标头。标头必须提供有关所用身份验证方法及身份验证应用范围的提示(范围可为任意字符串,用于向浏览器标记服务器上的不同区域)。在连接至 EndRequest 事件的方法中填写的内容即为发送该消息的代码。下面的示例将生成一条消息,指定所用的是基本身份验证,范围为 PHVIS:
在连接至 AuthenticateRequest 方法的方法中,需要检索客户端在收到 401/WWW-Authenticate 消息时发送的 Authorization 标题:
确认客户端已传递 Authorization 标题元素后(此处仍延续之前的假设,即,站点使用基本身份验证),需要解析包含用户名和密码的数据。用户名和密码经过 Base64 编码,以冒号分隔。下面的代码将用户名和密码存入一个包含两个位置的字符串数组中:
正如代码所示,用户名和密码以明文发送。如果不启用 SSL,则用户名和密码很容易被截取(在开启 SSL 的情况下,这段代码仍可正常工作)。 下一步是使用您认为合适的任意机制验证用户名和密码。不管以何种方式验证请求(我在下面示例中所用的代码可能过于简单),最后一步都是创建将在 ASP.NET 管道中稍后的授权过程中用到的用户标识。 要经由管道传递标识信息,需要使用将分配给用户的标识的名称创建一个 GenericIdentity 对象(在下面的代码中,我假定标识为在标头中发送的用户名)。创建 GenericIdentity 对象后,必须将其放入 Thread 类的 CurrentPrincipal 属性中。此外,ASP.NET 还在 HttpContext 对象中维护着第二个安全上下文,如果主机为 IIS,则必须通过将 HttpContext 的 Current 属性中的 User 属性设为 GenericIdentity 对象来提供支持:
如果需要基于角色的安全性支持,则必须将一个角色名称数组作为第二个参数传递给 GenericPrincipal 的构造函数。下面的示例将所有用户都分配为 manager 和 admin 角色。
要将 HTTP 模块整合到站点处理中,需要在项目的 web.config 文件中,在模块元素中使用添加标记。必须将添加标记的类型属性设置为完全限定类名后接模块程序集名称的字符串。
所创建的 GenericIdentity 对象将与 ASP.NET Authorize 属性搭配使用。此外,还可在服务方法中访问 GenericIdentity,以执行身份验证活动。例如,可通过检查 GenericIdentity 对象的 IsAuthenticated 属性(对于 Anonymous 用户,IsAuthenticated 返回 false)来确定用户是否已通过身份验证,然后为已登录用户和匿名用户提供不同服务:
利用 User 属性检索 GenericIdentity 对象会更加方便:
构建兼容客户端 要使用受该模块保护的服务,非 JavaScript 客户端必须提供可接受的用户名和密码。为提供使用 .NET HttpClient 的凭据,要先创建一个 HttpClientHandler 对象并将其 Credentials 属性设为包含用户名和密码的 NetworkCredential 对象(或将 HttpClientHandler 对象的 UseDefaultCredentials 属性设为 true,以使用当前用户的 Windows 凭据)。然后创建 HttpClient 对象,并传递该 HttpClientHandler 对象:
完成此项配置后,即可向服务发出请求。除非 HttpClient 在访问服务时遭拒并收到 WWW-Authenticate 消息,否则其不会出示凭据。如果服务无法接受 HttpClient 提供的凭据,则返回一条 HttpResponseMessage,并将其 Result 集的 StatusCode 设置为“unauthenticated”。 下面的代码使用 GetAsync 方法调用服务,检查结果是否成功并在失败时显示服务返回的状态码:
假设您会绕过非 JavaScript 客户端的 ASP.NET 登录过程,就像我这里所做的那样,则不会创建任何身份验证 Cookie,且客户端发出的每个请求都会被单独验证。为减少反复验证客户端提供的凭据所产生的开销,应考虑在服务端缓存检索的凭据(并使用 Dispose 方法丢弃这些已缓存的凭据)。 使用客户端证书 在 HTTP 模块中,可借助类似下面的代码来检索客户端证书对象(同时确保其存在并有效):
在更加靠后的处理管道中(比如在服务方法中),利用下面的代码检索证书对象(并检查其是否存在):
如果证书存在且有效,则可进一步检查证书属性(如主题或颁发者)中的特定值。 要通过 HttpClient 发送证书,第一步是创建 WebRequestHandler 对象而非 HttpClientHandler(与 HttpClientHandler 相比,WebRequestHandler 提供了更多的配置选项):
将 WebRequestHandler 对象的 ClientCertificateOptions 设置为 ClientCertificateOption 枚举中的 Automatic 值,可使 HttpClient 自动搜索客户端的证书存储:
但是,默认情况下,客户端必须在代码中将证书明确地连接至 WebRequestHandler。可从某个客户端证书存储检索证书,例如在本例中,使用颁发者的名称从 CurrentUser 的存储检索证书:
如果已向用户发送客户端证书,出于某种原因,该证书不会添加到用户的证书存储中,这时可借助类似下面的代码从证书文件创建一个 X509Certificate 对象:
无论 X509Certificate 是以何种方式创建的,在客户端处的最后步骤都是将证书添加到 WebRequestHandler ClientCertificates 集合中,然后使用配置好的 WebRequestHandler 创建 HttpClient:
自托管环境中的授权 虽然在自托管环境中无法使用 HttpModule,但是在自托管服务处理管道的靠前部分中,请求的保护过程是相同的: 从请求获取凭据,使用该信息对请求进行身份验证,创建标识并传递给当前线程的 CurrentPrincipal 属性。最简单的机制是创建一个用户名和密码验证程序。如果除了验证用户名和密码组合外,还需要执行其他工作,则可创建一个委派处理程序。我将首先介绍如何整合一个用户名和密码验证程序。 要创建验证程序(仍假定使用基本身份验证),必须创建继承自 UserNamePasswordValidator 的类(需要在项目中添加对 System.IdentityModel 库的引用)。唯一需要重载的基类方法是 Validate 方法(接受两个参数,分别为客户端发送给服务的用户名和密码)。跟以前一样,验证完用户名和密码后,必须创建一个 GenericPrincipal 对象并用其设置 Thread 类上的 CurrentPrincipal 属性(由于未使用 IIS 作为主机,因而不必设置 HttpContext User 属性):
下面的代码为名为 Customers 的控制器创建了一个主机,端点为 http:///MyServices,并指定了一个新的验证程序:
消息处理程序 如果除了验证用户名和密码外,还需要执行其他工作,则可创建自定义 Web API 消息处理程序。与 HTTP 模块相比,消息处理程序具有多项优势: 消息处理程序不依赖 IIS,在消息处理程序中应用的安全性对任意主机都有效;消息处理程序仅由 Web API 使用,它们提供了一种执行服务验证(以及分配标识)的简单方式:采用不同于 Web 站点页面所用的流程;此外,还可将消息处理程序分配给特定路由,以使安全代码仅在需要时才被调用。 创建消息处理程序的第一步是编写一个继承自 DelegatingHandler 的类并重载其 SendAysnc 方法:
在该方法中(假定创建的是与路由一一对应的处理程序),可以设置 DelegatingHandler 的 InnerHandler 属性,以便能够将该处理程序链接至管道中的其他处理程序:
对于本示例,我假设有效的请求必须在其查询字符串中包含简单的令牌(非常简单: 一个“authToken=xyx”形式的名称/值对)。如果缺少令牌或未设置为 xyx,则代码返回 403 (Forbidden) 状态码。 首先,我调用传递给该方法的 HttpRequestMessage 对象的 GetQueryNameValuePairs 方法,将查询字符串转换为一组名称/值对。然后,使用 LINQ 检索令牌(如果缺少令牌,则返回 null)。如果缺少令牌或令牌无效,则创建一个包含适当 HTTP 状态码的 HttpResponseMessage,然后将其封装在 TaskCompletionSource 对象中并返回:
如果令牌存在且已设置正确的值,则创建一个 GenericPrincipal 对象并用其设置 Thread 的 CurrentPrincipal 属性(为支持在 IIS 下使用该消息处理程序,我还在 HttpContext 对象不为 null 时设置了 HttpContext User 属性):
通过令牌和标识集对请求进行身份验证后,消息处理程序调用基类方法继续处理:
若要对所有控制器应用消息处理程序,则可将其添加到 Web API 处理管道中(就像对待任意其他消息处理程序那样)。但是,若要将处理程序限制为只应用至特定路由,则必须通过 MapHttpRoute 方法添加它。首先,将类实例化,然后将其作为第五个参数传递给 MapHttpRoute(该代码需要一条针对 System.Web.Http 的 Imports/using 语句):
如果不想在 DelegatingHandler 中设置 InnerHandler,也可在定义路由时,将 InnerHandler 属性设置为默认调度程序:
这样,InnerHandler 设置不会分散到多个 DelegatingHandlers 之间,可从单一位置(即定义路由的位置)对其进行管理。 扩展主体 如果按名称和角色授权请求达不到您的要求,则可通过实现 IPrincipal 接口来创建自建主体类,以扩展授权过程。但是,要充分利用自定义主体类,需要创建自定义授权属性,或向服务方法添加自定义代码。 例如,如果有一组服务只能由来自特定区域的用户访问,则可创建一个简单的主体类(实现 IPrincipal 接口并添加 Region 属性),如图 2 所示。 图 2 创建具有额外属性的自定义主体
要充分利用这一新的主体类(适用于任意主机),只需将其实例化,然后用其设置 CurrentPrincipal 和 User 属性。下面的代码将在请求的查询字符串中查找与名称“region”关联的值。检索到该值后,代码通过将该值传递给类的构造函数来设置主体的 Region 属性:
如果使用的是 Microsoft .NET Framework 4.5,则应从新的 ClaimsPrincipal 类继承,而不是实现 IPrincipal 接口。ClaimsPrincipal 同时支持基于声明的处理和与 Windows Identity Foundation (WIF) 的整合。但这些内容超出了本文的论述范围(我会在以后基于声明的安全性的相关文章中讨论这个话题)。 授权自定义主体 新主体对象就绪后,可创建一个授权属性,以利用该主体所携带的新数据。首先,创建一个继承自 System.Web.Http.AuthorizeAttribute 的类,并重载其 IsAuthorized 方法(这是一个不同于 ASP.NET MVC 做法的过程,ASP.NET MVC 是通过扩展 System.Web.Http.Filters.AuthorizationFilterAttribute 来创建新的 Authorization 属性)。IsAuthorized 方法接收一个 HttpActionContext 对象(可将其属性用作授权过程的一部分)。但是,本示例只需从 Thread 的 CurrentPrincipal 属性提取主体对象,将其转换为自定义主体类型,并检查 Region 属性。如果授权成功,代码返回 true。如果授权失败,则需要在返回 false 前借助 ActionContext Response 属性创建自定义响应,如图 3 所示。 图 3 筛选自定义主体对象
可将自定义授权筛选器当作默认的 ASP.NET Authorize 筛选器使用。由于该筛选器具有 Region 属性,作为用其修饰服务方法的一部分,必须将该属性设置为此方法可接受的区域:
对于本示例,我选择了从 AuthorizeAttribute 继承,这是因为我的授权代码只占用 CPU。如果我的代码需要访问某些网络资源(或执行任意 I/O 操作),则实现 IAuthorizationFilter 接口是更好的选择(因其支持异步调用)。 正如本文开头所述: 典型的 Web API 场景无需额外授权,除非需要抵御 CSFR 攻击。但如果需要扩展默认的安全系统,则 Web API 提供了丰富的选择(覆盖整个处理管道,可整合所需的任意防护措施)。有选择总是件好事。 |
|