Asp.Net 用户验证(自定义IPrincipal和IIdentity)

引言

前一段时间有两个朋友问我,为什么在HttpModule中无法获得到Session值,因为他们希望自定义一个HttpModule,然后在其中获取Session来进行用户验证。我奇怪为什么不使用.Net Framework已经提供的验证机制,而要和Asp时一样,自己手工进行cookie+Session验证?我们是基于.Net Framework这个平台进行编程,所以我觉得,在很多情况下,使用Framework已经建立好的机制会显著地提高工作效率,而且.NET Framework内置的验证机制通常也更加安全。

.Net提供了一整套的验证和授权机制,这里验证和授权是不同的概念,验证(Authentication)是指“证明你确实是你所说的人”,通常是提供一个用户名和口令,然后与持久存储(比如数据库)中的用户名和口令进行对比。授权(Authorization)是指“你是否有足够的权限做某件事”,此时你的身份已经被证明过了(匿名用户、会员还是管理员),授权通常与用户组或者用户级别联系起来,不同的用户组拥有不同的权限(访问特定页面或者执行特定操作)。

回想一下我刚接触.Net时,也曾经完全绕过.NET的验证,自己编码采用Cookie+Session实现身份验证,并且一个Asp.Net 登录控件都没有使用,那时候的理由是:我要使用自定义的用户表,不能使用Asp.Net安全机制在App_Data下自动生成的AspNetDB.mdf中的一系列数据表。除此以外,还有一个原因,就是.Net验证机制的核心IPrincipal和Identity提供的信息用户信息太少了,当在页面后置代码中使用继承来的User属性(IPrincipal类型)时,它的Identity属性只有一个Name与用户数据相关(AuthenticationType与IsAuthenticated都是与验证相关),而很多时候我们都需要许多额外的用户数据。其实这只是一个误解罢了,以为使用Asp.Net的验证机制和登录控件就一定要使用其附带的数据表,以为Identity就只能携带一个Name属性。

实际上,.NET的安全机制包括了几个部分,除了验证以外,还包括MemberShip、Profile、Role等,我们完全可以只使用它的验证机制,而绕过它的MemberShip、Profile和Role,来实现通常我们用Cookie+Session完成的功能,而且更高效更安全。这篇文章将快速地实现这样的一个流程。

开始前的准备

创建页面,配置Web.config

我们先创建解决方案、建立站点,然后在站点中添加下述文件,它们将会在后面使用:

接着对Web.config进行一下配置,首先看根目录下的Web.config:

<?xml version="1.0"?>
<configuration>
    <system.web>
        <authentication mode="Forms">
            <forms timeout="600" slidingExpiration="true" loginUrl="~/SignIn.aspx" />
        </authentication>
    </system.web>
   
    <location path="AuthOnly.aspx" >
        <system.web>
            <authorization>
                <deny users="?" />
            </authorization>
        </system.web>
    </location>
</configuration>

这里我们指定了采用Forms验证,并且设置用户身份验证过期时间为600分钟(默认为30分钟),slidingExpiration的意思是说timeout采用绝对时间还是滑动时间,当采用滑动时间时,如果在timeout时间内再次浏览页面,用户的最后活跃时间将设为当前时间,并重新开始计算,这里我们采用滑动时间。loginUrl指定了登录页面,当匿名用户访问需要验证后才能访问的页面时,将会到自动导航到这里所设置的SignIn.aspx页面,默认为Login.aspx。

接着我们指定AuthOnly.aspx页面为只有验证过的用户才可以访问。然后创建了AuthOnly文件夹,在其下添加了一个web.config,对这个目录进行设置,指定该文件夹下所有文件只允许验证用户进行访问。

<configuration>
    <system.web>
         <authorization>
             <deny users="?" />
         </authorization>
    </system.web>
</configuration>

创建用户数据表和数据访问

既然是用户登录,所以我们自然需要一张用户表,在App_Data下创建一个SiteData数据库,然后添加一张User用户表,表的设置如下:

这个表模拟了一个小型的论坛用户表,字段的含义基本都是自解释的,UserImage是用户头像的地址,PostCount是用户的发帖数,ReplyCount是用户的回帖数,Level是用户的级别。我已经为表中添加了两条范例数据,其中一条用户名为JimmyZhang,密码为password。

接下来我们需要添加一个存储过程,这个存储过程接收一个name参数,和一个password输出参数,根据name判断User表中是否存在该用户,如果存在,则由password带回正确的密码:

ALTER PROCEDURE dbo.IsValidUser
(
    @userName varchar(50),
    @password varchar(50) OUTPUT
)
AS
    if Exists(Select Id From [User] Where [Name] = @userName)
        Begin
            Select @password = Password From [User] Where [name]= @userName
            Select 1        -- Ture
        End    
Select 0        -- false

这样做的目的是为了程序能够区分“不存在此用户”和“用户存在,但是密码不正确”这两种情况。如果Select的where子句为[name]=@userName and [password] = @password,则无法进行区分。由数据库带回了正确的密码之后,我们只需要在程序中与用户输入的密码进行对比就可以知道用户的密码是否正确。

接下来我们创建一个强类型DataSet作为我们的数据访问层,因为我发现使用强类型DataSet作数据访问是最快的,基本不需要编写一行代码,在App_Code中添加一个AuthDataSet数据集文件,然后将User表拖进去,另外配置一下UserTableAdapter,添加两个方法,一个是GetUserTable(@name),它根据name参数获得用户信息;一个是IsValidUser(@userName, @password),它调用了上面的存储过程,并且返回一个标量值(0或者1)。

配置好以后,你的AuthDataSet应该和下面一样:

如果你查看一下生成的IsValidUser()方法,就会发现它具有这样的签名:

public virtual object IsValidUser(string userName, ref string password)

由于它返回的是一个object类型,并且接收的是一个ref参数,尽管这样最通用,但是可能不够方便,注意到UserTableAdapter是一个部分类,所以我们可以在App_Code中再创建一个UserTableAdapter部分类,对它进行一个简单的包装:

namespace AuthDataSetTableAdapters {

    // 检查是否是正确的用户名,如果是正确的用户名,带回正确的密码
    public partial class UserTableAdapter {    
        public bool IsValidUserST(string userName, out string password) {
            password = "";
            return Convert.ToBoolean(this.IsValidUser(userName, ref password));        
        }
    }
}

这里的方法后缀ST,意思是StrongType(强类型)。好了,现在我们的数据访问就已经OK了,接下来我们看一下第一个页面:SignIn.aspx用户登录页面。

用户登录 -- 为Identity添加用户数据

Login.aspx页面实现

在登录页面,我们需要针对登录用户和非登录用户做不同的处理:如果用户尚未登录,则显示登录用的表单;如果用于已经登录了,则显示登录用户名并进行提示。完成这件事最好就是使用LoginView控件和LoginName控件了:

<asp:LoginView ID="LoginView1" runat="server">
    <LoggedInTemplate>
        <asp:LoginName ID="LoginName1" runat="server" />
        ,你已经登录了^_^ <br /><br />
                 
        你可以选择 <asp:LoginStatus ID="LoginStatus1" runat="server" LogoutPageUrl="~/Logout.aspx" LogoutAction="Redirect"  />
    </LoggedInTemplate>
    <AnonymousTemplate>
        用户名:<asp:TextBox ID="txtUserName" runat="server" Width="128px"></asp:TextBox>
        <br />
        密 码:<asp:TextBox ID="txtPassword" runat="server"></asp:TextBox>
        <br />
        <asp:Button ID="btnLogin" runat="server" Text="登 录" onclick="btnLogin_Click" Width="100px" />
        <br />
        <br />
        <asp:Label ID="lbMessage" runat="server" ForeColor="Red" Text=""></asp:Label>
    </AnonymousTemplate>
</asp:LoginView>

