Dr. Dobb's is part of the Informa Tech Division of Informa PLC

This site is operated by a business or businesses owned by Informa PLC and all copyright resides with them. Informa PLC's registered office is 5 Howick Place, London SW1P 1WG. Registered in England and Wales. Number 8860726.


Channels ▼
RSS

.NET

ASP.NET Forms Authentication Best Practices


Feb04: ASP.NET Forms Authentication Best Practices

Douglas is the author of Designing Microsoft ASP.NET Applications and owner of Access Microsystems. Doug can be reached at [email protected].


For most ASP.NET web sites that need to be secured, the only reasonable option for authenticating users is ASP.NET Forms Authentication. While Windows and Passport authentication are available, they are not nearly as popular. For Windows Authentication, you need to have all users in a Windows domain, which is impractical for many applications—especially Internet applications. Passport Authentication is attractive, although not necessarily developer friendly, both financially and tool-wise.

A major issue for all developers is security, particularly when it comes to storing and safeguarding user's personal information—and among the most sensitive stored information is the user's password. Unlike credit-card information that many sites store only until credit-card authorization is received, passwords must be used to authenticate users for every visit to a restricted web page.

I can hear you saying, "My site does not really contain any really secret information. We use Forms Authentication primarily to let users personalize the content they receive, and save information they have entered for future visits." While that can be the case, it misses the point. Recently I did an informal survey of my nonprogrammer friends and relatives and asked how many passwords they use. Virtually all of the Internet users used either a single password or a couple of passwords for all sites. Generally, they used one password when there are no special character requirements and another for sites that demand a greater variety of character types (numbers and punctuation). Of the two users who said they specify a different password for every site, one indicated it was a burden and planned to change since it caused no end of confusion.

Does Your Site Require High Security?

So even if your site does not contain top-secret information, it is likely it does contain passwords that guard much more sensitive sites. Knowing this, what can you do? Is encrypting the user passwords sufficient? What happens if your user database is compromised? Will your encryption survive attacks where there's unlimited time to process the passwords? And what about a rogue administrator who has access to the site, database, passwords, and algorithms used to encrypt the passwords?

The solution is to use a one-way hash, a cryptographic technique that encrypts in a way that it is impossible to derive the original value from the hashed value. Using this technique, even you don't know the password of users (unless the login page is modified to capture the clear text of the password as it is entered).

When I suggest using a one-way hash for passwords on various newsgroups, the objection is that users will not be able to recover passwords when they're lost. True, but alternate arrangements can be made; for instance, e-mailing new passwords (perhaps made from combining two random words with a punctuation mark between them) or a link that brings users to a page where they can directly enter the password they wish to use. If the real user requests the password be reset, the e-mail about the new password shortly arrives. If someone else requests that the password be reset using a different user's e-mail, when the e-mail message is sent to real users, it alerts them that someone has been tinkering with their user account.

ASP.NET Forms Authentication Basics

To use Forms Authentication in ASP.NET, you need to modify settings in the Web.Config file in the application folder. The Authentication section of Web.Config needs to be changed to look something like Listing One(a), where you want to use login.aspx to log users in. protection="All" means that you want data validation and encryption on the authentication cookie. There is a 30-minute timeout on the cookie, and the cookie is saved in the root path. In addition, the Authorization element must also be changed to look like Listing One(b).

If you do not deny unauthenticated users (signified by the "?"), then Forms Authentication won't work, and all users will be able to get to all pages. In this example, you also have a Registration page, and users need to get to this page even though they are not logged in. To allow this, add the location element in Listing One(c) to Web.Config, inside the configuration element. This section is used as a location override for the Register.aspx page. In this example, I explicitly allow unauthenticated users to reach the register page.

Listings Two and Three validate users against one-way hashed passwords. (The complete source code and SQL Database Create Script are available electronically; see "Resource Center," page 5.) Listing Two is the UserDB utility class that calls the underlying database, and would likely be something you might change if it is implemented on your site. In the example, the SqlClient namespace is used and stored procedures are called using SqlParameters. (Using parameters, rather than building up SQL strings to execute, is critical to building secure systems. Stored Procedures are not essential since you can also use parameters on ad hoc SELECT, UPDATE, and INSERT SQL Statements.)

