Tuesday, September 27, 2005

[itsdifferent] Role-based Security with Forms Authentication

Role-based Security with Forms Authentication


Forms Authentication in ASP.NET can be a powerful feature. With very little code and effort, you can have a simple authentication system that is platform-agnostic. If your needs are more complex, however, and require more efficient controls over assets, you need the flexibility of groups. Windows Authentication gives you this flexibility, but it is not compatible with anything but Internet Explorer since it uses NTLM, Microsoft's proprietary authentication system. Now you must choose how to manage your assets: provide multiple login pages / areas and force users to register for each, or assign groups to users and limit access to pages / areas to particular groups. Obviously, you must choose the latter.

Role-based security in Forms Authentication is one thing Microsoft left out in this round for .NET, but they didn't leave you high-and-dry. The mechanisms are there, they're just not intuitive to code. This tutorial will cover the basics of Forms Authentication, how to adapt it to make use of role-based security, and how to implement role-based security on your site with single sign-ons.


This tutorial is all about role-based security with Forms Authentication, a detail that Microsoft left out of .NET for this round. This tutorial will use different techniques that are almost completely incompatible with the standard Forms Authentication, save the setup, which we'll cover shortly.

To follow along in this tutorial, you'll need to create a database, a web application, several secured directories, and a few ASP.NET Web Forms (pages).

Creating the Database

We will create a simple database containing a flat table for this tutorial. Using the <credentials/> section of the Web.config file is not an option because no mechanism for roles is supported. For the purposes of brevity, the table we create will be very simple. You're welcome to expand the database to make use of relations (what I would do and actual do use on several sites) for roles. The implementation does start to get a little messy depending on how you do it, and the details are left up to you. This is merely a tutorial about developing role-based security.

So, choose what database management system you want to use (DBMS). For this tutorial, we'll choose the Microsoft Data Engine (MSDE) available with Visual Studio .NET, Office XP Developer, and several other products. We'll add one database, say web, and then add one table, say users. To the users table, we'll add three fields: username, password, and roles. Set the username field to the primary key (since it'll be used for look-ups and needs to be unique), and optionally create an index on the username and password fields together. If you're using Table-creation SQL Scripts, your script might look something like this:

CREATE  DATABASE web  CREATE TABLE users ( 	username nvarchar(64) CONSTRAINT users_PK PRIMARY KEY, 	password nvarchar(128), 	roles nvarchar(64) )  CREATE INDEX credentials ON users ( 	username, 	password )

Feel free to add some credentials to your database, picking a few roles you think are good group names for your site, such as "Administrator", "Manager", and "User". For this tutorial, put them in comma-delimited format in the "roles" field like the following, pipe-delimited (|) table:

username|password|roles "hstewart"|"codeproject"|"Administrator,User" "joe"|"schmoe"|"User"

Take note to make the roles case-sensitive. Now let's move on to creating our pages necessary for role-based Forms Authentication.

Creating the Login Pages

If you haven't already done so, create a new Web Application, or attach to an existing Web Application, such as your web server's document root, "/". For this tutorial, we'll assume the Web Application resides in "/", though the procedure for any Web Application is the same.

Before we create any pages or setup our Web.config file, you must understand one thing: the login.aspx (or whatever you call your login page) must be public. If it isn't, your users will not be able to log-in, and could be stuck in an infinite loop of redirects, though I've not tested this and don't care to. So, this tutorial will assume that login.aspx is in "/", while we have two secured sub-directories, users and administrators.

First, we must create a Forms Authentication login system that supports roles. Because Microsoft did not provide for this easily, we will have to take over the process of creating the authentication ticket ourselves! Don't worry, it's not as hard as it sounds. A few pieces of information are needed, and the cookie has to be stored under the right name - the name matching the configured name for Forms Authentication in your root Web.config file. If these names don't match, ASP.NET won't find the Authentication Ticket for the Web Application and will force a redirect to the login page. For simplicity, we will put the code directly into the ASP.NET Web Form, which is easier to code for DevHood and should look something like the following:

<%@ Page Language="C#" %> <%@ Import Namespace="System.Data" %> <%@ Import Namespace="System.Data.SqlClient" %> <html> <head> 	<title>Login</title> </head> <script runat="server"> // If you're using code-behind, make sure you change "private" to // "protected" since the .aspx page inherits from the .aspx.cs // file's class private void btnLogin_Click(Object sender, EventArgs e) { 	// Initialize FormsAuthentication, for what it's worth 	FormsAuthentication.Initialize();  	// Create our connection and command objects 	SqlConnection conn = 	 new SqlConnection("Data Source=localhost;Initial Catalog=web;"); 	SqlCommand cmd = conn.CreateCommand(); 	cmd.CommandText = "SELECT roles FROM web WHERE username=@username " + 	 "AND password=@password";  	// Fill our parameters 	cmd.Parameters.Add("@username", SqlDbType.NVarChar, 64).Value = Username.Value; 	cmd.Parameters.Add("@password", SqlDbType.NVarChar, 128).Value = 	 FormsAuthentication.HashPasswordForStoringInConfigFile( 		Password.Value, "md5"); // Or "sha1"  	// Execute the command 	conn.Open(); 	SqlDataReader reader = cmd.ExecuteReader(); 	if (reader.Read()) 	{ 	 // Create a new ticket used for authentication 	 FormsAuthenticationTicket ticket = new FormsAuthenticationTicket( 		1, // Ticket version 		Username.Value, // Username associated with ticket 		DateTime.Now, // Date/time issued 		DateTime.Now.AddMinutes(30), // Date/time to expire 		true, // "true" for a persistent user cookie 		reader.GetString(0), // User-data, in this case the roles 		FormsAuthentication.FormsCookiePath);// Path cookie valid for  	 // Encrypt the cookie using the machine key for secure transport 	 string hash = FormsAuthentication.Encrypt(ticket); 	 HttpCookie cookie = new HttpCookie( 		FormsAuthentication.FormsCookieName, // Name of auth cookie 		hash); // Hashed ticket  	 // Set the cookie's expiration time to the tickets expiration time 	 if (ticket.IsPersistent) cookie.Expires = ticket.Expiration;  	 // Add the cookie to the list for outgoing 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 = "/";  	 // Don't call FormsAuthentication.RedirectFromLoginPage since it 	 // could 	 // replace the authentication ticket (cookie) we just added 	 Response.Redirect(returnUrl); 	} 	else 	{ 	 // Never tell the user if just the username is password is incorrect. 	 // That just gives them a place to start, once they've found one or 	 // the other is correct! 	 ErrorLabel = "Username / password incorrect. Please try again."; 	 ErrorLabel.Visible = true; 	}  	reader.Close(); 	conn.Close(); } </script> <body> 	<p>Username: <input id="Username" runat="server" type="text"/><br /> 	Password: <input id="Password" runat="server" type="password"/><br /> 	<asp:Button id="btnLogin" runat="server" OnClick="btnLogin_Click" 	 Text="Login"/> 	<asp:Label id="ErrorLabel" runat="Server" ForeColor="Red" 	 Visible="false"/></p> </body> </html>

You'll notice above that we do one other thing with our passwords: we hash them. Hashing is a one-way algorithm that makes a unique array of characters. Even changing one letter from upper-case to lower-case in your password would generate a completely different hash. We'll store the passwords in the database as hashes, too, since this is safer. In a production environment, you'd also want to consider having a question and response challenge that a user could use to reset the password. Since a hash is one-way, you won't be able to retrieve the password. If a site is able to give your old password to you, I'd consider steering clear of them unless you were prompted for a client SSL certificate along the way for encrypting your passphrase and decrypting it for later use, though it should still be hashed.

Note: without using HTTP over SSL (HTTPS), your password will still be sent in plain-text across the network. Hashing the password on the server only keeps the stored password secured. For information about SSL and acquiring a site or domain certificate, see http://www.versign.com/ or http://www.thawte.com/.

If you don't want to store hashed passwords in the database, change the line that reads FormsAuthentication.HAshPasswordForStoringInConfigFile(Password.Value, "md5") to just Password.Value.