这里的关键是“登录”按钮的代码后置文件,在“引言”部分,我们提到了Identity中的信息太少,为了向Identity中添加信息,我们可以先获得FormsIdentity的Ticket属性,它是一个FormsAuthenticationTicket类型,它含有一个UserData字符串属性可以用于承载我们的用户数据,遗憾的是这个属性是只读的,为了给这个属性赋值,我们需要重新新构建一个FormsAuthenticationTicket,并在构造函数中传入我们想要添加的用户信息。FormasAuthenticationTicket包含了诸多用于用户验证的信息,它从Cookie中获得,可以认为它是服务端对Cookie的一个包装,只是这里的Cookie的操作不需要我们来处理,而由Asp.Net运行时去处理。具体的代码如下:

public partial class SignIn : System.Web.UI.Page {

    private enum LoginResult {
        Success,
        UserNotExist,
        PasswordWrong
    }

    // 用户登录
    private LoginResult Login(string userName, string password) {

        string validPassword;   // 包含正确的密码
        AuthDataSetTableAdapters.UserTableAdapter adapter =
            new AuthDataSetTableAdapters.UserTableAdapter();

        // 判断用户名是否正确
        if (adapter.IsValidUserST(userName, out validPassword)) {
            // 判断密码是否正确
            if (password.Equals(validPassword))
                return LoginResult.Success;
            else
                return LoginResult.PasswordWrong;
        }

        // 用户名不存在
        return LoginResult.UserNotExist;
    }
   
    protected void btnLogin_Click(object sender, EventArgs e) {

        TextBox txtUserName = LoginView1.FindControl("txtUserName") as TextBox;
        TextBox txtPassword = LoginView1.FindControl("txtPassword") as TextBox;
        Label lbMessage = LoginView1.FindControl("lbMessage") as Label;

        string userName = txtUserName.Text;
        string password = txtPassword.Text;

        LoginResult result = Login(userName, password);

        string userData = "登录时间" + DateTime.Now.ToString();

        if (result == LoginResult.Success) {
            SetUserDataAndRedirect(userName, userData);        
        } else if (result == LoginResult.UserNotExist) {
            lbMessage.Text = "用户名不存在!";
        }else {
            lbMessage.Text = "密码有误!";
        }
    }


    // 添加自定义的值,然后导航到来到此页面之前的位置
    private void SetUserDataAndRedirect(string userName, string userData) {
        // 获得Cookie
        HttpCookie authCookie = FormsAuthentication.GetAuthCookie(userName, true);

        // 得到ticket凭据
        FormsAuthenticationTicket ticket = FormsAuthentication.Decrypt(authCookie.Value);

        // 根据之前的ticket凭据创建新ticket凭据,然后加入自定义信息
        FormsAuthenticationTicket newTicket = new FormsAuthenticationTicket(
            ticket.Version, ticket.Name, ticket.IssueDate,
            ticket.Expiration, ticket.IsPersistent, userData);

        // 将新的Ticke转变为Cookie值,然后添加到Cookies集合中
        authCookie.Value = FormsAuthentication.Encrypt(newTicket);
        HttpContext.Current.Response.Cookies.Add(authCookie);

        // 获得 来到登录页之前的页面,即url中return参数的值
        string url = FormsAuthentication.GetRedirectUrl(userName, true);

        Response.Redirect(url);
    }
}

我们首先定义了一个枚举,用来说明点击登录后的状态:Success(成功)、UserNotExsit(用户不存在)以及PasswordWrong(用户名存在,但密码错)。Login()方法调用了上一小节我们定义的强类型DataSet中的IsUserValidST()方法,然后返回登录结果。“搜索”按钮的事件处理方法反而非常简单,如果登录失败时在页面显示失败原因,如果登录成功则调用SetUserDataAndRedirect()方法。

在SetUserDataAndRedirect()方法中,我们执行了主要的逻辑,我们先获得了Asp.Net用于验证的Cookie,从Cookie中得到FormsAuthenticationTicket,最后,执行了前面所叙述的步骤,将我们自定义的数据 -- 当前用户的登录时间记录到了一个新构建的FormsAuthenticationTicket中,最后将它进行编码然后赋值给Cookie。接着我们导航到了来到SignIn.aspx之前所在的页面。

Default.aspx 页面预览

默认情况下SignIn.aspx在登录成功后会导航到Default.aspx页面,所以我们先简单的构建一下Default.aspx页面,看看实现的效果:

<asp:LoginView ID="LoginView1" runat="server">
    <AnonymousTemplate>
        欢迎访问, 游客 !     
    </AnonymousTemplate>
    <LoggedInTemplate>
        你好, <asp:LoginName ID="LoginName1" runat="server" /> ! <br />
        <strong>UserData值:</strong>
        <asp:Literal ID="lbUserData" runat="server" />
    </LoggedInTemplate>
</asp:LoginView>
<br />
<asp:LoginStatus ID="LoginStatus1" runat="server" LogoutPageUrl="~/Logout.aspx" LogoutAction="Redirect"  />

类似地,我们放置了一个LoginView控件,只是这里我们多放置了一个LoginStatus控件。接下来我们看一下后置代码:

protected void Page_Load(object sender, EventArgs e) {

    if (!IsPostBack) {
        if (Request.IsAuthenticated) {
            FormsIdentity identity = User.Identity as FormsIdentity;
            string userData = identity.Ticket.UserData;
            Literal lbUserData = LoginView1.FindControl("lbUserData") as Literal;
            lbUserData.Text = userData;
        }
    }
}

最后我们先进行登录,然后再打开Default.aspx页面,会看到类似这样的输出:

至此,我们已经看到了如何利用FormsAuthentionTicket来附带额外的用户数据,但是我们应该看到这种做法存在的问题:可以保存的数据过于单一,仅仅只是一个字符串。而我们第一节中所介绍的用户表包括各种类型的各种数据。如果你看过了 从一个范例看XML的应用 这篇文章,你应该立刻想到此处又是一个“单一字符串保存多种不同类型数据”的应用场景,我们可以定义XML来解决。对于这种方式,我不再演示了。实际上,我们可以自定义一个IPrincipal和IIdentity来完成,接下来就来看一下。

自定义IPrincipal和IIdentity

不管是在Windows上还是在Web上,.Net都使用这两个接口来实现用户的身份验证。它们不过是一个接口,实现了这两个接口的类型附带了用户的信息,最终被赋予线程(Windows)或Cookie(Web)来对用户进行验证。我们在App_Code下添加CustomPrincipal和CustomIdentity来实现这两个接口:

public class CustomPrincipal : IPrincipal {

    private CustomIdentity identity;

    public CustomPrincipal(CustomIdentity identity) {
        this.identity = identity;
    }

    public IIdentity Identity {
        get {
            return identity;
        }
    }

    public bool IsInRole(string role) {
        return false;
    }
}

public class CustomIdentity : IIdentity {
    private FormsAuthenticationTicket ticket;
    private HttpContext context = HttpContext.Current;

    public CustomIdentity(FormsAuthenticationTicket ticket) {
        this.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; }
    }

    // 这里可以是任意来自数据库的值,由Name属性取得
    // 需要注意此时已通过身份验证
    public string Email {
        get {
            HttpCookie cookie = context.Request.Cookies["Email"];

            if (cookie==null || String.IsNullOrEmpty(cookie.Value)) {
                string type = "jimmy_dev[at]163.com";   // 实际应根据name属性从数据库中获得
                cookie = new HttpCookie("UserType", type);
                cookie.Expires = DateTime.Now.AddDays(1);
                context.Response.Cookies.Add(cookie);
            }

            return cookie.Value;
        }
    }

    public string HomePage {
        get {
            HttpCookie cookie = context.Request.Cookies["HomePage"];

            if (cookie==null || String.IsNullOrEmpty(cookie.Value)) {
                string name = "www.tracefact.net";      // 实际应根据name属性从数据库中获得
                cookie = new HttpCookie("NickName", name);
                cookie.Expires = DateTime.Now.AddDays(1);
                context.Response.Cookies.Add(cookie);
            }
            return cookie.Value;
        }
    }
}

注意这里的HomePage和Email这两个属性,它们携带了我们的用户数据,这里我仅仅是对它们进行了一个简单的赋值,实际的数值应该是来自于数据库。还要注意获取到它们的值后被保存在了Cookie中,以避免频繁的对数据库进行访问。

定义了实现这两个接口的对象之后,我们还需要把它嵌入到应用程序的生命周期中,具体的做法就是挂接到HttpModule或者是重写Global.asax中的事件,这里我采用了重写Global.asax事件的方式,因此创建一个Global.asax文件,然后添加如下代码:

void Application_OnPostAuthenticateRequest(object sender, EventArgs e) {
    IPrincipal user = HttpContext.Current.User;

    if (user.Identity.IsAuthenticated
        && user.Identity.AuthenticationType == "Forms") {

        FormsIdentity formIdentity = user.Identity as FormsIdentity;
        CustomIdentity identity = new CustomIdentity(formIdentity.Ticket);

        CustomPrincipal principal = new CustomPrincipal(identity);
        HttpContext.Current.User = principal;

        Thread.CurrentPrincipal = principal;
    }
}

这段代码很好理解,它不过是在应用程序的PostAuthenticateRequest事件中用我们自定义的CustomPrincipal和CustomIdentity替换掉了默认的IPrincipal和IIdentity实现。

Default.aspx页面预览

我们再次对Default.aspx进行修改,添加两个Literal控件,用于显示我们自定义的数值:

自定义Identity中的值:<br />
<strong>Email:</strong>
<asp:Literal ID="ltrEmail2" runat="server"></asp:Literal><br />

<strong>HomePage:</strong>
<asp:Literal ID="ltrHomePage" runat="server"></asp:Literal><br />

然后修改页面的代码,使用我们的自定义CustomIdentity,然后从中获得自定义的属性值:

protected void Page_Load(object sender, EventArgs e) {

    if (!IsPostBack) {
        if (Request.IsAuthenticated) {

            CustomIdentity identity = User.Identity as CustomIdentity;
            if (identity != null) {
                // 获得UserData中的值
                string userData = identity.Ticket.UserData;
                Literal lbUserData = LoginView1.FindControl("lbUserData") as Literal;
                lbUserData.Text = userData;

                // 获得identity中的值
                ltrEmail2.Text = identity.Email;
                ltrHomePage.Text = identity.HomePage;
            }
        }
    }
}

如果你现在打开页面,将会看到类似下面的页面:

可以看到我们获得了定义在CustomIdentity中的属性。注意这里我只是做了一个示范,因此只在CustomIdentity中包含了Email和HomePage两个属性值,如果看到此处你便以为大功告成,然后将所有未完成的属性都添加到CustomIdentity中去就大错特错了。Identity的目的只是为你提供一个已经登录了的用户的名称,而不是携带所有的用户信息,这些信息应该由其他的类型提供。因此微软才定义了MemberShipUser类型和Profile。从这个角度上来看,自定义IPrincipal和IIdentity并没有太大的意义。

这里,我们最好是定义一个自己的类型来承载用户数据,下面我们就看下如何完成。

自定义类型携带用户数据

在App_Code中新建一个SiteUser类,它的实现如下,简单起见,我使用了公有字段而非属性:

public class SiteUser
{
    public string Name;
    public string UserImage;
    public DateTime RegisterDate;
    public string Email;
    public string HomePage;
    public int PostCount;
    public int ReplyCount;
    public byte Level;

    public SiteUser(AuthDataSet.UserRow userRow) {
        this.Email = userRow.Email;
        this.HomePage = userRow.Homepage;
        this.Level = userRow.Level;
        this.Name = userRow.Name;
        this.PostCount = userRow.PostCount;
        this.RegisterDate = userRow.RegisterDate;
        this.ReplyCount = userRow.ReplyCount;
        this.UserImage = userRow.UserImage;
    }

    // 实际应该由数据库获得
    public static SiteUser GetUser(string name) {

        AuthDataSetTableAdapters.UserTableAdapter adapter
            = new AuthDataSetTableAdapters.UserTableAdapter();
        AuthDataSet.UserDataTable userTable = adapter.GetUserTable(name);
       
        if(userTable.Rows.Count >0){
            return new SiteUser((AuthDataSet.UserRow)userTable.Rows[0]);
        }

        // 因为调用这个方法时,name应该是有效的,
        // 如果name无效,直接抛出异常
        throw new ApplicationException("User Not Found");
    }
}