A User Database Class

The UserDB class contains three public static methods.

  • The first is SelectUserInfo. Given a UserName (passed as a parameter), this method returns a DataSet with the information for the requested user, or a null if the user is not found. In this example, the fields returned in Tables[0] are:

    int PersonID
    string LastName
    string FirstName
    string UserName
    DateTime LastLogin
    string EMail
    Bool MustChangePassword
    String PasswordHash
    string Salt
    DateTime DatePasswordChange
    
    MustChangePassword is a Boolean value that indicates if users should be forced to change their password. Commonly, you might set this to True if the user's password is reset.

  • The second method in the UserDB class is ChangePassword, with the signature:

    public static bool ChangePassword(int UserID,
       string NewPasswordHash,
       string Salt,bool MustChangePassword)
    
    This method does exactly what you would expect, allowing the password for the specified user (by the UserID parameter) to be changed. Since you are not storing a plaintext password in the database, what is sent is not the password but rather the hashed password and the string used as salt for the hashing.

  • The final method in UserDB is SaveNewUser, with the signature:

    public static bool SaveNewUser(string UserName,
       string LastName,string FirstName,string email,
         string PasswordHash,string Salt,
       bool MustChangePassword)
    
    This method is used to create new users and simply passes the information sent into a stored procedure. Each of these methods calls a stored procedure and you can replace this code with whatever database code you like.

User Class

The User class (Listing Three), where the real work of securing user passwords takes place, has a number of private variables and two private methods. One possible way to compromise a password database is to use a dictionary attack. For example, assume a common password is "password." Using one-way encryption, if two users have set their password to "password," once one password is compromised, all other users who have the same hashed password are also compromised.

This is where the previously mentioned Salt comes into use. Salt is just a string of characters, for instance LGk2dcw=, used in combination with the clear text password, so that when hashed, each hashed password is different even if the original password is the same. There is the private method CreateSalt in the User class; see Listing Four(a). The RNGCryptoServiceProvider class referenced in CreateSalt is a class that provides a random-number generator using the implementation provided by the Cryptographic Service Provider. GetBytes returns a cryptographically strong sequence of values, meaning the values are random in a precise way. There is an additional private method in the User class that is used to create the password and hash; see Listing Four(b). This method concatenates the password and salt, then creates a hashed password by calling the somewhat unfortunately named HashPasswordForStoringInConfigFile method of the FormsAuthentication class. This method does exactly what it says, creating a hash suitable for storing in a configuration file (that is, nonbinary). For instance, a hash might look like this string:

4EF1EED06A845CE5385FC7DA2E848C4F93401D58

This is a representation of the hash where each byte is represented by two hex characters. The class is used in several places, first in the Login.aspx.cs page, the code-behind page for the Login page. When users enter a username/password and click the Login button, the click handler (Listing Five) is called. The btnLogin_Click method instantiates a new User object and fills in the required properties for authentication (UserName and Password). With the required properties set, btnLogin_Click calls the VerifyPassword instance method on the newly instantiated User object.

After declaring variables and validating that required properties are set correctly, the VerifyPassword method calls the static SelectUserInfo in the UserDB class. Recall that this method returns a DataSet with a single table and a single row—presuming that there is some data returned in the DataSet, determined by checking the Count property of the Tables collection of the DataSet; see Listing Six.

Once you've confirmed that there is some data in the table, gather the Salt from the returned DataSet with the Password the user has entered, and create a hashed password. Given that newly hashed password, you compare it with the value stored in the database as PasswordHash. If the new hashed password and the one from the database are the same, you know the users are who they say they are (or at least that they know the correct password).

Looking back at btnLogin_Click, if users appear to be who they say they are, call RedirectFromLoginPage from the FormsAuthentication class. This method sets a cookie used to track who users are, and redirects users back to the page they were sent from. So in this application, you might set Default.aspx as the homepage, and when users try to access that page, they are redirected to the Login.aspx page.

