在开发ASP.NET项目中,我们最常用的是Forms认证,也叫【表单认证】。这种认证方式既可以用于局域网环境,也可用于互联网环境,因此,它有着非常广泛的使用。这篇博客主要讨论的话题是:ASP.NET Forms 身份认证。
在这篇博客中,不涉及ASP.NET的登录系列控件以及membership的相关话题,只用比较原始的方式来说明在ASP.NET中是如何实现身份认证的过程。
ASP.NET身份认证基础
1. 如何判断当前请求是一个已登录用户发起的?
2. 如何获取当前登录用户的登录名?
在标准的ASP.NET身份认证方式中,上面二个问题的答案是:
1. 如果Request.IsAuthenticated为true,则表示是一个已登录用户。
2. 如果是一个已登录用户,访问HttpContext.User.Identity.Name可获取登录名(都是实例属性)。
ASP.NET身份认证过程
在ASP.NET中,整个身份认证的过程其实可分为二个阶段:认证与授权。
1. 认证阶段:识别当前请求的用户是不是一个可识别(的已登录)用户。
2. 授权阶段:是否允许当前请求访问指定的资源。
这二个阶段在ASP.NET管线中用AuthenticateRequest和AuthorizeRequest事件来表示。
在认证阶段,ASP.NET会检查当前请求,根据web.config设置的认证方式,尝试构造HttpContext.User对象供我们在后续的处理中使用。在授权阶段,会检查当前请求所访问的资源是否允许访问,因为有些受保护的页面资源可能要求特定的用户或者用户组才能访问。所以,即使是一个已登录用户,也有可能会不能访问某些页面。当发现用户不能访问某个页面资源时,ASP.NET会将请求重定向到登录页面。
受保护的页面与登录页面我们都可以在web.config中指定,具体方法可参考后文。
在ASP.NET中,Forms认证是由FormsAuthenticationModule实现的,URL的授权检查是由UrlAuthorizationModule实现的。
如何实现登录与注销
前面我介绍了可以使用Request.IsAuthenticated来判断当前用户是不是一个已登录用户,那么这一过程又是如何实现的呢?
为了回答这个问题,我准备了一个简单的示例页面,代码如下:
<fieldset><legend>用户状态</legend><form action="<%= Request.RawUrl %>" method="post"> <% if( Request.IsAuthenticated ) { %> 当前用户已登录,登录名:<%= Context.User.Identity.Name.HtmlEncode() %> <br /> <input type="submit" name="Logon" value="退出" /> <% } else { %> <b>当前用户还未登录。</b> <% } %> </form></fieldset>
页面显示效果如下:
下面我再加点代码来实现用户登录。页面代码:
<fieldset><legend>普通登录</legend><form action="<%= Request.RawUrl %>" method="post"> 登录名:<input type="text" name="loginName" style=" 200px" value="Fish" /> <input type="submit" name="NormalLogin" value="登录" /> </form></fieldset>
现在页面的显示效果:
登录与退出登录的实现代码:
public void Logon() { FormsAuthentication.SignOut(); } Out public void NormalLogin() { // ----------------------------------------------------------------- // 注意:演示代码为了简单,这里不检查用户名与密码是否正确。 // ----------------------------------------------------------------- string loginName = Request.Form["loginName"]; if( string.IsNullOrEmpty(loginName) ) return; FormsAuthentication.SetAuthCookie(loginName, true); TryRedirect(); }
现在,我可试一下登录功能。点击登录按钮后,页面的显示效果如下
从图片的显示可以看出,前面写的NormalLogin()方法确实可以实现用户登录。
当然,也可以在此时点击退出按钮,那么就回到了图片2的显示。
总结一下在ASP.NET中实现登录与注销的方法:
1. 登录:调用FormsAuthentication.SetAuthCookie()方法,传递一个登录名即可。
2. 注销:调用FormsAuthentication.SignOut()方法。
保护受限制的页面
在一个ASP.NET网站中,有些页面会允许所有用户访问,包括一些未登录用户,但有些页面则必须是已登录用户才能访问,还有一些页面可能会要求特定的用户或者用户组的成员才能访问。这类页面因此也可称为【受限页面】,它们一般代表着比较重要的页面,包含一些重要的操作或功能。
为了保护受限制的页面的访问,ASP.NET提供了一种简单的方式:可以在web.config中指定受限资源允许哪些用户或者用户组(角色)的访问,也可以设置为禁止访问。
比如,网站有一个页面:MyInfo.aspx,它要求访问这个页面的访问者必须是一个已登录用户,那么可以在web.config中这样配置:
<location path="MyInfo.aspx"> <system.web> <authorization> <deny users="?"/> </authorization> </system.web> </location>
为了方便,可以将一些与管理相关的多个页面放在Admin目录中,这些页面只允许Admin用户组的成员访问。对于这种情况,我们可以直接针对一个目录设置访问规则
<location path="Admin"> <system.web> <authorization> <allow roles="Admin"/> <deny users="*"/> </authorization> </system.web> </location>
这样就不必一个一个页面单独设置了,还可以在目录中创建一个web.config来指定目录的访问规则,请参考后面的示例。
在前面的示例中,有一点要特别注意的是:
1. allow和deny之间的顺序一定不能写错了,UrlAuthorizationModule将按这个顺序依次判断。
2. 如果某个资源只允许某类用户访问,那么最后的一条规则一定是 <deny users="*" />
在allow和deny的配置中,我们可以在一条规则中指定多个用户:
1. 使用users属性,值为逗号分隔的用户名列表。
2. 使用roles属性,值为逗号分隔的角色列表。
3. 问号 (?) 表示匿名用户。
4. 星号 (*) 表示所有用户。
登录页不能正常显示的问题
有时候,我们可能要开发一个内部使用的网站程序,这类网站程序要求 禁止匿名用户的访问,即:所有使用者必须先登录才能访问。因此,我们通常会在网站根目录下的web.config中这样设置:
<authorization> <deny users="?"/> </authorization>
对于我们的示例,我们也可以这样设置。此时在浏览器打开页面时,呈现效果如下:
从图片中可以看出:页面的样式显示不正确,最下边还多出了一行文字。
这个页面的完整代码是这样的(它引用了一个CSS文件和一个JS文件)
<%@ Page Language="C#" CodeFile="Default.aspx.cs" Inherits="_Default" %> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>FormsAuthentication DEMO - http://www.cnblogs.com/fish-li/</title> <link type="text/css" rel="Stylesheet" href="css/StyleSheet.css" /> </head> <body> <fieldset><legend>普通登录</legend><form action="<%= Request.RawUrl %>" method="post"> 登录名:<input type="text" name="loginName" style=" 200px" value="Fish" /> <input type="submit" name="NormalLogin" value="登录" /> </form></fieldset> <fieldset><legend>用户状态</legend><form action="<%= Request.RawUrl %>" method="post"> <% if( Request.IsAuthenticated ) { %> 当前用户已登录,登录名:<%= Context.User.Identity.Name.HtmlEncode() %> <br /> <% var user = Context.User as MyFormsPrincipal<UserInfo>; %> <% if( user != null ) { %> <%= user.UserData.ToString().HtmlEncode() %> <% } %> <input type="submit" name="Logon" value="退出" /> <% } else { %> <b>当前用户还未登录。</b> <% } %> </form></fieldset> <p id="hideText"><i>不应该显示的文字</i></p> <script type="text/javascript" src="js/JScript.js"></script> </body> </html>
页面最后一行文字平时不显示是因为JScript.js中有以下代码:
document.getElementById("hideText").setAttribute("style", "display: none");
这段JS代码能做什么,我想就不用再解释了。虽然这段JS代码没什么价值,但我主要是想演示在登录页面中引用JS的场景。
根据前面图片,我们可以猜测到:应该是CSS和JS文件没有正确加载造成的。为了确认就是这样原因,我们可以打开FireBug再来看一下页面加载情况:
根据FireBug提供的线索我们可以分析出,页面在访问CSS, JS文件时,其实是被重定向到登录页面了,因此获得的结果肯定也是无意义的,所以就造成了登录页的显示不正确。
还记得前所说的【授权】吗?
是的,现在就是由于我们在web.config中设置了不允许匿名用户访问,因此,所有的资源也就不允许匿名用户访问了,包括登录页所引用的CSS, JS文件。当授权检查失败时,请求会被重定向到登录页面,所以,登录页本身所引用的CSS, JS文件最后得到的响应内容其实是登录页的HTML代码,最终导致它们不能发挥作用,表现为登录页的样式显示不正确,以及引用的JS文件也不起作用。
不过,有一点比较奇怪:为什么访问登录页面时,没有发生重定向呢?
原因是这样的:在ASP.NET内部,当发现是在访问登录面时,会设置HttpContext.SkipAuthorization = true (其实是一个内部调用),这样的设置会告诉后面的授权检查模块:跳过这次请求的授权检查。 因此,登录页总是允许所有用户访问,但是CSS文件以及JS文件是在另外的请求中发生的,那些请求并不会要跳过授权模块的检查。
为了解决登录页不能正确显示的问题,我们可以这样处理:
1. 在网站根目录中的web.config中设置登录页所引用的JS, CSS文件都允许匿名访问。
2. 也可以直接针对JS, CSS目录设置为允许匿名用户访问。
3. 还可以在CSS, JS目录中创建一个web.config文件来配置对应目录的授权规则。可参考以下web.config文件:
<?xml version="1.0"?> <configuration> <system.web> <authorization> <allow users="*"/> </authorization> </system.web> </configuration>
第三种做法可以不修改网站根目录下的web.config文件。
注意:在IIS中看到的情况就和在Visual Studio中看到的结果就不一样了。 因为,像js, css, image这类文件属于静态资源文件,IIS能直接处理,不需要交给ASP.NET来响应,因此就不会发生授权检查失败,所以,如果这类网站部署在IIS中,看到的结果又是正常的。
认识Forms身份认证
前面演示了如何用代码实现登录与注销的过程,下面再来看一下登录时,ASP.NET到底做了些什么事情,它是如何知道当前请求是一个已登录用户的?
在探索这个问题前,有必要来了解一下HTTP协议的一些特点。
HTTP是一个无状态的协议,无状态的意思可以理解为: WEB服务器在处理所有传入请求时,根本就不知道某个请求是否是一个用户的第一次请求或后续请求,或者是另一个用户的请求。 WEB服务器每次在处理请求时,都会按照用户所访问的资源所对应的处理代码,从头到尾执行一遍,然后输出响应内容, WEB服务器根本不会记住已处理了哪些用户的请求,因此,我们通常说HTTP协议是无状态的。
虽然HTTP协议与WEB服务器是无状态,但我们的业务需求却要求有状态,典型的就是用户登录,在这种业务需求中,要求WEB服务器端能区分某个请求是不是一个已登录用户发起的,或者当前请求是哪个用户发出的。在开发WEB应用程序时,我们通常会使用Cookie来保存一些简单的数据供服务端维持必要的状态。既然这是个通常的做法,那我们现在就来看一下现在页面的Cookie使用情况吧,以下是我用FireFox所看到的Cookie列表:
这个名字:LoginCookieName,是我在web.config中指定的:
<authentication mode="Forms" > <forms cookieless="UseCookies" name="LoginCookieName" loginUrl="~/Default.aspx"></forms> </authentication>
这段代码指定了登录状态的Cookie名、身份验证模式,以及Cookie的使用方式。
为了判断这个Cookie是否与登录状态有关,我们可以在浏览器提供的界面删除它,然后刷新页面,此时页面的显示效果如下:
此时,页面显示当前用户没有登录。
为了确认这个Cookie与登录状态有关,我们可以重新登录,然后再退出登录。
发现只要是页面显示当前用户未登录时,这个Cookie就不会存在。
事实上,通过SetAuthCookie这个方法名,我们也可以猜得出这个操作会写一个Cookie。
注意:本文不讨论无Cookie模式的Forms登录。
从前面的截图我们可以看出:虽然当前用户名是 Fish ,但是,Cookie的值是一串乱码样的字符串。
由于安全性的考虑,ASP.NET对Cookie做过加密处理了,这样可以防止恶意用户构造Cookie绕过登录机制来模拟登录用户。如果想知道这串加密字符串是如何得到的,那么请参考后文。
小结:
1. Forms身份认证是在web.config中指定的,我们还可以设置Forms身份认证的其它配置参数。
2. Forms身份认证的登录状态是通过Cookie来维持的。
3. Forms身份认证的登录Cookie是加密的。
理解Forms身份认证
经过前面的Cookie分析,我们可以发现Cookie的值是一串加密后的字符串,现在我们就来分析这个加密过程以及Cookie对于身份认证的作用。
登录的操作通常会检查用户提供的用户名和密码,因此登录状态也必须具有足够高的安全性。在Forms身份认证中,由于登录状态是保存在Cookie中,而Cookie又会保存到客户端,因此,为了保证登录状态不被恶意用户伪造, ASP.NET采用了加密的方式保存登录状态。为了实现安全性,ASP.NET采用【Forms身份验证凭据】(即FormsAuthenticationTicket对象)来表示一个Forms登录用户,加密与解密由FormsAuthentication的Encrypt与Decrypt的方法来实现。
用户登录的过程大致是这样的:
1. 检查用户提交的登录名和密码是否正确。
2. 根据登录名创建一个FormsAuthenticationTicket对象。
3. 调用FormsAuthentication.Encrypt()加密。
4. 根据加密结果创建登录Cookie,并写入Response。
在登录验证结束后,一般会产生重定向操作,那么后面的每次请求将带上前面产生的加密Cookie,供服务器来验证每次请求的登录状态。
每次请求时的(认证)处理过程如下:
1. FormsAuthenticationModule尝试读取登录Cookie。
2. 从Cookie中解析出FormsAuthenticationTicket对象。过期的对象将被忽略。
3. 根据FormsAuthenticationTicket对象构造FormsIdentity对象并设置HttpContext.Usre
4. UrlAuthorizationModule执行授权检查。
在登录与认证的实现中,FormsAuthenticationTicket和FormsAuthentication是二个核心的类型,前者可以认为是一个数据结构,后者可认为是处理前者的工具类。
UrlAuthorizationModule是一个授权检查模块,其实它与登录认证的关系较为独立,因此,如果不使用这种基于用户名与用户组的授权检查,也可以禁用这个模块。
由于Cookie本身有过期的特点,然而为了安全,FormsAuthenticationTicket也支持过期策略,不过,ASP.NET的默认设置支持FormsAuthenticationTicket的可调过期行为,即:slidingExpiration=true 。这二者任何一个过期时,都将导致登录状态无效。
FormsAuthenticationTicket的可调过期的主要判断逻辑由FormsAuthentication.RenewTicketIfOld方法实现,代码如下:
public static FormsAuthenticationTicket RenewTicketIfOld(FormsAuthenticationTicket tOld) { // 这段代码是意思是:当指定的超时时间逝去大半时将更新FormsAuthenticationTicket对象。 if( tOld == null ) return null; DateTime now = DateTime.Now; TimeSpan span = (TimeSpan)(now - tOld.IssueDate); TimeSpan span2 = (TimeSpan)(tOld.Expiration - now); if( span2 > span ) return tOld; return new FormsAuthenticationTicket(tOld.Version, tOld.Name, now, now + (tOld.Expiration - tOld.IssueDate), tOld.IsPersistent, tOld.UserData, tOld.CookiePath); }
Request.IsAuthenticated可以告诉我们当前请求是否已经过身份验证,我们来看一下这个属性是如何实现的:
public bool IsAuthenticated { get { return (((this._context.User != null) && (this._context.User.Identity != null)) && this._context.User.Identity.IsAuthenticated); } }
从代码可以看出,它的返回结果基本上来源于对Context.User的判断。另外,由于User和Identity都是二个接口类型的属性,因此,不同的实现方式对返回值也有影响。
由于可能会经常使用HttpContext.User这个实例属性,为了让它能正常使用, DefaultAuthenticationModule会在ASP.NET管线的PostAuthenticateRequest事件中检查此属性是否为null,如果它为null,DefaultAuthenticationModule会给它一个默认的GenericPrincipal对象,此对象指示一个未登录的用户。