它的GetUser()静态方法根据用户的名称获得了一个SiteUser对象,这里需要注意的是通常调用这个方法时,用户已经登录过了,也就是说其name参数总是有效的,因此当搜索数据库找不到记录时,我简单地抛出了异常。

Default.aspx 页面预览

我们再次修改Default.aspx,添加用于显示用户详细信息的控件和HTML标记:

<asp:Panel ID="pnlUserInfo" runat="server" Visible="false">
    <table class="mainTable" style="width:280px">
        <tr>
            <th style="background:#f5f5f5;text-align:center" colspan="2">用户信息</th>
        </tr>
        <tr>
            <td colspan="2" style="text-align: center">
                <asp:Image ID="imgHeadImage" runat="server" />
            </td>
        </tr>
        <tr>
            <td style="width:28%;">姓名:</td>
            <td>
                <asp:Literal ID="ltrName" runat="server"></asp:Literal>
            </td>
        </tr>
        <tr>
            <td>注册日期:</td>
            <td>
                <asp:Literal ID="ltrRegisterDate" runat="server"></asp:Literal>
            </td>
        </tr>
        <tr>
            <td>电子邮件:</td>
            <td>
                <asp:Literal ID="ltrEmail" runat="server"></asp:Literal>
            </td>
        </tr>
        <tr>
            <td>个人主页:</td>
            <td>
                <asp:HyperLink ID="lnkHomepage" runat="server"></asp:HyperLink>
            </td>
        </tr>
        <tr>
            <td>发帖数:</td>
            <td>
                <asp:Literal ID="ltrPostCount" runat="server"></asp:Literal>
            </td>
        </tr>
        <tr>
            <td>回帖数:</td>
            <td>
                <asp:Literal ID="ltrReplyCount" runat="server"></asp:Literal>
            </td>
        </tr>
        <tr>
            <td>用户等别:</td>
            <td>
                <asp:Image ID="imgUserLevel" runat="server" />
            </td>
        </tr>
    </table>
</asp:Panel>

然后修改页面的后置代码:

protected void Page_Load(object sender, EventArgs e) {
    if (!IsPostBack) {
        if (Request.IsAuthenticated) {

            // 上面相同

            SiteUser user = SiteUser.GetUser(identity.Name);
            PopulateControls(user);
        }
    }
}

private void PopulateControls(SiteUser user) {
    ltrEmail.Text = user.Email;
    ltrName.Text = user.Name;
    ltrPostCount.Text = user.PostCount.ToString();
    ltrRegisterDate.Text = user.RegisterDate.ToShortDateString();
    ltrReplyCount.Text = user.ReplyCount.ToString();
    lnkHomepage.Text = user.HomePage;
    lnkHomepage.NavigateUrl = user.HomePage;

    imgHeadImage.ImageUrl = "~/Images/" + user.UserImage;
    imgUserLevel.ImageUrl =
        String.Format("~/Images/Star{0}.gif", user.Level);

    pnlUserInfo.Visible = true;
}

这里,我们从Identity中获得用户的名称,然后再调用SiteUser的GetUser()方法,获得了一个SiteUser对象,最后使用这个SiteUser对象对页面控件进行了赋值。下面是最后的页面效果:

总结

在这篇文章中我们看到了如何使用Asp.Net内置机制实现用户验证的功能,并且通过FormsAuthenticationTicket的UserData属性、自定义IPrincipal和IIdentity、以及自定义对象三种方式实现了附加 (获取)用户数据。

本文所附带的代码中,还有一些页面只有登录用户才能进行访问,但在这篇文章中我没有演示和说明。使用.Net的验证机制,我们可以通过仅在Web.config设置一下,就拥有了以前需要编码才能实现的限制页面访问的功能。通过这篇文章,我希望大家能够看到,大多数情况下,我们都可以使用.Net的自定义验证系统,而没有必要再重复造轮子去实现自己的验证方式。

感谢阅读,希望这篇文章能给你带来帮助。