细说ASP.NET Forms身份认证分类:DotNet 发布时间:2013/9/3 17:40:36
用户登录是个很常见的业务需求,在ASP.NET中,这个过程被称为身份认证。 由于很常见,因此,我认为把这块内容整理出来,与大家分享应该是件有意义的事。 在开发ASP.NET项目中,我们最常用的是Forms认证,也叫【表单认证】。 这种认证方式既可以用于局域网环境,也可用于互联网环境,因此,它有着非常广泛的使用。 这篇博客主要讨论的话题是:ASP.NET Forms 身份认证。 有一点我要申明一下:在这篇博客中,不会涉及ASP.NET的登录系列控件以及membership的相关话题, 我只想用比较原始的方式来说明在ASP.NET中是如何实现身份认证的过程。 ASP.NET身份认证基础在开始今天的博客之前,我想有二个最基础的问题首先要明确: 在标准的ASP.NET身份认证方式中,上面二个问题的答案是: 接下来,本文将会围绕上面二个问题展开,请继续阅读。 ASP.NET身份认证过程在ASP.NET中,整个身份认证的过程其实可分为二个阶段:认证与授权。 这二个阶段在ASP.NET管线中用AuthenticateRequest和AuthorizeRequest事件来表示。 受保护的页面与登录页面我们都可以在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="width: 200px" value="Fish" />
<input type="submit" name="NormalLogin" value="登录" />
</form></fieldset>
现在页面的显示效果:
登录与退出登录的实现代码: public void Logon()
{
FormsAuthentication.SignOut();
}
public void NormalLogin()
{
// -----------------------------------------------------------------
// 注意:演示代码为了简单,这里不检查用户名与密码是否正确。
// -----------------------------------------------------------------
string loginName = Request.Form["loginName"];
if( string.IsNullOrEmpty(loginName) )
return;
FormsAuthentication.SetAuthCookie(loginName, true);
TryRedirect();
}
现在,我可试一下登录功能。点击登录按钮后,页面的显示效果如下:
从图片的显示可以看出,我前面写的NormalLogin()方法确实可以实现用户登录。 写到这里,我想有必要再来总结一下在ASP.NET中实现登录与注销的方法: 保护受限制的页面在一个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来指定目录的访问规则,请参考后面的示例。 在前面的示例中,有一点要特别注意的是: 在allow和deny的配置中,我们可以在一条规则中指定多个用户: 登录页不能正常显示的问题有时候,我们可能要开发一个内部使用的网站程序,这类网站程序要求 禁止匿名用户的访问,即:所有使用者必须先登录才能访问。 因此,我们通常会在网站根目录下的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="width: 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代码能做什么,我想就不用再解释了。 根据前面图片,我们可以猜测到:应该是CSS和JS文件没有正确加载造成的。
根据FireBug提供的线索我们可以分析出,页面在访问CSS, JS文件时,其实是被重定向到登录页面了,因此获得的结果肯定也是无意义的, 所以就造成了登录页的显示不正确。 还记得前所说的【授权】吗? 不过,有一点比较奇怪:为什么访问登录页面时,没有发生重定向呢? 为了解决登录页不能正确显示的问题,我们可以这样处理: <?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应用程序时,我们通常会使用Cookie来保存一些简单的数据供服务端维持必要的状态。 既然这是个通常的做法,那我们现在就来看一下现在页面的Cookie使用情况吧,以下是我用FireFox所看到的Cookie列表:
这个名字:LoginCookieName,是我在web.config中指定的: <authentication mode="Forms" >
<forms cookieless="UseCookies" name="LoginCookieName" loginUrl="~/Default.aspx"></forms>
</authentication>
在这段配置中,我不仅指定的登录状态的Cookie名,还指定了身份验证模式,以及Cookie的使用方式。 为了判断这个Cookie是否与登录状态有关,我们可以在浏览器提供的界面删除它,然后刷新页面,此时页面的显示效果如下:
此时,页面显示当前用户没有登录。 为了确认这个Cookie与登录状态有关,我们可以重新登录,然后再退出登录。 事实上,通过SetAuthCookie这个方法名,我们也可以猜得出这个操作会写一个Cookie。 从前面的截图我们可以看出:虽然当前用户名是 Fish ,但是,Cookie的值是一串乱码样的字符串。 小结: 理解Forms身份认证经过前面的Cookie分析,我们可以发现Cookie的值是一串加密后的字符串, 现在我们就来分析这个加密过程以及Cookie对于身份认证的作用。 登录的操作通常会检查用户提供的用户名和密码,因此登录状态也必须具有足够高的安全性。 在Forms身份认证中,由于登录状态是保存在Cookie中,而Cookie又会保存到客户端,因此,为了保证登录状态不被恶意用户伪造, ASP.NET采用了加密的方式保存登录状态。 为了实现安全性,ASP.NET采用【Forms身份验证凭据】(即FormsAuthenticationTicket对象)来表示一个Forms登录用户, 加密与解密由FormsAuthentication的Encrypt与Decrypt的方法来实现。 用户登录的过程大致是这样的: 每次请求时的(认证)处理过程如下: 在登录与认证的实现中,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的判断。 由于可能会经常使用HttpContext.User这个实例属性,为了让它能正常使用, DefaultAuthenticationModule会在ASP.NET管线的PostAuthenticateRequest事件中检查此属性是否为null, 如果它为null,DefaultAuthenticationModule会给它一个默认的GenericPrincipal对象,此对象指示一个未登录的用户。 我认为ASP.NET的身份认证的最核心部分其实就是HttpContext.User这个属性所指向的对象。 实现自定义的身份认证标识前面演示了最简单的ASP.NET Forms身份认证的实现方法,即:直接调用SetAuthCookie方法。 不过调用这个方法,只能传递一个登录名。 但是有时候为了方便后续的请求处理,还需要保存一些与登录名相关的额外信息。 虽然知道ASP.NET使用Cookie来保存登录名状态信息,我们也可以直接将前面所说的额外信息直接保存在Cookie中, 但是考虑安全性,我们还需要设计一些加密方法,而且还需要考虑这些额外信息保存在哪里才能方便使用, 并还要考虑随登录与注销同步修改。 因此,实现这些操作还是有点繁琐的。 为了保存与登录名相关的额外的用户信息,我认为实现自定义的身份认证标识(HttpContext.User实例)是个容易的解决方法。 这个方法的核心是(分为二个子过程): 现在,我们还是来看一下HttpContext.User这个属性的定义: // 为当前 HTTP 请求获取或设置安全信息。
//
// 返回结果:
// 当前 HTTP 请求的安全信息。
public IPrincipal User { get; set; }
由于这个属性只是个接口类型,因此,我们也可以自己实现这个接口。 public class MyFormsPrincipal<TUserData> : IPrincipal
where TUserData : class, new()
{
private IIdentity _identity;
private TUserData _userData;
public MyFormsPrincipal(FormsAuthenticationTicket ticket, TUserData userData)
{
if( ticket == null )
throw new ArgumentNullException("ticket");
if( userData == null )
throw new ArgumentNullException("userData");
_identity = new FormsIdentity(ticket);
_userData = userData;
}
public TUserData UserData
{
get { return _userData; }
}
public IIdentity Identity
{
get { return _identity; }
}
public bool IsInRole(string role)
{
// 把判断用户组的操作留给UserData去实现。
IPrincipal principal = _userData as IPrincipal;
if( principal == null )
throw new NotImplementedException();
else
return principal.IsInRole(role);
}
与之配套使用的用户信息的类型定义如下(可以根据实际情况来定义): public class UserInfo : IPrincipal
{
public int UserId;
public int GroupId;
public string UserName;
// 如果还有其它的用户信息,可以继续添加。
public override string ToString()
{
return string.Format("UserId: {0}, GroupId: {1}, UserName: {2}, IsAdmin: {3}",
UserId, GroupId, UserName, IsInRole("Admin"));
}
#region IPrincipal Members
[ScriptIgnore]
public IIdentity Identity
{
get { throw new NotImplementedException(); }
}
public bool IsInRole(string role)
{
if( string.Compare(role, "Admin", true) == 0 )
return GroupId == 1;
else
return GroupId > 0;
}
#endregion
}
注意:表示用户信息的类型并不要求一定要实现IPrincipal接口,如果不需要用户组的判断,可以不实现这个接口。 登录时需要调用的方法(定义在MyFormsPrincipal类型中): /// <summary>
/// 执行用户登录操作
/// </summary>
/// <param name="loginName">登录名</param>
/// <param name="userData">与登录名相关的用户信息</param>
/// <param name="expiration">登录Cookie的过期时间,单位:分钟。</param>
public static void SignIn(string loginName, TUserData userData, int expiration)
{
if( string.IsNullOrEmpty(loginName) )
throw new ArgumentNullException("loginName");
if( userData == null )
throw new ArgumentNullException("userData");
// 1. 把需要保存的用户数据转成一个字符串。
string data = null;
if( userData != null )
data = (new JavaScriptSerializer()).Serialize(userData);
// 2. 创建一个FormsAuthenticationTicket,它包含登录名以及额外的用户数据。
FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(
2, loginName, DateTime.Now, DateTime.Now.AddDays(1), true, data);
// 3. 加密Ticket,变成一个加密的字符串。
string cookieValue = FormsAuthentication.Encrypt(ticket);
// 4. 根据加密结果创建登录Cookie
HttpCookie cookie = new HttpCookie(FormsAuthentication.FormsCookieName, cookieValue);
cookie.HttpOnly = true;
cookie.Secure = FormsAuthentication.RequireSSL;
cookie.Domain = FormsAuthentication.CookieDomain;
cookie.Path = FormsAuthentication.FormsCookiePath;
if( expiration > 0 )
cookie.Expires = DateTime.Now.AddMinutes(expiration);
HttpContext context = HttpContext.Current;
if( context == null )
throw new InvalidOperationException();
// 5. 写登录Cookie
context.Response.Cookies.Remove(cookie.Name);
context.Response.Cookies.Add(cookie);
}
这里有必要再补充一下: 登录状态是有过期限制的。Cookie有 有效期,FormsAuthenticationTicket对象也有 有效期。 这二者任何一个过期时,都将导致登录状态无效。 按照默认设置,FormsAuthenticationModule将采用slidingExpiration=true的策略来处理FormsAuthenticationTicket过期问题。 登录页面代码: <fieldset><legend>包含【用户信息】的自定义登录</legend> <form action="<%= Request.RawUrl %>" method="post">
<table border="0">
<tr><td>登录名:</td>
<td><input type="text" name="loginName" style="width: 200px" value="Fish" /></td></tr>
<tr><td>UserId:</td>
<td><input type="text" name="UserId" style="width: 200px" value="78" /></td></tr>
<tr><td>GroupId:</td>
<td><input type="text" name="GroupId" style="width: 200px" />
1表示管理员用户
</td></tr>
<tr><td>用户全名:</td>
<td><input type="text" name="UserName" style="width: 200px" value="Fish Li" /></td></tr>
</table>
<input type="submit" name="CustomizeLogin" value="登录" />
</form></fieldset>
登录处理代码: public void CustomizeLogin()
{
// -----------------------------------------------------------------
// 注意:演示代码为了简单,这里不检查用户名与密码是否正确。
// -----------------------------------------------------------------
string loginName = Request.Form["loginName"];
if( string.IsNullOrEmpty(loginName) )
return;
UserInfo userinfo = new UserInfo();
int.TryParse(Request.Form["UserId"], out userinfo.UserId);
int.TryParse(Request.Form["GroupId"], out userinfo.GroupId);
userinfo.UserName = Request.Form["UserName"];
// 登录状态100分钟内有效
MyFormsPrincipal<UserInfo>.SignIn(loginName, userinfo, 100);
TryRedirect();
}
显示用户信息的页面代码: <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>
为了能让上面的页面代码发挥工作,必须在页面显示前重新设置HttpContext.User对象。 protected void Application_AuthenticateRequest(object sender, EventArgs e)
{
HttpApplication app = (HttpApplication)sender;
MyFormsPrincipal<UserInfo>.TrySetUserInfo(app.Context);
}
TrySetUserInfo的实现代码: /// <summary>
/// 根据HttpContext对象设置用户标识对象
/// </summary>
/// <param name="context"></param>
public static void TrySetUserInfo(HttpContext context)
{
if( context == null )
throw new ArgumentNullException("context");
// 1. 读登录Cookie
HttpCookie cookie = context.Request.Cookies[FormsAuthentication.FormsCookieName];
if( cookie == null || string.IsNullOrEmpty(cookie.Value) )
return;
try {
TUserData userData = null;
// 2. 解密Cookie值,获取FormsAuthenticationTicket对象
FormsAuthenticationTicket ticket = FormsAuthentication.Decrypt(cookie.Value);
if( ticket != null && string.IsNullOrEmpty(ticket.UserData) == false )
// 3. 还原用户数据
userData = (new JavaScriptSerializer()).Deserialize<TUserData>(ticket.UserData);
if( ticket != null && userData != null )
// 4. 构造我们的MyFormsPrincipal实例,重新给context.User赋值。
context.User = new MyFormsPrincipal<TUserData>(ticket, userData);
}
catch { /* 有异常也不要抛出,防止攻击者试探。 */ }
}
在多台服务器之间使用Forms身份认证默认情况下,ASP.NET 生成随机密钥并将其存储在本地安全机构 (LSA) 中, 因此,当需要在多台机器之间使用Forms身份认证时,就不能再使用随机生成密钥的方式, 需要我们手工指定,保证每台机器的密钥是一致的。 用于Forms身份认证的密钥可以在web.config的machineKey配置节中指定,我们还可以指定加密解密算法: <machineKey decryption="Auto" [Auto | DES | 3DES | AES] decryptionKey="AutoGenerate,IsolateApps" [String] /> 关于这二个属性,MSDN有如下解释: ![]() 在客户端程序中访问受限页面这一小节送给所有对自动化测试感兴趣的朋友。 有时我们需要用代码访问某些页面,比如:希望用代码测试服务端的响应。 如果是简单的页面,或者页面允许所有客户端访问,这样不会有问题, 但是,如果此时我们要访问的页面是一个受限页面,那么就必须也要像人工操作那样: 先访问登录页面,提交登录数据,获取服务端生成的登录Cookie, 接下来才能去访问其它的受限页面(但要带上登录Cookie)。 注意:由于登录Cookie通常是加密的,且会发生变化,因此直接在代码中硬编码指定登录Cookie会导致代码难以维护。 在前面的示例中,我已在web.config为MyInfo.aspx设置过禁止匿名访问,如果我用下面的代码去调用: private static readonly string MyInfoPageUrl = "http://localhost:51855/MyInfo.aspx";
static void Main(string[] args)
{
// 这个调用得到的结果其实是default.aspx页面的输出,并非MyInfo.aspx
HttpWebRequest request = MyHttpClient.CreateHttpWebRequest(MyInfoPageUrl);
string html = MyHttpClient.GetResponseText(request);
if( html.IndexOf("<span>Fish</span>") > 0 )
Console.WriteLine("调用成功。");
else
Console.WriteLine("页面结果不符合预期。");
}
此时,输出的结果将会是: 页面结果不符合预期。 如果我用下面的代码: private static readonly string LoginUrl = "http://localhost:51855/default.aspx";
private static readonly string MyInfoPageUrl = "http://localhost:51855/MyInfo.aspx";
static void Main(string[] args)
{
// 创建一个CookieContainer实例,供多次请求之间共享Cookie
CookieContainer cookieContainer = new CookieContainer();
// 首先去登录页面登录
MyHttpClient.HttpPost(LoginUrl, "NormalLogin=aa&loginName=Fish", cookieContainer);
// 此时cookieContainer已经包含了服务端生成的登录Cookie
// 再去访问要请求的页面。
string html = MyHttpClient.HttpGet(MyInfoPageUrl, cookieContainer);
if( html.IndexOf("<span>Fish</span>") > 0 )
Console.WriteLine("调用成功。");
else
Console.WriteLine("页面结果不符合预期。");
// 如果还要访问其它的受限页面,可以继续调用。
}
此时,输出的结果将会是: 调用成功。 说明:在改进的版本中,我首先创建一个CookieContainer实例, 它可以在HTTP调用过程中接收服务器产生的Cookie,并能在发送HTTP请求时将已经保存的Cookie再发送给服务端。 在创建好CookieContainer实例之后,每次使用HttpWebRequest对象时, 只要将CookieContainer实例赋值给HttpWebRequest对象的CookieContainer属性,即可实现在多次的HTTP调用中Cookie的接收与发送, 最终可以模拟浏览器的Cookie处理行为,服务端也能正确识别客户的身份。
ASP.NET Forms身份认证就说到这里,如果您对ASP.NET Windows身份认证有兴趣,那么请关注我的后续博客。 |
|













最新评论