Thursday, February 23, 2012

FormsAuthentication, Identities and Role - based Security with a database

One of the most useful and perhaps most misunderstood authentication schemes built in to the ASP.NET runtime is Forms Authentication. Useful, because it is highly extensible and flexible (as we'll see in a moment). Misunderstood, because most developers don't get past the default setup described in the documentation and therefore never find out how to extend and customize it.



The keys to a successful understanding and implementation of Forms - based authentication are first - to become familiar with the FormsAuthentication class, its members and properties, and second - to learn how to implement it programmatically with a database containing usernames, passwords, and roles - the exact same type of roles that we use for Windows Authentication.
We implement this with the use of the Application_AuthenticateRequest method in Global.asax, like this:
protected void Application_AuthenticateRequest(Object sender, EventArgs e)
{
    if (HttpContext.Current.User != null)
     {
       if (HttpContext.Current.User.Identity.IsAuthenticated)
        {
          if (HttpContext.Current.User.Identity is FormsIdentity)
           {
             // Get Forms Identity From Current User
             FormsIdentity id = (FormsIdentity)HttpContext.Current.User.Identity;
             // Get Forms Ticket From Identity object
                FormsAuthenticationTicket ticket = id.Ticket;
            // Retrieve stored user-data (our roles from db)
               string userData = ticket.UserData;
               string[] roles = userData.Split(',');
               // Create a new Generic Principal Instance and assign to Current User
               HttpContext.Current.User = new GenericPrincipal(id, roles);
             }
        }
    }
}
As can be seen above, we check the current HttpContext for its Current.User property and determine if the Identity is Authenticated. If it is, we check to see if the identity is of type FormsIdentity. We then assign this Identity to a local member, "id", create a new FormsAuthenticationTicket from the id, and grab the custom UserData from it, which in this case is simply the comma - delimited string of roles that have been assigned to this user in our database. Finally, we create a new GenericPrincipal object and assign this to our Current.User HttpContext. At this point, we have now bound a custom identity to our HttpContext and it will travel around our site with this authenticaled user, wherever they may go. To enforce role based security on any page -- or even for specific controls on a page, all we need to do is query if (User.IsInRole("Administrator")) in our Page_Load or other pertinent code area and we can control access by using Response.Redirect back to the login page, or run code that enables or suppresses controls on the page, or whatever may be appropriate.
Before we review the code and web.config settings to look up each visitor in our database and use the above code, lets take a quick pass through a condensed version of theFormsAuthentication class:

FormsAuthentication Members

Public Constructors

FormsAuthentication ConstructorInitializes a new instance of the FormsAuthentication class.

Public Properties

FormsCookieNameReturns the configured cookie name used for the current application.
FormsCookiePathReturns the configured cookie path used for the current application.

Public Methods

AuthenticateAttempts to validate the credentials against those contained in the configured credential store, given the supplied credentials.
DecryptReturns an instance of a FormsAuthenticationTicket class, given an encrypted authentication ticket obtained from an HTTP cookie.
EncryptProduces a string containing an encrypted authentication ticket suitable for use in an HTTP cookie, given a FormsAuthenticationTicket.
Equals (inherited from Object)Overloaded. Determines whether two Object instances are equal.
GetAuthCookieOverloaded. Creates an authentication cookie for a given user name.
GetHashCode (inherited from Object)Serves as a hash function for a particular type, suitable for use in hashing algorithms and data structures like a hash table.
GetRedirectUrlReturns the redirect URL for the original request that caused the redirect to the logon page.
GetType (inherited from Object)Gets the Type of the current instance.
HashPasswordForStoringInConfigFileGiven a password and a string identifying the hash type, this routine produces a hash password suitable for storing in a configuration file.
InitializeInitializes FormsAuthentication by reading the configuration and getting the cookie values and encryption keys for the given application.
RedirectFromLoginPageOverloaded. Redirects an authenticated user back to the originally requested URL.
RenewTicketIfOldConditionally updates the sliding expiration on a FormsAuthenticationTicket.
SetAuthCookieOverloaded. Creates an authentication ticket and attaches it to the cookie's collection of the outgoing response. It does not perform a redirect.
SignOutRemoves the authentication ticket.
ToString (inherited from Object)Returns a String that represents the current Object.

Protected Methods

Finalize (inherited from Object)Overridden. Allows an Object to attempt to free resources and perform other cleanup operations before theObject is reclaimed by garbage collection.In C# and C++, finalizers are expressed using destructor syntax.
MemberwiseClone (inherited from Object)Creates a shallow copy of the current Object.

We will have an opportunity to use most of these members in our example. Now, how do we handle the login situation? Let's take a look at the web.config elements required for this setup:

<authentication mode="Forms">
<forms name="FormsAuthDB.AspxAuth"
loginUrl="default.aspx"
protection="All"
timeout ="10"
path="/"/>
</authentication>
<authorization>
<deny users="?" />
<allow users="*"/>
</authorization>
In this example, I have set up a FormsAuthentication block in web.config to enable Forms Authentication, provide a loginUrl (where you get directed to automatically if you are not authenticated when attempting to load a page), protection of "All" (recommended), timeout of 10 minutes for the ticket (cookie), and we are denying access to the anonymous user. The Path attribute controls the cookie path in the site; here, it's for the entire site. Be advised however, that you can place additional web.config files in subfolders on your site that provide more granular control about "who can go where".
Now in our login page (in this case Default.aspx because I want to catch everybody with a minimum of fuss), we'll need a couple of textboxes to ask for a username and password and I guess a button that says "LOGIN". I'll leave the UI up to you, let's look at the codebehind:
private void Button1_Click(object sender, System.EventArgs e)
 {
  // Initialize FormsAuthentication (reads the configuration and gets
  // the cookie values and encryption keys for the given application)

  FormsAuthentication.Initialize();

  // Create connection and command objects
  SqlConnection conn =
  new SqlConnection("Data Source=PETER;Database=Northwind;User ID=sa;password=;");
  conn.Open();
  SqlCommand cmd = conn.CreateCommand();
  cmd.CommandText = "SELECT roles FROM Employees WHERE username=@username " +
  "AND password=@password"; // this should really be a stored procedure, right?
// Fill our parameters
cmd.Parameters.Add("@username", SqlDbType.NVarChar, 64).Value = TextBox1.Text;
cmd.Parameters.Add("@password", SqlDbType.NVarChar, 64).Value = TextBox2.Text;
FormsAuthentication.HashPasswordForStoringInConfigFile(TextBox2.Text,"sha1");
// you can use the above method for encrypting passwords to be stored in the database
// Execute the command
SqlDataReader reader = cmd.ExecuteReader();
if (reader.Read())
  {
  // Create a new ticket used for authentication
  FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(
  1, // Ticket version
  TextBox1.Text, // Username to be associated with this ticket
  DateTime.Now, // Date/time issued
  DateTime.Now.AddMinutes(30), // Date/time to expire
  true, // "true" for a persistent user cookie (could be a checkbox on form)
  reader.GetString(0), // User-data (the roles from this user record in our database)
  FormsAuthentication.FormsCookiePath); // Path cookie is valid for

  // Hash the cookie for transport over the wire
    string hash = FormsAuthentication.Encrypt(ticket);
    HttpCookie cookie = new HttpCookie(
    FormsAuthentication.FormsCookieName, // Name of auth cookie (it's the name specified in web.config)
     hash); // Hashed ticket

  // Add the cookie to the list for outbound response
   Response.Cookies.Add(cookie);

  // Redirect to requested URL, or homepage if no previous page requested
  string returnUrl = Request.QueryString["ReturnUrl"];
  if (returnUrl == null) returnUrl = "LoggedIn.aspx";

  // Don't call the FormsAuthentication.RedirectFromLoginPage since it could
  // replace the authentication ticket we just added...

  Response.Redirect(returnUrl);
 }
   else
  {
  // Username and or password not found in our database...
  ErrorLabel.Text = "Username / password incorrect. Please login again.";
  ErrorLabel.Visible = true;
 }
}
The Parameters for the FormsAuthenticationTicket constructor are:
       version --The version number.
       name -- User name associated with the ticket.
       issueDate -- Time at which the cookie was issued.
      expiration -- Expiration date for the cookie.
      isPersistent -- True if the cookie is persistent.
      userData -- User-defined data to be stored in the cookie.
At this point, we're about 100% wired up for custom Forms Authentication. What I did here for this example is simply re-used the ever-popular Northwind database. I took the Employees table (hey, why reinvent the wheel...) and added three columns, all nvarchar:
username - the username they will login with
password - the password they will use
roles - a comma - delimited list of roles attached to this user e.g. "Administrator,Manager,Accounting, User"
So if you want to use the solution "out of the box" you'll only need to modify your Employees table in the Northwind database as above. Put in several users with different roles so you can experiment. Leave Nancy Davolio alone.
To summarize how custom FormsAuthentication works: a user is authenticated, then an authentication cookie ("ticket") is attached to the validating Response when you call one of the appropriate static methods of the FormsAuthentication provider. It is at that moment that the Application_AuthenticateRequest handler shown in the very first block of code in this article is called. When dynamically assigning custom roles (the UserData parameter) to a user you need to create a new instance of GenericPrincipal and assign it to the current thread context, and the Application_AuthenticateRequest handler in global.asax is the ideal place to do this.

The only thing that remains is to check users' identity credentials for roles wherever you need to in your code. This could be in the Page_Load event handler to control page access, or it could be around code that controls the visibility of particular controls on the page. For example, right now I'm coding a special utility page that allows users to view the status of some running services for one of our apps. However, only certain restricted users are actually allowed to start or stop these services. So with this arrangement, all I need to do is check the role of the authenticated user and if they aren't allowed to, I set the visibility property of the buttons that start or stop the services appropriately. if they don't have the credentials, they still get to see the services - just no stop/start buttons.

Here's an example of how you might check credentials on a given page:

private void Page_Load(object sender, System.EventArgs e)
  {
   try
     {
      // if they haven't logged in this will fail and we can send them to
      // the login page

      FormsIdentity id = (FormsIdentity)HttpContext.Current.User.Identity;
     }
     // whatever bad happened, let's just send them back to login page for now...
     catch(Exception ex )
     {
      Response.Redirect("Default.aspx"); // whatever your login page is
     }
   // is this an Administrator role?
   if (User.IsInRole("Administrator"))
     {
      Response.Write("Welcome Big Admin!");
      // ok let's enumerate their roles for them...
      FormsIdentity id = (FormsIdentity)HttpContext.Current.User.Identity;
      FormsAuthenticationTicket ticket = id.Ticket;
      string userData = ticket.UserData;
      string[] roles = userData.Split(',');
      foreach(string role in roles)
       {
         Response.Write("You are: " + role.ToString()+"<BR>");
       }
Response.Write ("You get to see the Admin link:<BR><A href=\"Admin/Adminstuff.aspx\">Admin Only</a>");
   }
     else
   {
     // ok, they got in but we know they aren't an Administrator...
     Response.Write("Ya got logged in, but you ain't an Administrator!");
     FormsIdentity id = (FormsIdentity)HttpContext.Current.User.Identity;
     FormsAuthenticationTicket ticket = id.Ticket;
     string userData = ticket.UserData;
     string[] roles = userData.Split(',');
       foreach(string role in roles)
        {
         Response.Write("You are: " +role.ToString()+"<BR>");
        }
}

 

There is plenty more cool stuff you can do with this that I haven't shown here. For example, although I haven't had the time to completely look into it, you might be able to have a Webservice with a Login webmethod that returns the FormsAuth ticket (encrypted of course with the encrption method) back to the user in response to a login SOAP method providing their username and password. Then on subsequent calls to other methods they would pass their ticket either as a SOAP header or as an element in the SOAP body. Your code would grab this ticket and basically do the same thing we are doing here above without having to hit the database again.

Another interesting idea would be to return an XML document (as a string from a database column) into the userData parameter. This would allow you to load the string into an XmlDocument instance and use XPath to handle complex hierarchical user memberships and relationships which could be more complicated than a simple array of roles.

You can download the complete solution which also includes a subfolder "Admin" along with the web.config for it that controls access to the page therein, here:

Download the code that accompanies this article


No comments:

Post a Comment