A
Anonieko Ramos
ASP.NET Forms Authentication Best Practices
Dr. Dobb's Journal February 2004
Protecting user information is critical
By Douglas Reilly
Douglas is the author of Designing Microsoft ASP.NET Applications and
owner of Access Microsystems. Doug can be reached at
(e-mail address removed).
--------------------------------------------------------------------------------
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
(a)
<authentication mode="Forms" >
<forms
loginUrl="login.aspx"
protection="All"
timeout="30"
path="/" />
</authentication>
(b)
<authorization>
<deny users="?" />
</authorization>
(c)
<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
(a)
private string CreateSalt(int size)
{
RNGCryptoServiceProvider rng=new RNGCryptoServiceProvider();
byte[] buff=new byte[size];
rng.GetBytes(buff);
return Convert.ToBase64String(buff);
}
(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.";
}
}
}
Dr. Dobb's Journal February 2004
Protecting user information is critical
By Douglas Reilly
Douglas is the author of Designing Microsoft ASP.NET Applications and
owner of Access Microsystems. Doug can be reached at
(e-mail address removed).
--------------------------------------------------------------------------------
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
(a)
<authentication mode="Forms" >
<forms
loginUrl="login.aspx"
protection="All"
timeout="30"
path="/" />
</authentication>
(b)
<authorization>
<deny users="?" />
</authorization>
(c)
<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
(a)
private string CreateSalt(int size)
{
RNGCryptoServiceProvider rng=new RNGCryptoServiceProvider();
byte[] buff=new byte[size];
rng.GetBytes(buff);
return Convert.ToBase64String(buff);
}
(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.";
}
}
}