Of course, there are a couple of other requirements when you are creating an application secured with forms authentication. The standard way to change a password is to enter the current password, then enter the new password twice. On this screen, I use standard ASP.NET validators to verify that the fields are filled in, and that the new password is entered identically twice. One thing to be especially careful about is exposing information you do not intend to in the validator code. If, in fact, your system lets you know the user's current password, it would be a terrible idea to use the Compare validator to ensure that the Old Password field is filled with the correct password. The Compare validator has a ValueToCompare property that could be used to hold this value; however, doing so sends the current password to the browser as clear text.

Figure 1 is the Change Password screen with the new password not entered correctly in both fields. Once all fields pass the validators and the user clicks the Submit button, Listing Seven in the Button1_Click method is executed. Once again, the User object is created and the properties are set. In this example, you use the User.Identity.Name property to get the UserName that was saved when FormsAuthentication.RedirectFromLoginPage was called on the Login screen if the current password entered is correct (as confirmed by a true return from VerifyPassword).

There is one quirk in how RedirectFromLoginPage works. If you go directly to the login page instead of going to a secured page and then redirecting to the login page, there is no ReturnUrl passed in the query string to the login page. In that case, ASP.NET redirects to a page named Default.aspx (and displays a 404 error if you do not have a Default.aspx). My solution is to always have a Default.aspx, even if that is not in fact the real homepage, and redirect from that page to whatever the real homepage is.

To make this system something that you can just use (and not what you should be doing in a real application), this system lets you register if you like from the main page. Clicking on the Register link from the login page brings you to a form like in Figure 2. This screen also uses ASP.NET validators to ensure required fields are entered and that the password is entered identically in both password fields (using logic just like the ChangePassword screen). When you click the Save button, Listing Eight is executed. In this case, I also instantiate a User object, but rather than use it, I just call the SaveNewUser method on that object. In the end, this code calls simply down into the UserDB method of the same name, after doing the same one-way hashing on the password and salt.

Possible Enhancements

There are a number of improvements that could be made to this code in a production environment. First, you might want to implement a Group system, so that in addition to allowing/disallowing unauthenticated users, you can use a full role-based system. By storing user roles in the authentication cookie, you can restore them into a GenericPrincipal object whenever Application_AuthenticateRequest is called. Also, to avoid another roundtrip to the database, I do not have a method in place to seed the LastLogin DateTime field when users log in. If this is important, you could implement this. And finally, the logic to reset the password is not present, although the same logic used to create new users can be used to reset passwords. From there, you could use whatever logic you want to send new passwords to users.

One other improvement (most useful if the database server was on a different machine than the web server) would be to store an additional string to act as salt somewhere in the actual web application. This way, compromising the database alone will not allow even a user by user dictionary attack.

DDJ

Listing One

<B>(a)</B>
<authentication mode="Forms" > 
    <forms 
       loginUrl="login.aspx"
       protection="All"
       timeout="30"
       path="/" />
</authentication>

<B>(b)</B>
<authorization>
   <deny users="?" /> 
</authorization>

<B>(c)</B>
<location path="Register.aspx">
  <system.web>
    <authorization>
      <allow users="?"/>
    </authorization>
  </system.web>
</location>

Back to Article

Listing Two

using System;
using System.Configuration;
using System.Data;
using System.Data.SqlClient;
using System.Web.Security;

namespace FormsAuth
{
    /// <summary>
    /// Summary description for UserDB.
    /// </summary>
    public class UserDB
    {
        public static DataSet SelectUserInfo(string UserName)
        {
            string strCn;
            DataSet ds=null;
            if ( UserName==string.Empty || UserName==null )
            {
             throw new NullReferenceException("User Name Must Be Specified!");
            }
            strCn=System.Configuration.ConfigurationSettings.
                                               AppSettings["DSN"].ToString();
            SqlConnection cn=new SqlConnection(strCn);
            cn.Open();
            try
            {
                SqlCommand cmd=new SqlCommand("spSelectUserInfo",cn);
                cmd.CommandType=CommandType.StoredProcedure;
                cmd.Parameters.Add("@UserName",UserName);
                SqlDataAdapter da=new SqlDataAdapter(cmd);
                ds=new DataSet();
                da.Fill(ds,"User");
            }
            catch ( Exception )
            {
                // Do something...
            }
            finally
            {
                cn.Close();
            }
            return ds;
        }
        public static bool ChangePassword(int UserID, string NewPasswordHash,
                                         string Salt,bool MustChangePassword)
        {
            bool ret=false;
            if ( NewPasswordHash==string.Empty || UserID==0 )
            {
              throw new Exception("Not all required variables set in UserDB");
            }
            string strCn;
            strCn=System.Configuration.ConfigurationSettings.
                                               AppSettings["DSN"].ToString();
            SqlConnection cn=new SqlConnection(strCn);
            cn.Open();
            try
            {
                SqlCommand cmd=new SqlCommand("spSaveChangedPassword",cn);
                cmd.CommandType=CommandType.StoredProcedure;
                cmd.Parameters.Add("@UserID",UserID);
                cmd.Parameters.Add("@PasswordHash",NewPasswordHash);
                cmd.Parameters.Add("@Salt",Salt);
                cmd.Parameters.Add("@MustChangePassword",MustChangePassword);
                SqlParameter prm=new SqlParameter();
                prm.Direction=ParameterDirection.ReturnValue;
                prm.ParameterName="ReturnValue";
                cmd.Parameters.Add(prm);
                cmd.ExecuteNonQuery();
                if ( (int)cmd.Parameters["ReturnValue"].Value!=0 )
                {
                    ret=true;
                }
            }
            finally
            {
                cn.Close();
            }
            return ret;
        }
        public static bool SaveNewUser(string UserName, string LastName,
            string FirstName,string email,string PasswordHash,string Salt,
            bool MustChangePassword)
        {
            bool ret=false;
            string strCn;
            strCn=System.Configuration.ConfigurationSettings.
                                            AppSettings["DSN"].ToString();
            SqlConnection cn=new SqlConnection(strCn);
            cn.Open();
            try
            {
                SqlCommand cmd=new SqlCommand("spSaveNewUser",cn);
                cmd.CommandType=CommandType.StoredProcedure;
                cmd.Parameters.Add("@UserID",0);
                cmd.Parameters.Add("@UserName",UserName);
                cmd.Parameters.Add("@LastName",LastName);
                cmd.Parameters.Add("@FirstName",FirstName);
                cmd.Parameters.Add("@email",email);
                cmd.Parameters.Add("@PasswordHash",PasswordHash);
                cmd.Parameters.Add("@Salt",Salt);
                cmd.Parameters.Add("@MustChangePassword",MustChangePassword);
                SqlParameter prm=new SqlParameter();
                prm.Direction=ParameterDirection.ReturnValue;
                prm.ParameterName="ReturnValue";
                cmd.Parameters.Add(prm);
                cmd.ExecuteNonQuery();
                if ( (int)cmd.Parameters["ReturnValue"].Value!=0 )
                {
                    ret=true;
                }
            }
            finally
            {
                cn.Close();
            }
            return ret;
        }
    }
}

Back to Article

Listing Three

using System;
using System.Data;
using System.Security;
using System.Security.Cryptography;
using System.Web.Security;

namespace FormsAuth
{
    /// <summary>
    /// Summary description for User.
    /// </summary>
    public class User
    {
        private string m_LastName;
        private string m_FirstName;
        private string m_UserName;
        private string m_Email;
        private string m_Password;
        private bool m_MustChangePassword;
        private int m_UserID;

        #region Properties
        public string LastName
        {
            get { return m_LastName; }
            set { m_LastName=value; }
        }
        public string FirstName
        {
            get { return m_FirstName; }
            set { m_FirstName=value; }
        }
        public string UserName
        {
            get { return m_UserName; }
            set { m_UserName=value; }
        }
        public string Email
        {
            get { return m_Email; }
            set { m_Email=value; }
        }
        public string Password
        {
            get { return m_Password; }
            set { m_Password=value.ToLower(); }
        }
        public bool MustChangePassword
        {
            get { return m_MustChangePassword; }
            set { m_MustChangePassword=value; }
        }
        public int UserID
        {
            get { return m_UserID; }
            set { m_UserID=value; }
        }
        #endregion
        #region Private Methods
        private string CreateSalt(int size)
        {
            RNGCryptoServiceProvider rng=new RNGCryptoServiceProvider();
            byte[] buff=new byte[size];
            rng.GetBytes(buff);
            return Convert.ToBase64String(buff);
        }
        private string CreatePasswordHash(string pwd,string salt)
       {
            string saltAndPassword=string.Concat(pwd,salt);
            string hashedPassword= 
                FormsAuthentication.HashPasswordForStoringInConfigFile(
                saltAndPassword,"SHA1");
            return hashedPassword;
        }
        #endregion
        public User()
        {
            m_LastName=string.Empty;
            m_FirstName=string.Empty;
            m_UserName=string.Empty;
            m_Email=string.Empty;
            m_Password=string.Empty;
            m_UserID=0;
        }
        public bool VerifyPassword()
        {
            string PasswordHashFromDB;
            string strSalt;
            bool ret=false;
            if ( m_UserName==string.Empty || m_Password==string.Empty )
            {
                throw new NullReferenceException("Not all required 
                                                        properties set!");
            }
            try
            {
                DataSet ds=UserDB.SelectUserInfo(m_UserName);
                if ( ds.Tables.Count!=0 )
                {
                    strSalt=ds.Tables[0].Rows[0]["Salt"].ToString();
                    string hashedPasswordAndSalt = 
                        this.CreatePasswordHash(m_Password,strSalt);
                    PasswordHashFromDB=
                             ds.Tables[0].Rows[0]["PasswordHash"].ToString(); 
                    if ( PasswordHashFromDB!=string.Empty && 
                        PasswordHashFromDB.Equals(hashedPasswordAndSalt) )
                    {
                        m_UserID=int.Parse(ds.Tables[0].
                                             Rows[0]["PersonID"].ToString());
                        m_FirstName=ds.Tables[0].
                                             Rows[0]["FirstName"].ToString();
                        m_LastName=ds.Tables[0].
                                             Rows[0]["LastName"].ToString();
                        m_MustChangePassword=bool.Parse(ds.Tables[0].
                                    Rows[0]["MustChangePassword"].ToString());
                        m_Email=ds.Tables[0].Rows[0]["Email"].ToString();
                        ret=true;
                    }
                }
            }
            catch ( Exception exc )
            {
                // rethrow, or you could do something useful...
                throw exc;
           }
            finally
            {
            }
            return ret;
        }
        public bool ChangePassword(string NewPassword)
        {
            return ChangePassword(NewPassword,false);
        }
        public bool ChangePassword(string NewPassword,bool MustChangePassword)
        {
            bool ret=false;
            if ( this.UserID==0 )
            {
                throw new Exception("User Not Initialized. 
                                                    Can't change password.");
            }
            if ( NewPassword==string.Empty )
            {
                throw new NullReferenceException("Not all required arguments set!");
            }
            try
            {
                string salt=CreateSalt(5);
                string PasswordHash=CreatePasswordHash(NewPassword,salt);
                UserDB.ChangePassword(this.m_UserID,NewPassword,salt,
                                                      MustChangePassword);
                ret=true;
            }
            catch ( Exception )
            {
            }
            return ret;
       }
       public bool SaveNewUser(string UserName,string LastName,
       string FirstName,string email,string Password,bool MustChangePassword)
       {
            bool ret=false;
            string salt=CreateSalt(5);
            string PasswordHash=CreatePasswordHash(Password,salt);
            return UserDB.SaveNewUser(UserName,LastName,FirstName,
                           email,PasswordHash,salt,MustChangePassword);
        }

    }
}

Back to Article

Listing Four

<B>(a)</B>
private string CreateSalt(int size)
{
   RNGCryptoServiceProvider rng=new RNGCryptoServiceProvider();
   byte[] buff=new byte[size];
   rng.GetBytes(buff);
   return Convert.ToBase64String(buff);
}

<B>(b)</B>
private string CreatePasswordHash(string pwd,string salt)
{
 string saltAndPassword=string.Concat(pwd,salt);
 string hashedPassword=FormsAuthentication.HashPasswordForStoringInConfigFile(
     saltAndPassword,"SHA1");
 return hashedPassword;
}

Back to Article

Listing Five

private void btnLogin_Click(object sender, System.EventArgs e)
{
    FormsAuth.User u=new FormsAuth.User();
    u.UserName=this.edUserName.Text;
    u.Password=this.edPassword.Text;
    if ( u.VerifyPassword()==true )
    {
        // Redirect, don't bother with persistent cookie.
        FormsAuthentication.RedirectFromLoginPage(u.UserName,false);
    }
    else
    {
        this.lblError.Text="Sorry - Could not log you in...";
    }
}

Back to Article

Listing Six

DataSet ds=UserDB.SelectUserInfo(m_UserName);
if ( ds.Tables.Count!=0 )
{
   strSalt=ds.Tables[0].Rows[0]["Salt"].ToString();
   string hashedPasswordAndSalt = this.CreatePasswordHash(m_Password,strSalt);
   PasswordHashFromDB=ds.Tables[0].Rows[0]["PasswordHash"].ToString(); 
   if ( PasswordHashFromDB!=string.Empty && 
        PasswordHashFromDB.Equals(hashedPasswordAndSalt) )
   {
        m_UserID=int.Parse(ds.Tables[0].Rows[0]["PersonID"].ToString());
        m_FirstName=ds.Tables[0].Rows[0]["FirstName"].ToString();
        m_LastName=ds.Tables[0].Rows[0]["LastName"].ToString();
        m_MustChangePassword=bool.Parse(
                   ds.Tables[0].Rows[0]["MustChangePassword"].ToString());
        m_Email=ds.Tables[0].Rows[0]["Email"].ToString();
        ret=true;
   }
}

Back to Article

Listing Seven

private void Button1_Click(object sender, System.EventArgs e)
{
    if ( Page.IsValid )
    {
        FormsAuth.User u=new FormsAuth.User();
        u.UserName=User.Identity.Name;
        u.Password=this.edOldPassword.Text;
        if ( u.VerifyPassword() )
        {
            if ( u.ChangePassword(edPassword1.Text) )
            {
                lblMessage.Text="Password Changed!";
            }
            else
            {
                lblMessage.Text="Password NOT Changed!";
            }
        }
    }
}

Back to Article

Listing Eight

private void Button1_Click(object sender, System.EventArgs e)
{
    if ( Page.IsValid )
    {
        FormsAuth.User u=new FormsAuth.User();
        if ( u.SaveNewUser(edUserName.Text,edLastName.Text,
            edFirstName.Text,edEmail.Text,edPassword1.Text,true) )
        {
            Response.Redirect("Login.aspx");
        }
        else
        {
            lblMessage.Text="Can't register that name. Please try again.";
        }
    }
}



Back to Article


Related Reading


More Insights






Currently we allow the following HTML tags in comments:

Single tags

These tags can be used alone and don't need an ending tag.

<br> Defines a single line break

<hr> Defines a horizontal line

Matching tags

These require an ending tag - e.g. <i>italic text</i>

<a> Defines an anchor

<b> Defines bold text

<big> Defines big text

<blockquote> Defines a long quotation

<caption> Defines a table caption

<cite> Defines a citation

<code> Defines computer code text

<em> Defines emphasized text

<fieldset> Defines a border around elements in a form

<h1> This is heading 1

<h2> This is heading 2

<h3> This is heading 3

<h4> This is heading 4

<h5> This is heading 5

<h6> This is heading 6

<i> Defines italic text

<p> Defines a paragraph

<pre> Defines preformatted text

<q> Defines a short quotation

<samp> Defines sample computer code text

<small> Defines small text

<span> Defines a section in a document

<s> Defines strikethrough text

<strike> Defines strikethrough text

<strong> Defines strong text

<sub> Defines subscripted text

<sup> Defines superscripted text

<u> Defines underlined text

Dr. Dobb's encourages readers to engage in spirited, healthy debate, including taking us to task. However, Dr. Dobb's moderates all comments posted to our site, and reserves the right to modify or remove any content that it determines to be derogatory, offensive, inflammatory, vulgar, irrelevant/off-topic, racist or obvious marketing or spam. Dr. Dobb's further reserves the right to disable the profile of any commenter participating in said activities.

 
Disqus Tips To upload an avatar photo, first complete your Disqus profile. | View the list of supported HTML tags you can use to style comments. | Please read our commenting policy.