使用具有表单身份验证和 ISAPI 的 .NET 反向代理保护非 .NET 资产






4.65/5 (10投票s)
Aug 27, 2006
15分钟阅读

85651

601
如何使用 .NET 反向代理、ISAPI 重定向过滤器和 .NET 表单身份验证来保护安全资产
引言
我的一位朋友最近向我提出了一个问题,这次不是那种需要大量心理咨询的问题。他有一个小型公司网站,他想使用 .NET 表单身份验证和声明式安全来锁定该网站。我当然说没问题,庆幸他不是来咨询他又一段失败的感情。我解释说,.NET 表单身份验证非常适合锁定 ASP.NET 应用程序。它提供基于角色的授权,并且实现起来非常简单。但是,他指出,并非他所有的安全资产都是 .NET 文件。他还需要保护 PDF 文档、HTML 文件和一些经典的 ASP 页面。我很快意识到,与他大多数问题不同,这个问题无法通过心理咨询或酒精解决,而需要仔细地结合 C++ 和 .NET。
关于 ASP.NET 表单身份验证
ASP.NET 表单身份验证是 Web 应用程序中一种功能强大且直接的安全机制,但由于它运行在 ASP.NET 进程内,因此对于保护非 .NET 资产几乎没有用处。
在 Windows 2003 中,IIS6 提供了一个名为“通配符映射”的功能,允许您构建一个 .NET HTTP 处理程序来拦截每个 HTTP 请求。这可以用于身份验证等目的。如果需要由非托管 ISAPI 处理程序处理像经典 ASP 文件这样的安全非 .NET 资产,HTTP 处理程序会将控制权交还给 IIS。不幸的是,我朋友的网站运行在 Windows 2000 上,这意味着 IIS5,这意味着没有那个很酷的“通配符映射”功能,这意味着没有快速的解决方案,这意味着我将一个美丽的夏日周末与我的笔记本电脑和必不可少的 Fritos 袋子一起度过。
如果您不明白上面一段话中的任何一个词(别担心,我习惯被误解),下面的图表应该有助于解释表单身份验证的问题
这说明了 .NET 表单身份验证的请求链。正如您所见,它只能用于保护 ASP.NET 资产。
经过仔细考虑和几袋 Fritos 之后,我决定解决这个问题的办法是创建一个反向代理。
反向代理解决方案
很多人问我:反向*什么*?如果您正在阅读这篇文章,您可能已经知道什么是代理服务器了。通常,它位于客户端工作站和外部世界之间,拦截客户端的出站请求,然后代表客户端将请求转发到目标。代理服务器用于多种原因,包括安全、请求修改和日志记录。反向代理与标准代理完全相同,除了……嗯……反过来。
简单来说,反向代理的主要功能就像夜总会里的保安。未经其批准,任何人都无法进入。主要区别在于,反向代理不关心您是否是 Paris Hilton。它的作用是拦截对安全资产的入站请求,然后决定是否允许它们通过。
通常,安全服务器配置为只允许来自反向代理的请求,从而确保恶意用户无法“绕过保安”。反向代理还可以用于混淆 URL;换句话说,隐藏安全资产的真实地址,使其对最终用户不可见。一旦反向代理拦截并验证了请求,它就会获取安全资产并将其流式传输回客户端,可能在出去的路上添加一些响应头。
下面的序列图说明了一个非常简单的反向代理
到这个时候,您可能已经猜到反向代理服务器将是一个受 .NET 表单身份验证保护的 ASP.NET 应用程序,并且我们将使用 HTTPWebRequest
和 HTTPWebResponse
对象来检索和提供安全资产。
啊,您可能会说,但是我们如何强制 ASP.NET 反向代理服务器在每个请求到达默认 IIS 处理程序之前就拦截它呢?答案是创建一个非常简单的 ISAPI 过滤器,将每个入站请求重定向到 ASP.NET 代理。将 ISAPI 过滤器想象成一个交通警察,他的工作是确保每个人在继续前进之前都被引导到代理。
下图说明了这是如何工作的
有了反向代理,实际内容就驻留在单独的虚拟目录中,甚至可能驻留在物理上独立的服务器上。这让我想起了一个老女友,她会在争吵后好几天不和我说话。在那些安静的时期,她的姐姐经常充当调解人,在我们之间传递消息。出于某种原因,反向代理总是让我想起她。但说跑题了……
整合
所有这些术语理论上都很好,但它实际上是如何工作的呢?
- 用户请求一个名为 *http://www.mycomp.com/proxyweb/sales.html* 的页面。
- 安装在 *www.mycomp.com* 上的 ISAPI 过滤器会拦截该请求,并确定 *sales.html* 页面位于反向代理保护的位置。过滤器会修改 HTTP 请求上的 URL 头,使其指向 *http://www.mycomp.com/proxyweb/proxy.aspx?origUrl=/sales.html*。请求中的所有表单 POST 数据和二进制数据都保持不变。
- 如果用户没有表单身份验证 Cookie,*proxy.aspx* 会将其重定向到 *http://www.mycomp.com/proxyweb/login.aspx*。ISAPI 过滤器会忽略对 *login.aspx* 的请求,因为过滤器知道 *login.aspx* 需要匿名访问。
- 一旦用户通过身份验证,*proxy.aspx* 就会将相对 URL(*/sales.html*)映射到受保护的 URL(*http://secure.my.net/secure/sales.html*)。它会生成一个
HTTPWebRequest
对象,使用HTTPWebResponse
对象捕获来自安全服务器的响应,并将其提供给客户端。这里有一个假设,即安全网站或虚拟目录只接受来自反向代理的请求。这可以通过编程方式实现,或通过使用 IPSec、模拟帐户或防火墙规则来实现。
为简单起见,本文提供的代码旨在将代理和安全应用程序安装在同一物理机上的不同虚拟文件夹中。基本架构在此图表中说明
现在我们已经把一切都讲清楚了,是时候做有趣的部分了,我们将开始查看一些代码。我敢打赌您一直在想我什么时候才会说到代码,对吧?
ISAPI 重定向过滤器
最好的起点是 ISAPI 过滤器。关于 ISAPI 过滤器和扩展,您需要了解的第一件事是它们必须用 C++ 创建。您需要了解的第二件事是它们是事件驱动的模块,可以插入 IIS。您需要了解的第三件事是它们的复杂性可能导致偏头痛和长期的抑郁。但别担心。我们编写的 ISAPI 过滤器可能是您遇到的最简单的。
在请求的各个阶段,IIS 都会引发各种事件,这些事件可以被过滤器捕获并相应地处理。我们感兴趣的事件是 SF_NOTIFY_PREPROC_HEADERS
。当 IIS 完成请求头预处理时,它会调用此事件。在此事件处理程序中,我们可以访问请求的 URL 以及其他头信息。这使我们能够在将请求交还给 IIS 之前修改请求。修改请求 URL 的代码如下
const char* PROXY_CONFIG_FILE = "c:\\proxyconfig\\securitymap.txt"; CString clientHost = ""; CString realHost = ""; CString clientUrl = ""; CString realUrl = ""; CString loginPage = ""; CString logoutPage = ""; CString proxyUrl = ""; CUrlMap *configSettings; BOOL hasLoadedConfig = false; DWORD CTobyIsapiFilter::OnPreprocHeaders( CHttpFilterContext* pCtxt, PHTTP_FILTER_PREPROC_HEADERS pHeaderInfo) { // Check to see if the globals have // been loaded. If not, then load them if (!hasLoadedConfig) { // Load the globals from a file loadConfigSettings(); hasLoadedConfig=true; } // Get the URL contained in the request header. // TODO: Add some buffer checking here char buffer[1024]; DWORD buffSize = sizeof(buffer); BOOL bHeader = pHeaderInfo->GetHeader(pCtxt->m_pFC, "url", buffer, &buffSize); CString urlString(buffer); urlString.MakeLower(); // Get the URL for the proxy and append // the request URL to a query string // unless the request URL is not // to be proxied (for example, login and logout pages) CString newUrl = configSettings->getUrl(urlString); char *pNewUrlString = newUrl.GetBuffer(newUrl.GetLength()); // Note that we can use SetHeader to add custom // headers as well, although they will be prefixed // with HTTP (for example, "ProxyConfig" becomes // "HTTP_ProxyConfig" when read from ServerVariables) pHeaderInfo->SetHeader(pCtxt->m_pFC, "url", pNewUrlString); char *pProxyUrlString = (char *)configSettings->proxyUrl; pHeaderInfo->SetHeader(pCtxt->m_pFC, "proxyconfig:", pProxyUrlString); return SF_STATUS_REQ_HANDLED_NOTIFICATION; } ////////////////////////////////////////////////////////////////////// // getUrl(): Checks the target URL that the user is trying to access. // Normally, this method returns a path to the proxy.aspx page with // a query string identifying the target page. But there are some // pages on the proxy server that are real (for example, login and // logout pages). We obviously don't want to proxy those. So this // method knows which pages to ignore. If it finds one of those // pages, it just returns the original URL without any modifications ////////////////////////////////////////////////////////////////////// CString CUrlMap::getUrl(CString origUrl) { // We are not going to redirect if the // login form is specified, otherwise we will // get caught in an infinite redirection loop if (origUrl.Find(this->loginPage)!=-1 || (origUrl.Find(this->logoutPage)!=-1)) { return origUrl; } // Make sure that we don't get caught // in an infinite redirection loop when // proxy.aspx consumes data from the // target site. You only need to include this // logic if the real site is in a virtual // directory on the same website as // the proxy site. If the real content // is on a separate website that the ISAPI // filter is not protecting, // then we don't need to do the following: if (origUrl.Find(this->realUrl)!=-1) { // realUrl example="/myrealsite/" return origUrl; } // This is the default behavior for all requests // to the website. We take the original URL and // add it to a query string so // that proxy.aspx can pick it up CString newUrl = this->proxyUrl; newUrl.Insert(newUrl.GetLength() + 1, "?origUrl="); newUrl.Insert(newUrl.GetLength() + 1, origUrl); return newUrl; } void CTobyIsapiFilter::loadConfigSettings() { // Initialize the configSettings object, // which will be held in memory by IIS configSettings = new CUrlMap(); // Read the configuration file ifstream myfile; myfile.open(PROXY_CONFIG_FILE); if (!myfile.good()) { // The file does not exist or cannot be read, // so populate default values clientHost = "https://"; realHost = "https://"; clientUrl = "/proxyweb/"; realUrl = "/myrealsite/"; loginPage = "/proxyweb/login.aspx"; logoutPage = "/proxyweb/logout.aspx"; proxyUrl = "/proxyweb/proxy.aspx"; myfile.close(); } else { char str[1024]; while (!myfile.eof()) { myfile.getline(str, 1024); CString m_line; m_line = str; if (m_line.Left(12) == "clienthost: ") { clientHost = m_line.Mid(12); } else if (m_line.Left(10) == "realhost: ") { realHost = m_line.Mid(10); } else if (m_line.Left(11) == "clienturl: ") { clientUrl = m_line.Mid(11); } else if (m_line.Left(9) == "realurl: ") { realUrl = m_line.Mid(9); } else if (m_line.Left(11) == "loginpage: ") { loginPage = m_line.Mid(11); } else if (m_line.Left(12) == "logoutpage: ") { logoutPage = m_line.Mid(12); } else if (m_line.Left(10) == "proxyurl: ") { proxyUrl = m_line.Mid(10); } } myfile.close(); } configSettings->clientHost = clientHost; configSettings->clientUrl = clientUrl; configSettings->realUrl = realUrl; configSettings->loginPage = loginPage; configSettings->logoutPage = logoutPage; configSettings->proxyUrl = proxyUrl; configSettings->realHost = realHost; hasLoadedConfig=true; }
配置设置
上面代码块开头引用的 SecurityMap.txt 文件描述了一个简单的配置文件,该文件描述了我们的代理设置。配置文件中的默认设置如下
[CONFIGSETTINGS]
proxyurl: /proxyweb/proxy.aspx
logoutpage: /proxyweb/logout.aspx
loginpage: /proxyweb/login.aspx
realurl: /myrealsite/
clienturl: /proxyweb/
realhost: https://
clienthost: https://
以下是对每个设置的简要概述
PROXYURL
:作为反向代理的页面的相对路径。当附加到CLIENTHOST
时,它提供了代理的完全限定 URL。LOGOUTPAGE
:注销页面的相对路径。对该页面的请求不会被 ISAPI 过滤器重定向。LOGINPAGE
:登录页面的相对路径。对该页面的请求不会被 ISAPI 过滤器重定向。REALURL
:实际内容所在的相对路径。当附加到REALHOST
时,它提供了安全网站或虚拟目录的完全限定 URL。CLIENTURL
:反向代理所在的相对路径。当附加到CLIENTHOST
时,它提供了安全网站或虚拟目录的完全限定 URL。REALHOST
:安全内容所在主机的名称。CLIENTHOST
:反向代理所在主机的名称。
SecurityMap.txt 文件在 IIS 启动时被 ISAPI 过滤器和 ProxyWeb 应用程序本身消耗并缓存。因此,如果您对配置文件进行了任何更改,则必须回收 IIS。
在附加的 ZIP 文件中,我提供了一个实用程序,允许您生成此文件并验证您的设置。securitymap.txt 文件必须存储在名为 c:\proxyconfig 的目录中。
身份验证和授权
现在 ISAPI 过滤器已将入站请求重定向到反向代理,我们需要考虑代理本身的安全性模型。我不会花太多时间讨论 .NET 表单身份验证的复杂性,因为网上已经有大量的优秀文档可用。对于本次演示,我包含了一个非常简单的表单身份验证实现,它依赖于 ProxyWeb 应用程序的 web.config 文件中存储的明文凭据进行身份验证,以及一个名为 Authorization.xml 的文件进行授权。
让我们从 web.config 文件开始
<authorization>
<deny users="?"/>
</authorization>
<authentication mode="Forms">
<forms loginUrl="login.aspx" slidingExpiration="true">
<credentials passwordFormat="Clear">
<user name="toby" password="Toto"/>
<user name="john" password="Toto"/>
<user name="jane" password="Toto"/>
<user name="paul" password="Toto"/>
</credentials>
</forms>
</authentication>
<authorization>
节点告诉 ASP.NET 应用程序拒绝所有未经身份验证的用户的访问。<authentication>
节点指定要使用的安全类型(在此情况下为表单身份验证)以及用于登录的 URL。上面的代码片段中提供了四种用户帐户。在实际场景中,您需要将凭据安全地存储在数据库或带有哈希密码的目录中。换句话说,请勿在家尝试上述技术!
授权规则在名为 c:\proxyconfig\authorization.xml 的文件中提供。这指定了每个用户的角色以及对单个资产的具体访问控制
<?xml version="1.0"?>
<!-- This is a very simple declarative security model
showing how roles can be applied
to .NET Forms Authentication -->
<authorization>
<roles>
<!-- Users in this section should match
the users specified in the "web.config" -->
<role name="administrators">
<user name="Toby" />
</role>
<role name="creators">
<user name="John" />
<user name="Paul" />
</role>
<role name="readers">
<user name="Jane" />
<user name="John" />
</role>
</roles>
<!-- Note that URLs have to be fully qualified and point
to the real (hidden) URL being locked down.
IMPORTANT: URLs must be in lower case -->
<permissions allow="administrators,creators,readers">
<location url="https:///myrealsite/default.aspx"
allow="*" />
<location url="https:///myrealsite/myphoto.gif"
allow="readers,administrators" />
<location url="https:///myrealsite/creatorsonly.html"
allow="creators" />
<location url="https:///myrealsite/readersonly.html"
allow="readers" />
</permissions></authorization>
<roles>
元素应该是不言自明的。<permissions>
元素包含一个默认规则,允许管理员、创建者和读取者角色的成员访问。此权限应用于所有安全资产,除非被后续的 <location>
元素覆盖。例如,default.aspx 页面将允许所有已通过身份验证的用户(由“*”通配符表示)访问,而 CreatorsOnly.html 页面将只允许“creators”角色的成员访问。
authorization.xml 文件,就像 securitymap.txt 文件一样,在 ProxyWeb 应用程序启动时被缓存。这在 global.asax 文件的 Application_Start
事件中处理
Sub Application_Start(ByVal sender As Object, ByVal e As EventArgs)
' Retrieve settings from the security.map
' file and cache them in application state
Dim sr As StreamReader = _
File.OpenText("c:\proxyconfig\securitymap.txt")
Do While sr.Peek > 0
Dim thisLine As String = sr.ReadLine.ToLower
If thisLine.StartsWith("clienthost: ") Then
Application.Add("clienthost", _
thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
ElseIf thisLine.StartsWith("realhost: ") Then
Application.Add("realhost", _
thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
ElseIf thisLine.StartsWith("clienturl: ") Then
Application.Add("clienturl", _
thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
ElseIf thisLine.StartsWith("realurl: ") Then
Application.Add("realurl", _
thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
ElseIf thisLine.StartsWith("loginpage: ") Then
Application.Add("loginpage", _
thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
ElseIf thisLine.StartsWith("logoutpage: ") Then
Application.Add("logoutpage", _
thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
ElseIf thisLine.StartsWith("proxyurl: ") Then
Application.Add("proxyurl", _
thisLine.Substring(thisLine.IndexOf(": ") + 2).Trim)
End If
Loop
' Cache the authorization XML file in application
' state so we don't have to keep loading it
Dim xmlAuth As New XmlDocument
xmlAuth.Load("c:\proxyconfig\authorization.xml")
Application.Add("authorization", xmlAuth)
sr.Close()
End Sub
接下来我们应该看看 login.aspx 页面,该页面负责对用户进行身份验证并颁发表单身份验证 Cookie
Partial Class Login
Inherits System.Web.UI.Page
Protected Sub Button1_Click(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles Button1.Click
FormsAuthentication.Initialize()
' Authenticate using the credentials provided in the web.config file
If FormsAuthentication.Authenticate(TextBox1.Text, _
TextBox2.Text) Then
lblError.Text = String.Empty
' Issue the forms auth cookie for this
' session and redirect to the default page
Dim authTicket As New FormsAuthenticationTicket(1, _
TextBox1.Text, DateTime.Now, _
DateTime.Now.AddMinutes(30), _
False, GetRoles(TextBox1.Text))
Dim encryptedTicket As String = _
FormsAuthentication.Encrypt(authTicket)
Dim authCookie As New _
HttpCookie(FormsAuthentication.FormsCookieName, _
encryptedTicket)
Context.Response.Cookies.Add(authCookie)
Response.Redirect("default.aspx", True)
Else
lblError.Text = "Authentication Failed"
End If
End Sub
Private Function GetRoles(ByVal username As String) As String
' Load the XML permissions file
Dim perms As XmlDocument = _
CType(Application("authorization"), XmlDocument)
' Get the user's roles from the XmlDocument object
Dim xmlRoles As XmlNodeList = _
perms.SelectNodes("authorization/roles/role")
Dim userRoles() As String = New String() {}
For Each xmlRole As XmlNode In xmlRoles
Dim xmlRoleName As String = xmlRole.Attributes("name").Value
For Each xmlRoleUser As XmlNode In xmlRole.ChildNodes
Dim roleUser As String = xmlRoleUser.Attributes("name").Value
If roleUser.ToLower.Equals(username.ToLower) Then
Array.Resize(userRoles, userRoles.Length + 1)
userRoles(userRoles.Length - 1) = xmlRoleName
End If
Next
Next
Return String.Join(",", userRoles)
End Function
End Class
此代码段颁发一个表单身份验证 Cookie,用于访问代理本身(proxyweb\proxy.aspx)。我们提供给 FormsAuthenticationTicket
对象构造函数的参数之一是经过身份验证的用户的角色列表(逗号分隔)。这由 GetRoles()
方法从 authorization.xml 文件中提取。
安全模型中还有最后一步,这让我们回到 global.asax 文件。当 ProxyWeb 应用程序的请求通过身份验证时,将触发 Application_AuthenticateRequest
事件。此时,我们将创建一个包含当前用户身份信息的 GenericPrincipal
对象,并将其添加到 HTTP 上下文中。这将允许我们在代理本身内执行声明式基于角色的授权。
Protected Sub Application_AuthenticateRequest(ByVal sender _
As Object, ByVal e As System.EventArgs)
If Request.IsAuthenticated Then
' Get the roles from the FormsAuthenticationTicket
' and create a GenericPrincipal object
' that will be added to the current HTTP context
Dim authCookie As HttpCookie = _
Context.Request.Cookies(FormsAuthentication.FormsCookieName)
Dim authTicket As FormsAuthenticationTicket = _
FormsAuthentication.Decrypt(authCookie.Value)
Dim userRoles() As String = authTicket.UserData.Split(CType(",", Char()))
Dim identity As New FormsIdentity(authTicket)
Dim userPrincipal As New _
Principal.GenericPrincipal(identity, userRoles)
Context.User = userPrincipal
End If
End Sub
现在只需添加一个代理
现在,您一直在等待的部分;代理代码本身。这可能有点令人失望,因为代码实际上非常简单。ISAPI 过滤器会将请求重定向到 proxy.aspx,并附带一个名为“origUrl
”的查询字符串,其中包含请求的 URL。Proxy.aspx 期望接收此查询字符串,并将其转换为一个完全限定的 URL,然后可以将其提供给 HTTPWebRequest
对象。在获取 HTTP 响应后,proxy.aspx 会将其流式传输回客户端。从用户的角度来看,所有这些都是完全透明的。他们甚至不会在浏览器中看到查询字符串。
Private clientHost As String
Private realHost As String
Private clientUrl As String
Private realUrl As String
Private loginPage As String
Private logoutPage As String
Private proxyUrl As String
Private Const APP_CLIENTHOST As String = "clienthost"
Private Const APP_REALHOST As String = "realhost"
Private Const APP_CLIENTURL As String = "clientUrl"
Private Const APP_REALURL As String = "realUrl"
Private Const APP_LOGINPAGE As String = "loginpage"
Private Const APP_LOGOUTPAGE As String = "logoutpage"
Private Const APP_PROXYURL As String = "proxyUrl"
Private Const PRE_HTTP As String = "http://"
Private Const DEFAULT_PAGE As String = "default.aspx"
Private Const QS_ORIGURL As String = "origurl"
Private Const ERR_ACCESS_DENIED As String = _
"<h1>Access Denied.</h1><h2>" & _
"You do not have the appropriate role " & _
"to access this resource</h2>"
Private Const HTTP_FORMSID As String = "FORMS-ID"
Private Const HTTP_ROLES As String = "ROLES"
Private Const ERR_PROXY As String = _
"The following error occurred on the proxy: "
Private Const AUTH_ALLOW As String = "allow"
Private Const AUTH_DENY As String = "deny"
Protected Sub Page_Load(ByVal sender As Object, _
ByVal e As System.EventArgs) Handles Me.Load
' This is to correct a weird bug with HTTP posts
' in the 2.0 version of HTTPWebRequest
System.Net.ServicePointManager.Expect100Continue = False
' Set up the globals
clientHost = Application(APP_CLIENTHOST).ToString
realHost = Application(APP_REALHOST).ToString
clientUrl = Application(APP_CLIENTURL).ToString
realUrl = Application(APP_REALURL).ToString
loginPage = Application(APP_LOGINPAGE).ToString
logoutPage = Application(APP_LOGOUTPAGE).ToString
proxyUrl = Application(APP_PROXYURL).ToString
' Establish the real URL that the user is trying to request
Dim origUrl As String = String.Empty
If Request.QueryString(QS_ORIGURL) Is Nothing OrElse _
Request.QueryString(QS_ORIGURL).Length.Equals(0) Then
' This is just somewhere to send users
' when no URL has been provided
origUrl = realUrl & "/default.aspx"
Else
origUrl = Request.QueryString(QS_ORIGURL)
End If
Dim targetUrl As String = GetTargetUrl(origUrl)
' Now we have the target URL and the user's roles,
' it is time to authorize them. If declarative security fails,
' then don't go any further
If Not AuthorizeUser(targetUrl) Then
Context.Response.Write(ERR_ACCESS_DENIED)
Context.Response.End()
End If
' It's SHOWTIME!
Dim proxyRequest As HttpWebRequest = _
WebRequest.Create(targetUrl)
' Set up the request, starting by adding the true
' identity of the client to the request so that it can
' be read by the target application by
' reading the "HTTP_FORMS_ID" server variable.
proxyRequest.Headers.Add(HTTP_FORMSID, _
Context.User.Identity.Name)
' Retrieve the roles for the authenticated user
Dim authCookie As HttpCookie = _
Request.Cookies(FormsAuthentication.FormsCookieName)
Dim authTicket As FormsAuthenticationTicket = _
FormsAuthentication.Decrypt(authCookie.Value)
Dim userRoles As String = authTicket.UserData
' Add a header that describes the user's roles.
' This will be available via HTTP_ROLES
proxyRequest.Headers.Add(HTTP_ROLES, userRoles)
' TODO: This is where you might use impersonation
' to ensure that only the proxy can request a secure
' page. You would do this by creating an instance
' of NetworkCredentials and applying it to the Credentials
' property of the HttpWebRequest object. But we're not
' doing that today (hey, do you need me to do everything
' for you?) Instead, we'll just use
' default credentials as a placeholder.
proxyRequest.Credentials = CredentialCache.DefaultCredentials
' This tells the secure asset that the request originated
' with proxy.aspx, although it can be spoofed,
' so don't rely too much on it
proxyRequest.Referer = realHost & proxyUrl
proxyRequest.Accept = Request.Headers.Get("Accept")
' Add client cookies to the request
' in case the secure asset requires them
proxyRequest.Headers.Add("Cookie", _
Context.Request.Headers.Get("Cookie"))
' Slightly different techniques are used for a GET
' and POST request to ensure that posted form
' fields can be provided to the secure asset
Select Case Request.RequestType
Case "GET"
' Now the request has been set up,
' get the response (or at least try to)
Dim getResponse As HttpWebResponse = Nothing
Try
getResponse = proxyRequest.GetResponse
Catch ex As WebException
Dim errMsg As String = ex.Message
Response.Write(ERR_PROXY & errMsg)
Context.Response.End()
End Try
Dim getStream As Stream = getResponse.GetResponseStream
' Decide whether to stream binary content
' or simply write it to the client
Dim contentType As String = getResponse.ContentType.ToLower
Response.ContentType = contentType
If (Not contentType.Contains("html")) _
AndAlso (Not contentType.Contains("xml")) _
AndAlso (Not contentType.Contains("javascript")) Then
' This is binary content
Dim receiveBuffer(1024) As Byte
' Create 1K chunks to spit out to the client
Dim byteFlag As Integer = 0
Dim ms As New MemoryStream
' Read the binary content into a memorystream object
Do
byteFlag = getStream.Read(receiveBuffer, _
0, receiveBuffer.Length)
ms.Write(receiveBuffer, 0, byteFlag)
Loop Until byteFlag.Equals(0)
' Write the binary stream to the client
Response.BinaryWrite(ms.ToArray)
Else
' Return some cookies to the client,
' just because we love them...
Context.Response.ClearHeaders()
For i As Integer = 0 To getResponse.Headers.Count - 1
Context.Response.AddHeader(getResponse.Headers.Keys(i), _
getResponse.Headers(i))
Next
' Process the main content and stream it to the client
Dim readStream As New StreamReader(getStream)
Dim content As String = readStream.ReadToEnd
Context.Response.Write(content)
readStream.Close()
End If
' Clean out the trash
getResponse.Close()
getStream.Close()
Context.Response.End()
Case "POST"
' This block is all about writing post data to the request
proxyRequest.Method = "POST"
proxyRequest.ContentType = Context.Request.ContentType
proxyRequest.ContentLength = Context.Request.ContentLength
Dim postStream As StreamReader = _
New StreamReader(Context.Request.InputStream)
Dim forwardStream As New _
StreamWriter(proxyRequest.GetRequestStream)
forwardStream.Write(postStream.ReadToEnd)
forwardStream.Close()
postStream.Close()
' From this point on, the code looks
' very similar to the "GET" scenario
Dim getResponse As HttpWebResponse = Nothing
Try
getResponse = proxyRequest.GetResponse()
Catch ex As WebException
Dim errMsg As String = ex.Message
Response.Write(ERR_PROXY & errMsg)
Context.Response.End()
End Try
Dim getStream As Stream = _
getResponse.GetResponseStream
' Send some cookies to the client,
' just because we love them...
Context.Response.ClearHeaders()
For i As Integer = 0 To getResponse.Headers.Count - 1
Context.Response.AddHeader(getResponse.Headers.Keys(i), _
getResponse.Headers(i))
Next
' Process the main content and stream it to the client
Dim readStream As New StreamReader(getStream)
Dim content As String = readStream.ReadToEnd
Context.Response.Write(content)
' Clean out the trash
getResponse.Close()
getStream.Close()
readStream.Close()
Context.Response.End()
End Select
End Sub
Private Function GetTargetUrl(ByVal originalUrl As String) As String
'First, we will replace references to the proxy
'website with references to the real website
originalUrl = originalUrl.Replace(clientUrl, realUrl)
'Now, we do the same with the server name
originalUrl = originalUrl.Replace(clientHost, realHost)
'The URL must begin with "http://[realhost]/"
If Not originalUrl.StartsWith(PRE_HTTP) Then
originalUrl = realHost & originalUrl
End If
'Make sure that we are pointing at the real host
If Not originalUrl.StartsWith(realHost) Then
originalUrl = originalUrl.Replace(PRE_HTTP, realHost)
End If
'Make sure we have a default page
If originalUrl.EndsWith("/") Then
originalUrl &= DEFAULT_PAGE
End If
Return originalUrl
End Function
从上面的代码片段中,您会注意到,在确保用户有权查看所请求的 URL 之前,我们甚至不会创建 HTTPWebRequest
对象。此检查是在 AuthorizeUser()
方法中执行的
Private Function AuthorizeUser(ByRef TargetUrl As String) As Boolean
' Deny access to all authenticated users by default
Dim Allow As Boolean = False
Dim xmlAuth As XmlDocument = Application("authorization")
' Get the default rule that is applied at the root of the website
Dim defaultRule As String = _
xmlAuth.SelectSingleNode("authorization" & _
"/permissions").Attributes(AUTH_ALLOW).Value
' A wildcard means that everybody is allowed access
' by default. If a list of groups is specified,
' then they are allowed by default.
If defaultRule.Equals("*") Then
Allow = True
Else
For Each rootPermission As String In defaultRule.Split(",")
If User.IsInRole(rootPermission) Then
Allow = True
Exit For
End If
Next
End If
' Discover if the requested page has an
' explicit permission set in the XML rules
Dim protectedPages As XmlNodeList = _
xmlAuth.SelectNodes("authorization/permissions" & _
"/location[@url='" & TargetUrl.ToLower & "']")
' If no rule exists for the current page,
' then just return the permission we already have
If protectedPages Is Nothing OrElse _
protectedPages.Count.Equals(0) Then
Return Allow
End If
' An authorization rule exists for the page,
' so assume that we will deny access unless an "allow" rule is in
' place for the current user's role
Allow = False
' Enumerate the rules that have been applied to the current page
For Each urlNode As XmlNode In protectedPages
Dim urlAllow As String = String.Empty
Dim urlDeny As String = String.Empty
If Not urlNode.Attributes(AUTH_ALLOW) Is Nothing Then
urlAllow = urlNode.Attributes(AUTH_ALLOW).Value
End If
If Not urlNode.Attributes(AUTH_DENY) Is Nothing Then
urlAllow = urlNode.Attributes(AUTH_DENY).Value
End If
' Check the "allow" permissions for the URL
If urlAllow.Length > 0 Then
' A wildcard means everybody is allowed
If urlAllow.Equals("*") Then
Allow = True
Else
' See if the list of allowed roles
' matches one of the user's roles
For Each urlPermission As String In urlAllow.Split(",")
If User.IsInRole(urlPermission) Then
Allow = True
Exit For
End If
Next
End If
End If
' Now check "deny" permissions for the URL. Wildcards
' are pointless here, because we are
' already denying access by default.
If urlDeny.Length > 0 Then
For Each urlDenial As String In urlDeny.Split(",")
If User.IsInRole(urlDenial) Then
Allow = False
Exit For
End If
Next
End If
Next
Return Allow
End Function
解决方案清单
附加的 ZIP 文件包含以下代码
- \ProxyWeb - 这是要在 IIS 中安装的代理网站。
- \MyRealSite - 一个将被代理保护的演示网站。
- \ProxyConfig - 必须复制到 C:\proxyconfig\ 的文件。
- \TobyISAPI - ISAPI 过滤器。这必须安装在 ProxyWeb 应用程序所在的网站根目录下。
- \ReverseProxyConfig - 用于创建 securitymap.txt 配置文件实用程序。
安装
- 首先,您必须安装 ISAPI 过滤器。
- 在 Internet 服务管理器中,右键单击 /proxyweb 虚拟目录所在的网站根目录。选择 ISAPI 筛选器选项卡,然后安装 TobyIsapiFilter.dll 文件。
- 通过在命令行输入 IISRESET 来回收 IIS。过滤器现在应该已加载。
- 创建一个名为 C:\ProxyConfig 的目录。将 SecurityMap.txt 和 Authorization.xml 复制到其中。确保所有人至少拥有该目录的读取权限(我们至少需要授予 ASPNET、System、IWAM 和 IUSR 帐户的访问权限)。
- 在 IIS 中,创建一个名为 \ProxyWeb 的虚拟目录,以及另一个名为 \MyRealSite 的虚拟目录。这两个虚拟目录都应位于安装 ISAPI 过滤器所在的根目录下。
- 将 \ProxyWeb 和 \MyRealSite 文件夹的内容复制到这些虚拟文件夹中。
要测试应用程序
- 输入 https:///proxyweb/test.asp。如果您尚未通过身份验证,您将被重定向到登录页面。
- 输入 ID“Toby”和密码“Toto”以获取您的身份验证 Cookie。
- 您将看到,即使“/proxyweb”虚拟文件夹中没有名为“default.aspx”的页面,它也会为您提供服务,就好像它在那里一样。
- 尝试使用其他 ID(John、Jane 和 Paul)登录,每个 ID 都有不同的角色,您将看到授权是如何应用的。
考虑因素
如果您正在开发一个位于反向代理后面的应用程序,您应该注意以下几点:
- 在您的 HTML 中,指向代理后面的其他 Web 资产(如图像和样式表)的链接和路径必须是相对的。例如,如果您硬编码到 http://secure.my.net/images/mypage.jpg 的链接,客户端将无法访问它,因为假定 secure.mycomp.com 域受到防火墙保护,并且只有反向代理可以直接访问它。实现此链接的正确方法是 /images/mypage.html。这将导致客户端看到 http://www.mycomp.com/proxyweb/images/mypage.jpg,这将确保请求被 ISAPI 过滤器捕获并重定向到 Proxy.aspx。
- 根据定义,反向代理是单点故障。如果它出现故障,您将失去对其所保护的一切的访问权限,因此请确保您有一个良好的灾难恢复计划。此外,您需要确保代理在负载下经过彻底测试,并确保您有足够的负载均衡服务器来处理将通过反向代理的所有流量。
未来的增强
这是我为朋友实现的非常基础的反向代理解决方案的演示。生产版本要复杂一些,但这段代码构成了它的基础。在此期间,这里有一些关于如何扩展该解决方案的想法
- 不要使用 proxy.aspx,而是将反向代理实现为 HTTPModule。这可以提高性能,因为请求将在请求链的更高层次被拦截。
- 修改 ISAPI 过滤器和 proxy.aspx 代码,以便单个代理实例可以支持多个安全网站。
- 增强 proxy.aspx,使其能够修改提供给客户端的 HTML。例如,您可以确保指向图像和链接的任何 URL 都指向代理,而不是实际的安全服务器,后者对客户端应该是不可见的。
- 使用模拟来实现代理的额外安全性。在附加的 test.asp 页面中,我使用
HTTP_REFERER
服务器变量来确保它只处理由 proxy.aspx 引起的请求。如果您买不起防火墙或无法实现 IPSEC,模拟是实现相同效果的另一种简单方法。只需创建一个 Windows 帐户,并将您的安全资产锁定在 NTFS 中,以确保只有模拟帐户才能查看它们。在 proxy.aspx 代码中,创建一个基于 Windows 帐户的NetworkCredential
对象,并将其附加到HTTPWebRequest
对象。如果您没有 IPSEC 或防火墙来保护它们,这可以防止未经授权的个人直接访问您的安全资产。
结论
附加代码为我朋友现在用来保护各种资产的极其健壮的解决方案奠定了基础。它甚至为几个 JBoss 服务器提供了安全性,证明了这种解决方案是平台无关的,尽管前端解决方案基于 .NET 表单身份验证。
Needless to say, my friend was thrilled, and he paid me in full. Yessir, one bottle of premium tequila is now mine. So that single weekend alone with my laptop and a steady supply of Fritos was for a good cause after all!
历史
- 版本 1.0:2006 年 8 月 27 日。