本文英文原版及代码下载:http://www.asp.net/learn/security/tutorial-03-cs.aspx
Security Tutorials系列文章第三章:Forms Authentication Configuration and Advanced Topics
导言:
在上一章,我们探讨了在ASP.NET应用程序了执行forms authentication所必须的步骤,如何在Web.config文件里指定from配置以创建一个登录页面,对认证用户和匿名用户分别显示不同的内容.记得我们通过将<authentication>元素的mode属性配置为Forms以使站点使用forms authentication.其实<authentication>元素还可以任意的包含一个<forms>子元素,通过该子元素,我们可以指定各类forms authentication配置.
在本文,我们将考察各种不同的forms authentication设置,以及如何通过<forms>元素来对它们进行修改.我们将详细的考察如何定制表单验证票据的timeout值,如何为登录页指定一个自定义URL(比如用SignIn.aspx替换掉默认的Login.aspx), 以及无cookie的表单认证票据.我们也将更加深入地考察表单验证票据的结构(makeup),看ASP.NET如何来确保票据的数据的安全.我们也会考察如何在表单认证票据里存储额外的用户数据,以及如何通过一个自定义principal object对象来对数据进行模型化(model this data).
第一步:考察<forms>配置选项
ASP.NET里的forms authentication system提供了一系列的配置选项.比如表单验证票据的生命周期;对票据实施何种保护;在何种条件下使用无cookie的票据;登录页面的路径等等.为了修改这些默认的值,我们要在<authentication>元素里添加一个子元素<forms>,指定的属性值要像XML属性那样,比如:
<authentication mode="Forms">
<forms propertyName1="value1"
propertyName2="value2"
...
propertyNameN="valueN" />
</authentication>
表1对可以通过<forms>元素进行定制的属性进行了汇总,由于Web.config是一个 XML文件, 在表左边的attribute名称都是区分大小写的.
Table 1: <forms>元素属性汇总
在ASP.NET 2.0及更高版本,默认的forms authentication值都是在.NET Framework里的FormsAuthenticationConfiguration类里“硬编码”的.任何的修改都应在Web.config里依据application-by-application的原则进行.这与ASP.NET 1.x不同,在ASP.NET 1.x里,默认的forms authentication值是存储在machine.config文件里的(因此可以通过machine.config来进行修改).关于这一点,有必要提一下,ASP.NET 1.x里的很多forms authentication配置与ASP.NET 2.0及更高版本是不同的.如果你打算将应用程序从ASP.NET 1.x环境里迁移过来,知晓这些差异是很重要的.对这些差异,你可以查阅《the <forms> element technical documentation》.
注意:
有几个forms authentication配置,比如timeout, domain, 以及path,都对最终的表单认证票据cookie进行了详细的指定.关于cookies的更多信息,比如它们是如何进行工作的,它包含的哪些属性等,请查阅系列文章《Cookies tutorial》.
指定票据的Timeout值
表单认证票据就是用户身份的凭证(ticket).对基于cookie的票据而言,该凭证是以cookie的形式进行存储的,每次发出请求都会将该凭证发送到web server.从本质上来说,拥有了凭证,就相当于向系统宣称:“我是xxx,我已经登录,通过认证了”,在用户在页面间导航时记住用户的身份(identity).
表单验证票据不仅包含了用户的身份,还包含了其它的信息以确保凭证的真实性和安全性.毕竟,我们并不希望一个怀有恶意的用户创建一个假凭证或对某个真实有效的凭证秘密的进行修改.
票据包含的众多信息里面有一个叫做expiry(可理解为有效期),它是票据过期的日期和时间点.每次FormsAuthenticationModule对一个票据进行检查时都要确保其没有过期,如果过期了就销毁票据,将用户看作匿名用户.这种安全措施可以抵御重发攻击(replay attacks).没有expiry的话,如果某个黑客截获了一个有效的认证票据,它们就可以用这个偷来的票据向服务器发送一个请求,以通过认证,达到登录的目的.虽然expiry不能预防这种情况的发生,但它可以对这种攻击可以成功的窗口进行限制(it does limit the window during which such an attack can succeed.).
注意:
第3步将详细介绍forms authentication用于保护票据的技术.
当创建票据后,forms authentication system通过参考timeout设置来判断它的expiry.正如Table 1里提到的那样,默认设置是30分钟,意思就是说,票据在其创建之时起的30分钟内有效.
这样定义的是票据的绝对的过期时间,但开发人员通常希望执行的是灵活的过期时间,也就是说,在每次用户向站点发出请求时,只要票据还没有过期就重新设置过期时间.为此,我们要通过slidingExpiration配置选项来实现该行为.如果将它设置为true,每次FormsAuthenticationModule对用户通过认证后,就更新该用户的票据的expiry.如果设置为false (默认值), 就不对expiry进行更新,后果就是,用户票据一旦过了设置好的绝对过期点,用户就从认证用户变成匿名用户.
注意:
票据的expiry信息是一个绝对的日期和时间值,比如“August 2, 2008 11:34 AM.” 然而,该日期和时间值又与服务器所在的本地时间有关.我们假定服务器所在的区域采用的时间标准是Daylight Saving Time(DST)——也就是美国地区的时间要向前推进一个小时 (注:不知这样翻译是否恰当,地理知识忘得差不多了,翻译有误请指正!).我们来看看某个ASP.NET站点在DST执行时间(也就是2:00 AM)左右会发生什么有趣的事情.假定某个用户在March 11, 2008 1:55 AM登录网站,那么站点将会为他创建一个票据,该票据过期时间为March 11, 2008 2:25 AM(因为采用的是默认的30分钟).然而,到了2:00 AM,因为采用的是DST时间标准的缘故,时间自动跳到3:00 AM.当用户在登录6分钟后(也就是在3:01 AM)访问一个新页面时,FormsAuthenticationModule发现票据已经过期了,因此将用户重新导航到登录页面.关于这点以及票据的timeout的稀奇古怪的事情的探讨,请参阅Stefan Schackow所著的《Professional ASP.NET 2.0 Security, Membership, and Role Management》(ISBN: 978-0-7645-9698-8).
图1阐述的是将slidingExpiration设置为false,且timeout为30时的工作流程.注意,登录是创建的票据包含了票据的有效期,且在以后的请求发生时不对有效期进行更新.如果FormsAuthenticationModule发现票据过期了就摒弃它,并将用户当成匿名用户.
图1
图2显示的是slidingExpiration设置为true,且timeout设置为30时的工作流程.当一个认证用户的请求抵达时(且该用户的票据未过期),就对过期时间进行更新.
图2
当使用基于cookie的票据时(这是默认的),问题就变的稍微复杂了点,因为cookie也有自己的expiry,它指示浏览器该在什么时候销毁cookie.
如果没有为cookie指定expiry,在关闭浏览器时就会自动销毁cookie.如果有expiry,cookie就存储在用户的电脑里,超过指定的日期和时间时就会被销毁.当某个cookie被销毁掉了,就再也不会将它发送给服务器了.
注意:
当然,我们也可以提前销毁存储在电脑里的任何cookies.在Internet Explorer 7里,你点“工具”、“选项”、“浏览器历史记录”区域里的“删除”按钮,再点“删除cookies”按钮即可.
forms authentication system是创建基于session还是基于expiry(session-based or expiry-based)的cookies 呢?这要取决于传递给persistCookie参数的值.在前一篇文章里,我们提到FormsAuthentication类的GetAuthCookie(), SetAuthCookie(),和RedirectFromLoginPage()方法都包含2个参数:username和 persistCookie.另外,我们创建的登录页面包含一个“Remember me” CheckBox,通过它来判断是否创建一个“持久保存的cookie”(Persistent cookies).“持久保存的cookie”是基于expiry的;而“非持久保存的cookie”是基于session的.
不管是基于session还是基于expiry的cookies,前面探讨的timeout 和 slidingExpiration概念应用到这2种cookies时都是一样的.唯一比较大的区别在于执行方面:对使用基于expiry的cookies而言,如果将slidingTimeout设置为true的话,当指定的有效时间长度超过一半时(比如,如果指定的有效期为20分钟,当时间超过10分数时)才对cookie的expiry进行更新.
我们来对站点的票据过期策略进行改动,使票据在一个小时(60分钟)后过期,且使用sliding expiration.为此,改动Web.config文件,为
<authentication>元素添加一个<forms>子元素,设置如下:
<authentication mode="Forms">
<forms slidingExpiration="true" timeout="60" /> </authentication>
使用定制的登录页面URL,而不是默认的Login.aspx
由于FormsAuthenticationModule自动将未授权的用户导航到登录页面,因此它需要知道登录页面的URL.该URL通过<forms>元素的loginUrl属性来指定,默认值为“login.aspx”.
比如你的系统的登录页面为SignIn.aspx,且位于Users目录下,那么你可以将 loginUrl设置为“~/Users/SignIn.aspx”,如下:
<authentication mode="Forms">
<forms loginUrl="~/Users/SignIn.aspx" />
</authentication>
当然,如果你的系统的登录页面就是Login.aspx,那就用不着在<forms>元素里进行指定了.
第二步:使用无Cookie的表单认证票据
默认情况下,forms authentication system将决定是将票据存储在cookies collection里还是插入用户访问页面的URL里.所有主流的桌面浏览器,比如Internet Explorer,Firefox, Opera, 或Safari都支持cookies,但并非所有的移动设备都支持.
forms authentication system使用何种cookie策略,取决于<forms>元素里的cookieless设置,它可以有如下四种配置:
.UseCookies——指定总是使用基于cookie的票据
.UseUri——指定从不使用基于cookie的票据
.AutoDetect——如果device profile不支持cookies,就不使用基于cookie的票据;如果device profile支持cookies,那么就运用一种探测机制来判断是否可以使用cookies.
.UseDeviceProfile——这是默认值.如果device profile支持cookies,就使用基于cookie的票据.不运用探测机制.
其中,AutoDetect 和 UseDeviceProfile选项都依靠一个device profile来判断是使用基于cookie还是无cookie的票据.ASP.NET有一个关于这种devices及其性能的数据库,比如某种devices是否支持cookies,它支持那个版本的JavaScript等信息.每次,当一个device向服务器发出对某个页面的请求时,该请求里将包含一
个名为“user-agent”的HTTP header,用于表明device的类型.ASP.NET将自动的把提供的user-agent字符串与数据库里相应的信息匹配起来.
注意:
该数据库存储在很多的XML文件里.这些默认的device profile文件,其路径为%WINDIR%\Microsoft.Net\Framework\v2.0.50727\CONFIG\Browsers. 你也可以在你应用程序的App_Browsers文件夹里添加自定义的文件,关于这方面的更多信息,请参阅文章《How To: Detect Browser Types in ASP.NET Web Pages》
由于默认使用的是UseDeviceProfile选项.当访问站点的某个device不支持cookies时,就站点就使用无cookie的票据.
在URL里对票据进行编码
当浏览器每次向某个站点发出请求时,用来存储信息的载体通常是Cookies.但如果访问站点的device不支持Cookies的话,我们必须使用某种方法在客户端和服务器端传递票据,通常的做法是在URL里将cookie数据编码.
为了进行演示,我们将强迫站点使用无cookie的票据,为此我们将采用UseUri:
<authentication mode="Forms">
<forms cookieless="UseUri" slidingExpiration="true" timeout="60"/>
</authentication>
做了上述修改后,通过浏览器访问.当以匿名用户进行访问时,URL看起来和以前没什么区别,比如访问Default.aspx页面时,地址栏看起来和下面的差不多:
http://localhost:2448/ASPNET_Security_Tutorial_03_CS/default.aspx
然而一旦你登录后,票据将加密到URL里.比如,当以Sam的名义登录后,转到Default.aspx页面,这次地址栏看起来和下面的差不多:
该票据已经被编码进URL。字符串(F(jaIOIDTJxIr12xYS-VVgkqKCVAuIoW30Bu0diWi6flQC-FyMaLXJfow_Vd9GZkB2Cv-rfezq0gKadKX0YPZCkA2)就是以16进制对票据信息编码后的效果.这于通常情况下存储在一个cookie里的数据是一样的.
为使无cookie的票据工作正常,系统必须对所有页面的URL编码以包含票据数据.另外,当用户点击一个链接时,票据也不会丢失.还好,该编码过程是自动执行的.为演示该功能,我们打开Default.aspx页面,添加一个HyperLink链接,分别设置其Text 和 NavigateUrl属性为“Test Link”和“SomePage.aspx”, 当然,这并不是说我们的站点真的有个页面叫SomePage.aspx.
保存对Default.aspx的改动,再从浏览器访问它.先登录,以便把票据编码进URL,然后,在Default.aspx页面,点击“Test Link”链接.会发生什么?如果不存在SomePage.aspx页面,就会发生一个404错误,不过在这里这并不重要,注意观察地址栏,在URL里包含了该票据!
链接里的URL——“SomePage.aspx”,将自动的添加到一个包含票据的一个URL里——我们不用手写一行代码!票据将自动的编码进一个URL,只是该URL不能以 “http://”或“/”开头. 至于该链接是出现在一个对Response.Redirect的调用,或一个HyperLink控件里,又或是一个anchor HTML元素(比如<a href="...">...</a>)里,那到无关紧要.只要URL不是“http://www.someserver.com/SomePage.aspx” 或 “/SomePage.aspx”这样的形式,系统都会为我们自动编码.
注意:
虽然和基于cookie的票据一样,无cookie的票据也采取了某种过期策略,但无cookie的票据却更用户受到replay攻击,原因正是因为票据被编码进了URL.我们来设想一下,加入某个用户访问站点,成功登录后将URL复制下来通过电邮传给他的同事,在票据未过期之前,他的同事点击该链接,那么他们都能以那个发邮件的用户的名义登录系统了!
第三步:Securing the Authentication Ticket
除了身份信息外,票据还可以包含用户数据(我们将在第四步看到),因此将票据进行加密很有必要,更重要的是,forms authentication system必须保证票据未被篡改过.
为确保票据的真实性,forms authentication system必须验证票据.验证就是这样的行为:确保某个数据块没有被修改过,这是通过message authentication code (MAC)来实现的. MAC就是一小片信息,用来对需要进行验证的数据(就本文而言,就是票据)实施鉴别.如果数据被改动过,那么MAC就不能与改动过的数据匹配.另外,对某个黑客来说,既要改动数据,还要计算产生一个自己的MAC,以便使该MAC与改动过的数据匹配实在是太困难了.
当forms authentication system创建或改动一个票据后,就生成一个与票据数据相对应的MAC.当后续的请求到达时,forms authentication system就将MAC与票据数据进行比较,以验证票据数据的真实性.图3以图表的形式对该工作流程进行了阐述.
图3
对票据运用何种安全措施取决于<forms>元素里protection的配置.该项的值可为如下几个值之一:
.All——默认值,票据要加密且运用数据有效性验证
.Encryption——只加密,不生成MAC
.None——既不加密也不运用数据有效性验证.
.Validation——生成一个MAC,但不对票据加密,以纯文本的形式传递.
微软强烈推荐使用All配置选项.
Setting the Validation and Decryption Keys
forms authentication system运用的加密方法以及验证票据的散列法可以在Web.config文件的<machineKey>元素里进行用户定制.Table 2列出了<machineKey>元素的属性以及可能的取值.
Table 2: <machineKey>元素属性
对这些encryption 和 validation选项的深入研究,以及各种算法的优缺点探讨已经超出了本系列文章的范畴,对这些问题的深入探讨,包括使用哪种encryption 和 validation算法,密匙的长度,如何最好的生成这些keys,请参阅《Professional ASP.NET 2.0 Security, Membership, and Role Management》.
默认情况下,会自动为每个应用程序生成用于encryption和validation的keys,这些keys都存储在Local Security Authority (LSA). 简而言之,根据server-by-web server 和 application-by-application准则,默认配置保证keys都是唯一的、独特的.因此,在这种默认行为在如下2种情况下是不行的:
.Web Farms——在一个web farm里,一个应用程序运行在多个服务器上,每个后续的请求都会指派到场中的一个服务器处理,这就意味着在一个用户的会话期间,每个服务器都可能要处理他的请求.因此,每个服务器都必须使用相同的encryption 和 validation keys,这样,在一台服务器上created, encrypted, 以及validated的票据才能在另一台服务器上进行decrypted和validated操作.
.Cross Application Ticket Sharing——一个服务器上可能运行了多个ASP.NET运用程序.如果你需要让不同的应用程序之间共用一个单独的票据,你必须使这些运用程序的encryption 和 validation keys匹配.
当你处于上述2种情况之一时,你必须在受影响的应用程序里配置<machineKey>元素,保证这些程序的decryptionKey 和 validationKey相互匹配.
即使你的应用程序没有处于上述2种情况下,你也可以显式的指定decryptionKey 和 validationKey值,以及定义要运用的运算法则.在Web.config文件里添加一个<machineKey>配置项:
<configuration>
<system.web> ... Some markup was removed for brevity ...
<machineKey decryption="AES" validation="SHA1" decryptionKey="1513F567EE75F7FB5AC0AC4D79E1D9F25430E3E2F1BCDD3370BCFC4EFC97A541" validationKey="32CBA563F26041EE5B5FE9581076C40618DCC1218F5F447634EDE8624508A129" />
</system.web>
</configuration>
更多详情,请参阅《How To: Configure MachineKey in ASP.NET 2.0》
注意:
上面运用的这些decryptionKey 和 validationKey值,采用的是Steve Gibson的《Perfect Passwords web page》,详情请参阅该文.
第四步:在票据里存储附加的用户数据
很多应用程序显示当前登录用户的某些信息,比如用户名或最后一次登录的时间.票据存储了当前用户的名称,但如果需要其它的信息时,页面必须求助于用户存储——一般是某个数据库——以查找需要的,但又没有存储在票据里的信息.
我们只需要很少的代码就可以在票据里存储附加的用户信息.我们要用到FormsAuthenticationTicket类的UserData属性.该属性是存储与用户相关的数据量不大的信息的理想之地.对该属性指定的值可以是authentication ticket cookie.根据forms authentication system的配置来进行加密和验证.默认情况下,该UserData是一个空字符串.
为了在票据里存储用户数据,我们需要在登录页面写代码以获取用户信息并存储进票据.由于UserData是一个字符串类型的属性,因此存储在该属性里的数据必须要序列化为一个字符串.比如,假定我们的用户存储包括每个用户的出生日期和所在公司名称,我们希望将这2个属性存储在票据里.我们可以用(“|”)来将这2个属性连接起来,比如某个用户出生于August 15, 1974,所在公司为Northwind Traders,那么我们可以对UserData属性赋值为这样的字符串:“1974-08-15|Northwind Traders”.
任何时候当我们需要访问存储在票据里的数据时,我们可以获取当前请求的FormsAuthenticationTicket,并对UserData属性进行反序列化(deserializing).以上面的例子来说,我们可以根据分隔符(“|”)将UserData字符串分割为2个子字符串.
图4:将用户信息存储在票据里
将信息写入UserData
不幸的是,将用户信息写入票据并不如大家期望的那么简单.FormsAuthenticationTicket类的UserData属性是只读的,只能通过FormsAuthenticationTicket类的构造器进行指定.当我们在构造器里为该属性指定值时,我们还需要提供票据的其它值,比如:username,issue date, expiration等等.在前面的文章里,当我们创建登录页面时,FormsAuthentication类以及自动的帮我们进行了处理.要在票据里添加用户数据时,我们要重新用到FormsAuthentication类里提供的、已有的函数.
让我们对Login.aspx页面进行更新,以向票据添加额外的信息,这样来探究处理UserData所必需的代码.假设我们的用户存储包含用户公司的名称,以及用户的头衔,而且我们想在票据里获取这些信息.为此,对Login.aspx页面的LoginButton Click事件处理器进行更新,如下:
protected void LoginButton_Click(object sender, EventArgs e)
{
// Three valid username/password pairs: Scott/password, Jisun/password, and Sam/password.
string[] users = { "Scott", "Jisun", "Sam" };
string[] passwords = { "password", "password", "password" };
string[] companyName = { "Northwind Traders", "Adventure Works", "Contoso" };
string[] titleAtCompany = { "Janitor", "Scientist", "Mascot" };
for (int i = 0; i < users.Length; i++)
{
bool validUsername = (string.Compare(UerName.Text, users[i], true) == 0);
bool validPassword = (string.Compare(Password.Text, passwords[i], false) == 0);
if (validUsername && validPassword)
{
// Query the user store to get this user's User Data
string userDataString = string.Concat(companyName[i], "|", titleAtCompany[i]);
// Create the cookie that contains the forms authentication ticket
HttpCookie authCookie = FormsAuthentication.GetAuthCookie(UserName.Text, RememberMe.Checked);
// Get the FormsAuthenticationTicket out of the encrypted cookie
FormsAuthenticationTicket ticket = FormsAuthentication.Decrypt(authCookie.Value);
// Create a new FormsAuthenticationTicket that includes our custom User Data
FormsAuthenticationTicket newTicket = new FormsAuthenticationTicket(ticket.Version, ticket.Name, ticket.IssueDate, ticket.Expiration, ticket.IsPersistent, userDataString);
// Update the authCookie's Value to use the encrypted version of newTicket
authCookie.Value = FormsAuthentication.Encrypt(newTicket);
// Manually add the authCookie to the Cookies collection
Response.Cookies.Add(authCookie);
// Determine redirect URL and send user there
string redirUrl = FormsAuthentication.GetRedirectUrl(UserName.Text, RememberMe.Checked);
Response.Redirect(redirUrl);
}
}
// If we reach here, the user's credentials were invalid
InvalidCredentialsMessage.Visible = true;
}
我们一行一行的进行分析.该方法首先定义了4个字符串数组:users, passwords, companyName,和titleAtCompany.这些数组用于存储用户名、密码、公司名称、以及用户头衔.这里有3个用户Scott, Jisun,Sam.在实际的应用程序里,这些值都是从用户存储里查询得到的,不像我们这样在页面的源代码里"硬编码"得到的.
在前面的文章里,如果用户提供的登录信息有误,我们仅仅调用FormsAuthentication.RedirectFromLoginPage(UserName.Text, RememberMe.Checked)方法,它执行如下的步骤:
1.创建表单验证票据
2.将票据写入适当的存储.对基于cookies的票据而言,使用的是浏览器的cookies collection;而对无cookies的票据而言,将票据数据序列化进URL
3.将用户重导航到某个恰当的页面
这些步骤在上述代码里都实现了.首先,我们用(“|”)将company name和title连接起来,作为存储到UserData属性里的最终字符串,如下:
string userDataString = string.Concat(companyName[i], "|", titleAtCompany[i]);
接下来,调用FormsAuthentication.GetAuthCookie方法,它创建票据,根据配置的情况进行encrypts 和 validates处理,并赋值给一个HttpCookie对象:
HttpCookie authCookie = FormsAuthentication.GetAuthCookie(UserName.Text, RememberMe.Checked);
为了处理植入cookie里的FormAuthenticationTicket,我们需要调用FormAuthentication类的Decrypt方法, 传入cookie值,如下:
FormsAuthenticationTicket ticket = FormsAuthentication.Decrypt(authCookie.Value);
然后我们在现有FormsAuthenticationTicket的值的基础上,创建一个新的FormsAuthenticationTicket实例,该新票据包含与用户有关的信息(也就是userDataString),如下:
FormsAuthenticationTicket newTicket = new FormsAuthenticationTicket(ticket.Version, ticket.Name, ticket.IssueDate, ticket.Expiration, ticket.IsPersistent, userDataString);
当调用Encrypt方法对新票据进行encrypt (以及validate)处理后,又将其返回给authCookie,如下:
authCookie.Value = FormsAuthentication.Encrypt(newTicket);
最后将authCookie添加到Response.Cookies集合,并调用GetRedirectUrl方法将用户导航到恰当的页面.
Response.Cookies.Add(authCookie);
string redirUrl = FormsAuthentication.GetRedirectUrl(UserName.Text, RememberMe.Checked);
Response.Redirect(redirUrl);
所有这些代码都是必须的,因为UserData属性是只读的,且在FormsAuthentication类的GetAuthCookie, SetAuthCookie,或 RedirectFromLoginPage方法里都没有提供任何指定UserData信息的途径.
注意:
以上代码将用户相关的信息存储进基于cookies的票据.另一方面,将票据序列化到URL的工作是由.NET Framework内部的类进行处理的,因此,对无cookie的票据而言,我们无法将用户数据存储到票据里.
访问UserData信息
至此,当用户登录时,用户的公司名以及他们的头衔都存储在票据的UserData属性里.在任何一个页面上,不用再去查询用户存储,我们就可以通过票据访问这些信息.为进行演示,我们对Default.aspx页面进行更新,使欢迎信息里不但包含用户名,还包含公司名和他的头衔.
目前,Default.aspx页面包含一个名为AuthenticatedMessagePanel的Panel控件,里面有一个名为WelcomeBackMessage的Label控件.该Panel是面向认证用户的.对Default.aspx页面的Page_Load事件处理器进行更新,如下:
protected void Page_Load(object sender, EventArgs e)
{
if (Request.IsAuthenticated)
{ WelcomeBackMessage.Text = "Welcome back, " + User.Identity.Name + "!";
// Get User Data from FormsAuthenticationTicket and show it in WelcomeBackMessage
FormsIdentity ident = User.Identity as FormsIdentity;
if (ident != null)
{ FormsAuthenticationTicket ticket = ident.Ticket;
string userDataString = ticket.UserData;
// Split on the |
string[] userDataPieces = userDataString.Split("|".ToCharArray());
string companyName = userDataPieces[0];
string titleAtCompany = userDataPieces[1];
WelcomeBackMessage.Text += string.Format(" You are the {0} of {1}.", titleAtCompany, companyName);
}
AuthenticatedMessagePanel.Visible = true;
AnonymousMessagePanel.Visible = false; }
else
{ AuthenticatedMessagePanel.Visible = false;
AnonymousMessagePanel.Visible = true; }
}
如果Request.IsAuthenticated为true,则将WelcomeBackMessage的Text属性设置为“Welcome back, username.” ,然后将User.Identity属性转换为一个FormsIdentity对象,以便于我们访问潜在的FormsAuthenticationTicket.一旦我们获取到FormsAuthenticationTicket后,我们根据分割符"|",将UserData属性反序列化为2个代表公司和头衔的字符串.然后将公司名和头衔显示在WelcomeBackMessage Label里.
图5显示的是实际的一个截屏,以Scott的名义登录,欢迎消息里包含Scott的公司名和头衔.
图5:
注意:
票据的UserData属性是用户存储的一个缓存.与其它任何缓存一样,当“源数据”发生改动时,需要对缓存进行刷新.
第五步:Using a Custom Principal
每次请求抵达时,FormsAuthenticationModule都会对用户进行鉴别.如果票据未过期,FormsAuthenticationModule就将HttpContext.User属性赋值为一个新的 该GenericPrincipal对象有一个代表表单认证票据FormsIdentity.而GenericPrincipal类包含的功能仅仅够贯彻IPrincipal接口——它只有一个Identity属性和一个IsInRole方法.
principal对象有2个职责:指出用户属于什么角色以及提供identity信息.这2个职责是分别通过IPrincipal接口的IsInRole(roleName)方法和Identity属性来实现的.GenericPrincipal类允许通过它的构造器为role名称指定一个字符串数组,而其IsInRole(roleName)方法仅仅检查传入的roleName是否存在于该字符串数组里.当FormsAuthenticationModule创建GenericPrincipal时,在GenericPrincipal的构造器里传入一个空的字符串数组,因此当调用IsInRole时,总是返回false.
对绝大多数没有用到role的、基于表单的认证而言,该GenericPrincipal类是完全满足需要的.如果这种对role的默认处理无法满足你的需要,或者你需要将用户与一个自定义IIdentity对象联系起来的话,你可以在认证流程里创建一个自定义的IPrincipal对象,并将其赋值给HttpContext.User属性.
注意:
正如你将在以后的文章看到的那样,当使ASP.NET’s Roles framework创建一个类型为RolePrincipal的自定义principal对象,并重写forms authentication创建GenericPrincipal对象的方法时,我们必须这样做,对principal的IsInRole方法进行定制,以便与Roles framework的API“接轨”.
由于我们现在还没有牵涉到role,因此本文只探讨自定义IIdentity对象的问题.在第四步,我们考察了将附加的用户信息存储在票据的UserData属性里,具体来说就是公司名和员工的头衔.然而,UserData信息只能通过票据进行访问,且是一个连续的字符串,这就意味着我们想访问存储在票据里的用户信息时,必须对UserData属性进行解析.
你还可以创建一个贯彻IIdentity接口的类,使它包含CompanyName属性和Title属性.那样的话,开发人员就可以直接通过CompanyName 和 Title属性直接访问当前登录用户的公司名称和头衔,而不用知道如何对UserData属性进行解析了.
创建自定义Identity 和 Principal类
本文,我们来在App_Code文件夹里创建自定义的principal和identity对象.首先在项目里添加一个App_Code文件夹,该文件夹专门用于存放专用于本站点的class文件.
注意:
只有当通过Website Project Model对项目进行管理时才应使用App_Code文件夹.如果你使用的是Web Application Project Model, 你只需要创建一个一般的文件夹,将class文件放进去即可.比如,你创建一个名为Classes的文件夹,以存放你的类.然后,向App_Code文件夹里添加2个class文件,一个叫CustomIdentity.cs,另一个叫CustomPrincipal.cs.
图6:向你的项目添加CustomIdentity 和 CustomPrincipal类
该CustomIdentity类用于执行IIdentity接口,而IIdentity接口又定义了AuthenticationType, IsAuthenticated,以及Name属性.除了这些必需的属性外,我们还对表示票据里的用户公司名和头衔的属性感兴趣.在CustomIdentity类里键入如下的代码:
using System;
using System.Data;
using System.Configuration;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
public class CustomIdentity : System.Security.Principal.IIdentity
{
private FormsAuthenticationTicket _ticket;
public CustomIdentity(FormsAuthenticationTicket ticket)
{
_ticket = ticket;
}
public string AuthenticationType
{
get { return "Custom"; }
}
public bool IsAuthenticated
{
get { return true; }
}
public string Name
{
get { return _ticket.Name; }
}
public FormsAuthenticationTicket Ticket
{
get { return _ticket; }
}
public string CompanyName
{
get {
string[] userDataPieces = _ticket.UserData.Split("|".ToCharArray());
return userDataPieces[0]; }
}
public string Title
{
get
{
string[] userDataPieces = _ticket.UserData.Split("|".ToCharArray());
return userDataPieces[1];
}
}
}
注意该类里包含一个FormsAuthenticationTicket类型的成员变量(_ticket),通过构造器来提供票据信息.票据数据用于返回identity的Name;其UserData属性经过解析以返回CompanyName 和 Title属性的值.
接下来创建CustomPrincipal类,因为在这里我们暂时不关心role的问题,因此该CustomPrincipal类的构造器仅仅接受一个CustomIdentity对象,其IsInRole方法总是返回false,如下:
using System;
using System.Data;
using System.Configuration;
using System.Web;
using System.Web.Security; using System.Web.UI; using System.Web.UI.WebControls; using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
public class CustomPrincipal : System.Security.Principal.IPrincipal
{
private CustomIdentity _identity;
public CustomPrincipal(CustomIdentity identity)
{
_identity = identity;
}
public System.Security.Principal.IIdentity Identity
{
get { return _identity; }
}
public bool IsInRole(string role)
{
return false; }
}
将一个CustomPrincipal对象赋值给随后请求的安全内容
到目前为止,我们有一个对默认的IIdentity接口进行扩展的类,它还包含CompanyName和Title属性;也有一个使用我们自定义identity的自定义principal类.我们现在将深入ASP.NET,将我们自定义的principal对象赋值给随后的请求的安全内容里.
ASP.NET接到一个请求后,通过一系列的步骤对请求进行处理.在每一步都会触发一个具体的事件,这就为开发人员楔入ASP.NET处理通道内部,在其生命周期的某一点上对请求进行修改提供了可能.以FormsAuthenticationModule为例,当ASP.NET引发AuthenticateReques事件后,在该事件里它对请求进行认证票据检查,如果在请求里发现了票据,就生成一个GenericPrincipal对象,并赋值给HttpContext.User属性.
在AuthenticateRequest事件之后,ASP.NET又引发PostAuthenticateRequest事件, 在该事件里,我们可以将FormsAuthenticationModule创建的GenericPrincipal对象替换为我们自定义的CustomPrincipal对象的一个实例,图7描绘了该流程.
图7
为了对ASP.NET的这些事件做出回应,我们要么在Global.asax文件里创建相应的事件处理器,要么创建我们自己的HTTP Module,就本系列文章而言,我们在Global.asax文件里创建事件处理器.首先将Global.asax文件添加到站点.
图8:在站点里添加一个Global.asax文件
默认的Global.asax模板里包含了对应于一些ASP.NET事件的事件处理器,包括Start, End以及Error事件等.放心大胆的将这些事件处理器删除,因为我们的应用程序里不需要用到它们,我们关心的事件是PostAuthenticateRequest.对你的Global.asax文件进行更新,使其代码看起来和下面的差不多:
<%@ Application Language="C#" %>
<%@ Import Namespace="System.Security.Principal" %>
<%@ Import Namespace="System.Threading" %>
<script runat="server">
void Application_OnPostAuthenticateRequest(object sender, EventArgs e)
{
// Get a reference to the current User
IPrincipal usr = HttpContext.Current.User;
// If we are dealing with an authenticated forms authentication request
if (usr.Identity.IsAuthenticated && usr.Identity.AuthenticationType == "Forms") {
FormsIdentity fIdent = usr.Identity as FormsIdentity;
// Create a CustomIdentity based on the FormsAuthenticationTicket CustomIdentity ci = new
CustomIdentity(fIdent.Ticket);
// Create the CustomPrincipal
CustomPrincipal p = new CustomPrincipal(ci);
// Attach the CustomPrincipal to HttpContext.User and Thread.CurrentPrincipal HttpContext.Current.User = p;
Thread.CurrentPrincipal = p;
}
}
</script>
该Application_OnPostAuthenticateRequest方法只在ASP.NET runtime引发PostAuthenticateRequest事件时才执行,且在每个请求抵达时只发生一次.代码首先检查用户是否通过了认证,且认证方式为表单认证.如果是的,则创建一个新的CustomIdentity对象,并将当前请求的票据传入其构造器.接下来,创建一个CustomPrincipal对象,并将刚才创建的CustomIdentity对象传到其构造器里.最后,将最新创建的CustomPrincipal对象赋值给当前请求的安全内容.
注意最后一步——将principal分配给HttpContext.User和Thread.CurrentPrincipal.这2步是必需的,因为是ASP.NET来处理的安全内容.我们知道,.NET Framework将一个安全内容与每个running thread联系起来;我们可以通过Thread object的CurrentPrincipal属性,以IPrincipal对象的形式来获得这些信息. 容易让人混淆的是ASP.NET也有自己的安全内容信息(也就是HttpContext.User)
在某些情况下通过检查Thread.CurrentPrincipal属性来判定安全内容;而在另一些情况下是通过检查HttpContext.User属性来实现的.比如,在.NET里有一些安全特性(security features),利用这些特性来声明哪些用户或角色可以使用一个类或调用某个特定的方法(请参阅《Adding Authorization Rules to Business and Data Layers Using PrincipalPermissionAttributes),此外,这些技术还可以通过Thread.CurrentPrincipal属性来判定安全内容.
我们再看看使用HttpContext.User用户的情形.再前面的文章里,我们使用该属性来显示当前登陆用户的用户名.自然的,Thread.CurrentPrincipal属性和HttpContext.User属性包含的安全内容必须匹配.
ASP.NET runtime自动的为我们同步的处理这些属性.然而,该同步是发生在AuthenticateRequest事件之后,PostAuthenticateRequest事件之前。因此,当在PostAuthenticateRequest事件里添加一个自定义principal的时候,我们需要手动地为Thread.CurrentPrincipal赋值,不然Thread.CurrentPrincipal 和 HttpContext.User就不同步了. 更多细节请参阅《Context.User vs. Thread.CurrentPrincipal》.
访问CompanyName 和 Title属性
任何时候,当请求抵达并由ASP.NET引擎分派时,都会引发Global.asax文件里的Application_OnPostAuthenticateRequest事件,如果请求成功通过FormsAuthenticationModule的认证的话,该事件处理器将创建一个新的CustomPrincipal对象,该对象将包含一个基于票据的CustomIdentity对象.在这里进行这些逻辑处理后,我们就可以相当方便地访问当前用户的公司名和头衔.
返回到Default.aspx页面的Page_Load事件处理器,在第四步,我们在该事件处理器里写代码检索票据,并将UserData数据进行解析以显示公司名和头衔.现在使用CustomPrincipal 和 CustomIdentity对象后,我们就不要对票据的UserData属性进行解析了. 仅仅需要获取对CustomIdentity对象的引用,并使用其CompanyName和Title属性,如下:
CustomIdentity ident = User.Identity as CustomIdentity;
if (ident != null) WelcomeBackMessage.Text += string.Format(" You are the {0} of {1}.", ident.Title, ident.CompanyName);
结语:
在本文,我们考察了如何通过Web.config文件来对表单认证系统进行配置.我们也考察了如何处理票据的有效期,以及encryption和validation安全措施是如何从inspection和modification这2方面对票据进行保护的.最后,我们探讨了使用票据的UserData属性来存储票据的附加信息.,以及如何使用自定义的principal和identity对象来以一种更好的方式访问这些附加信息.
到本文为止,我们考察完了ASP.NET里的表单认证.下一篇文章我们将开始考察Membership framework.
祝编程快乐!