Next, we'll need to modify the Global.asax file. If your Web Application doesn't have one already, right-click on the Web Application, select "Add->Add New Item...->Global Application Class". In either the Global.asax or Global.asax.cs (or Global.asax.vb, if you're using VB.NET), find the event handler called Application_AuthenticateRequest. Make sure it imports / uses the System.Security.Principal namespace and modify it like so:

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) 	 { 		FormsIdentity id = 			(FormsIdentity)HttpContext.Current.User.Identity; 		FormsAuthenticationTicket ticket = id.Ticket;  		// Get the stored user-data, in this case, our roles 		string userData = ticket.UserData; 		string[] roles = userData.Split(','); 		HttpContext.Current.User = new GenericPrincipal(id, roles); 	 } 	}   } }

What's happening above is that since our principal (credentials - which are your username and roles) is not stored plainly as part of our cookie (nor should it, since a user could modify their list of role-memberships), it needs to be generated for each request. The FormsAuthenticationTicket is actually encrypted as part of a cookie using your machine key (usually configured in machine.config) and the FormsAuthentication module decrypts the tick as part of the user's identity. If you search long and hard enough on Microsoft MSDN web site, you'll find this documentation buried. We use the UserData to obtain the list of roles and generate a new principal. Once the principal is created, we add it to the current context for the user, which the receiving page can use to retrieve credentials and role-memberships.

Securing Directories with Role-based Forms Authentication

To make the role-based authentication work for Forms Authentication, make sure you have a Web.config file in your Web Application root. For the authentication setup, this particular Web.config file must be in your Web Application's document root. You can override the <authorization/> in Web.config files for sub-directories.

To begin, make sure your Web.config file has at least the following:

<configuration> 	<system.web> 		<authentication	mode="Forms"> 			<forms name="MYWEBAPP.ASPXAUTH" 				loginUrl="login.aspx" 				protection="All" 				path="/"/> 		</authentication> 		<authorization> 			<allow users="*"/> 		</authorization> 	</system.web> </configuration>

The FormsAuthentication name (MYWEBAPP.ASPXAUTH) above it arbitrary, although the name there and the name in the HttpCookie we created to hold the hashed FormsAuthenticationTicket must match, for even though we are overriding the ticket creation, ASP.NET still handles the authorization automatically from the Web.config file.

To control authorization (access by a particular user or group), we can either 1) add some more elements to the Web.config file from above, or 2) create a separate Web.config file in the directory to be secure. While, I prefer the second, I will show the first method:

<configuration> 	<system.web> 		<authentication mode="Forms"> 			<forms name="MYWEBAPP.ASPXAUTH" 				loginUrl="login.aspx" 				protection="All" 				path="/"/> 		</authentication> 		<authorization> 			<allow users="*"/> 		</authorization> 	</system.web> 	<location path="administrators"> 		<system.web> 			<authorization> 				<!-- Order and case are important below --> 				<allow roles="Administrator"/> 				<deny users="*"/> 			</authorization> 		</system.web> 	</location> 	<location path="users"> 		<system.web> 			<authorization> 				<!-- Order and case are important below --> 				<allow roles="User"/> 				<deny users="*"/> 			</authorization> 		</system.web> 	</location> </configuration>

The Web Application always creates relative paths from the paths entered here (even for login.aspx), using it's root directory as the starting point. To avoid confusion with that condition and to make directories more modular (being able to move them around without changing a bunch of files), I choose to put a separate Web.config file in each secure sub-directory, which is simply the <authorization/> section like so:

<configuration> 	<system.web> 		<authorization> 			<!-- Order and case are important below --> 			<allow roles="Administrator"/> 			<deny users="*"/> 		</authorization> 	</system.web> </configuration>

Notice, too, that the role(s) is/are case-sensitive. If you want to allow or deny access to more than one role, delimit them by commas.

That's it! Your site is setup for role-based security. If you use code-behind, compile your application first. Then try to access a secure directory, such as /administrators, and you'll get redirected to the login page. If login was successful, you're in, unless your role prohibits it, such as the /administrators area. This is hard for the login.aspx page to determine, so I'd recommend a Session variable to store the login attempts and after so many times, return an explicit "Denied" statement. There is another way, however, which is discussed below.

Conditionally Showing Controls with Role-based Forms Authentication

Sometimes it's better to show / hide content based on roles when you don't want to duplicate a bunch of pages for various roles (user groups). Such examples would be a portal site, where free- and membership-based accounts exist and membership-based accounts can access premium content. Another example would be a news page that would display an "Add" button for adding news links if the current user is in the "Administrator" role. This section describes how write for such scenarios.

The IPrincipal interface, which the GenericPrincipal class we used above implements, has a method called IsInRole(), which takes a string designating the role to check for. So, if we only want to display content if the currently logged-on user is in the "Administrator" role, our page would look something like this:

<html> <head>   <title>Welcome</title>   <script runat="server">   protected void Page_Load(Object sender, EventArgs e)   {    if (User.IsInRole("Administrator"))     AdminLink.Visible = true;   }   </script> </head> <body>   <h2>Welcome</h2>   <p>Welcome, anonymous user, to our web site.</p>   <asp:HyperLink id="AdminLink" runat="server"    Text="Administrators, click here." NavigateUrl="administrators/"/> </body> </html>

Now the link to the Administrators area of the web site will only show up if the current user is logged in and is in the "Administrator" role. If this is a public page, you should provide a link to the login page, optionally setting the QueryString variable called ReturnUrl to the path on the server you want the user to return to upon successful authentication.

Note: This Group is not a Job Searching Group, so please co-operate and dont transfer any kind of job related material across this Group.AnyOne doing so can be banned from the Group
Thanx , Group Co-Ordinators


No comments: