Chapter 4. Membership and User Profiling

The sample website developed in this book contains dynamic content such as news, events, newsletters, polls, forum posts, and more. It can be considered a content-based site, where significant parts of the site can be easily changed or updated by privileged users (this functionality is sometimes called a content management system), although it differs from many content-based sites because we've also added an important e-commerce section that enables our investors to earn a healthy return on their investment.

Here's a secret (although not a well-kept one) for any content-based site that you want to be successful: build a vigorous and thriving community of users! If you have a lot of loyal users, you can be sure that the site will increase its user base and, thus, its size, its popularity, and your revenues. You want to encourage users to register for a free account on the site, so you can enable them to customize their view, participate in message forums, and even order merchandise from e-commerce pages. Once they obtain a free account, they will be a member of the site. Membership is a form of empowerment — they will feel special because they are a member, and you want to reward their loyalty by enabling them to customize certain visual aspects, and to remember their settings on their return visits.

To track members, it is necessary to have some sort of identity to describe and distinguish them from other members and, more importantly, from anonymous users who have not logged in. This chapter will explain how to develop user registration functionality and user profiles. The user account will also be used to grant or deny access to special restricted pages of the site. The profile will be used by modules developed later in this book to customize content and give users a public "virtual face," visible to other members and users.

Problem

In reality, a membership system is a requirement for most websites — not just for community and content-based sites. Sites typically have a number of administration pages to which visitors should not have access. The administration section can be a complete application in itself or just a couple of simple pages to allow people to change some settings. However, you always need to identify each user who tries to access those restricted pages and check whether they are authorized to do so.

The process of identifying a user is called authentication, and the process of determining what access a user has is called authorization. Unfortunately, it's easy to confuse these terms, so it helps to think of the root words: authenticate (who are you?) and authorize (now that I know you, what are you allowed to do?). The authentication and authorization processes are part of the site's membership system, which includes the creation of new user accounts, the management of the user's credentials (including protection mechanisms such as encryption and password recovery in case passwords are lost or forgotten), and roles associated with an account.

For the sample site, the membership system must be complete because it will be used by administrators and editors to access protected areas, and by users who want to have their own identity within the community, post messages to the forums, and be recognized by other members. It must enable users to create their account interactively without administrator intervention and to update their profile information on demand.

Administrators must also be able to see a list of registered users and to control them. For example, if there is a user who regularly posts spam or offending messages to the forum, a good administrator (or forum moderator) will want to temporarily or permanently disable this user's account. Conversely, if a user always behaves well and respects the site's policies, an administrator may decide to promote him or her to the status of moderator, or even editor. In other words, modifying user account settings and their roles should be an easy thing to do, because the administrator may need to do it frequently. Thus, we require an easy-to-use administration section to manage user accounts.

To make it easier to manage security permissions, we'll create roles that are basically a group of users who have special permission in addition to the normal user permissions. For example, the Administrators role will be used to designate certain individuals who will have the capability to manage user accounts and site content.

Although a membership system is necessary for common security-related tasks, other things are needed in order to build an effective community of happy users. The users expect to have some benefits from their registration. For example, they could receive newsletters with useful information (with links back to the website), and they could customize the home page so that it highlights the type of content they are most interested in. Furthermore, their preferred site template could be saved and restored between sessions. All this information makes up what's called a user profile. Implementing a system for profiling the user is a good thing not just for the end user but also for the site administrators. Among the information stored in the profile is the user's age, gender, and full address. A savvy administrator could later make use of such data in a variety of ways:

  • To customize the user appearance for registered and profiled users: For example, the news and events modules developed in the next chapter will use the details stored in the user's profile to highlight with different colors the news and events that happen in the user's country, state, or city, to identify the items closest to home. This rather simple feature can improve the user experience, and gives users an incentive to provide such personal details in their profile.

  • To implement targeted marketing: For example, you could send a newsletter about a concert or some other event to all users that reside in a particular country, state, or city. You can do the same with banners or text notices on the site. Multiple criteria could be used for targeting the sponsored news, other than that from the user's location: it could be chosen according to age, gender, or a combination of multiple conditions. The more details you have about your users, the more chances you have to sell advertisement spaces on your site(s) to external companies, or to effectively use the ad possibilities yourself.

  • To offer a choice of identity authorization: Username and passwords have been the traditional means to be authenticated on websites for years and are still the standard means to identify yourself. There are a few problems with the traditional username and password combinations because they require users to keep track of numerous combinations across all the sites the users visit and such combinations have been coming under more and more scrutiny these days. To resolve these issues, new identity management mechanisms have been introduced: OpenID and CardSpace. Offering a choice of authentication mechanism makes the site more usable by more visitors.

  • To implement authentication via ASP.NET AJAX: AJAX was created to make a better user experience for visitors. ASP.NET AJAX leverages Forms authentication, the membership provider, and profile services via web services.

The site administrator will need an intuitive console from which she can see and edit the profile of any user — to remove an offending signature or avatar image (an avatar image is a small picture of a user, or a "cool" signature picture a user wants to display next to his or her name) used in the forums.

Design

To recap, the "virtual client" has commissioned a membership system that handles the following operations and features:

  • Users must be able to create new accounts independently, by filling out an online registration form.

  • Users must be able to later change their own credentials or recover them if they forget them.

  • The administrator must be able to grant or deny access to specific sections or individual pages by certain users. The permissions should be editable even after deploying the site, without requiring the intervention of a developer to change complex code or settings.

  • The administrator must be able to temporarily or permanently suspend a user account, for example, when a user does not respect the site's policy of conduct.

  • The administrator should be able to see summary and statistical data such as the number of total registered users and how many of them are online at a particular time. The administrator may also want to know when specific users registered, and the last time they logged in.

  • A profiling system should enable each registered user to save data such as site preferences and personal details in a data store (such as a database), so that the information will be remembered on future visits. The administrator must be able to view and edit the profile of each user.

ASP.NET 2.0 introduced some great features that help to develop the membership subsystem.

Password Storage Mechanisms

There are basically three methods for storing passwords, with each one offering different trade-offs between security and the convenience of developers, administrators, and users.

  1. The most convenient method of password storage for developers and administrators is to store the password as plain text in a database field. This is also convenient for users because you can easily e-mail a user's password to them in case they forget it. However, this is the least secure option because all of the passwords are stored as plain text — if your database were compromised by a hacker, he'd have easy access to everyone's password. You need to be extremely careful about locking down your database and ensuring that you secure your database backup files.

  2. To enhance the security of password storage, you can encrypt the passwords before storing them in a database. There are many ways to encrypt passwords, but the most common method is symmetric encryption, which uses a guarded system password to encrypt all user passwords. This is two-way encryption: you can encrypt a password and also decrypt it later. This offers medium convenience for developers but still offers a lot of convenience for users because you can still e-mail them a forgotten password.

  3. The highest level of security requires a form of encryption that prevents administrators and developers from gaining access to any user's password. This uses a one-way type of encryption known as hashing. You can always encrypt a password by hashing the password with a proven algorithm, but you can never decrypt it. Therefore, you store the hashed version of the password, and later, when you want to verify a user's password when he logs in again, you can perform the same hashing algorithm on whatever he types in as his password. You can then compare this hash against the hash you stored in the database — if the two match, then you know the user entered his password correctly. This offers a low amount of convenience to developers, administrators, and users because it's not possible to e-mail forgotten passwords. Instead, if a user forgets his password, your only choice is to change the user's password to a known value and then save the hash for his new password.

Hashing (method 3) was used in the first edition of this book, but it caused a lot of confusion for administrators and frustration for users because people generally prefer having the option of "recovering" a lost password without requiring a new one. We will use symmetric encryption (method 2) in this edition, but please keep in mind that password hashes should always be used to protect websites containing financial data or other very sensitive data (such as medical records, test scores, etc.). Most users would not like to see their super-secret banking password mailed to them in an e-mail message, and most don't even want bank employees to have access to passwords. A bank employee who is trusted today might become a disgruntled former employee tomorrow, and it's nice to know that he won't be taking your password with him!

Authentication Modes: Windows Security or Custom Login Form?

The first thing you have to decide when you set up a security mechanism for a website is whether you want to use Windows or Forms authentication. Windows authentication is the easiest to set up and use, while Forms authentication requires you to create a custom database and a login form. Windows security is usually the best choice when you are developing an intranet site for which all users who have access to the site are also users of a company's internal network (where they have domain user accounts). With Windows security, users enjoy the capability to use restricted web pages without having to formally log in to the website; the page is executed under the context of the user requesting it, and security restrictions are automatically enforced on all resources that the code tries to access and use (typically files and database objects). Another advantage is that Windows will securely store and encrypt user credentials so that you don't have to.

However, the requirement to have a local network account is a huge disadvantage that makes it a bad choice for Internet sites. If you use Windows security for users located outside of a company's network, the company would be required to create a network user account for each website user, which makes it slow for users to gain access and expensive for companies to administer. While you could conceivably write some code to automate the creation of Windows network accounts, and could write a login page that uses Windows impersonation behind the scenes, it just doesn't make sense to employ Windows security with those nasty workarounds in our context (a public website with possibly thousands of users). Instead, it makes more sense to use Forms authentication, and store user account credentials and related profile data in a custom database.

The Let's Do Everything on Our Own Approach

Designing a module for handling user membership and profiling is not easy. It may not seem particularly difficult at first: you can easily devise some database tables for storing the required data (roles, account credentials, and details; the associations between roles and accounts; and account profiles) and an API that allows the developer to request, create, and modify this data. However, things are rarely as easy as they appear at first!

You must not downplay the significance of these modules because they are crucial to the operation of the website, and properly designing these modules is important because all other site modules rely on them. If you design and implement the news module poorly, you can go back and fix it without affecting all the other site's modules (forum, e-commerce, newsletter, polls, etc.). However, if you decide to change the design of the membership module after you have developed other modules that use it, chances are good that you will need to modify something in those modules as well. The membership module must be complete but also simple to use, and developers should be able to use its classes and methods when they design administration pages. They should also be able to create and edit user accounts by writing just a few lines of code or, better yet, no code at all. ASP.NET 1.1 provided a partial security framework that allowed you to specify roles that could or could not access specific pages or folders by specifying role restrictions in web.config. It also took care of creating an encrypted authentication cookie for the user, once the user logged in. The developer, though, was completely responsible for all the work of writing the login and registration pages, authenticating the user against a database of credentials, assigning the proper roles, and administering accounts. In the first edition of this book, we did everything ourselves with custom code. The solution worked fine but still suffered from a couple of problems:

  • The developer had to perform all security checks programmatically, typically in the Page_Load event, before doing anything else. If you later wanted to add roles or users to the ACL (access control list) of a page or site area, you had to edit the code, recompile it, and redeploy the assembly.

  • The membership system also included user profiling. The database table had columns for the user's first and last name, address, birth date, and other related data. However, the table schema was fixed, so if you wanted to add more information to the profile later, you had to change the database, the related stored procedures, and many API methods, in addition to the user interface to insert the data.

Things could have been made more flexible, but it would have been more difficult to develop. You have to weigh the advantages of design extensibility against the time and effort required to implement it. Fortunately, ASP.NET has full-featured membership and profiling systems out of the box! Yes, that's right, you don't have to write a single line of code to register users, protect administrative pages, and associate a profile with the users, unless you want to customize the way they work (for example, to change the format in which the data is stored, or the storage medium itself).

This section introduces the built-in security and profiling framework of ASP.NET; after that, you will learn how to profitably use it in your own project instead of "rolling your own" solution.

The Membership and MembershipUser Classes

ASP.NET 2.0 included many features to help developers solve common problems quickly; one of these features is the Membership and Role providers. The principal class of the ASP.NET's security framework is System.Web.Security.Membership, which exposes a number of static methods to create, delete, update, and retrieve registered users. The following table describes the most important class methods.

Method

Description

CreateUser

Creates a new user account.

DeleteUser

Deletes the specified user.

FindUsersByEmail

Returns an array of users with the specified e-mail address. If SQL Server is used to store accounts, the input e-mail can contain any wildcard characters supported by SQL Server in LIKE clauses, such as % for any string of zero or more characters, or _ for a single character.

FindUsersByName

Returns an array of users with the specified name. Wildcard characters are supported.

GeneratePassword

Generates a new password with the specified length, and the specified number of non-alphanumeric characters.

GetAllUsers

Returns an array with all the registered users.

GetNumberOfUsersOnline

Returns an integer value indicating how many registered users are currently online.

GetUser

Retrieves a specific user by name.

GetUserNameByEmail

Returns the username of a user with the given e-mail address.

UpdateUser

Updates a user.

ValidateUser

Returns a Boolean value indicating whether the input credentials correspond to a registered user.

Some of these methods (CreateUser, GetAllUsers, GetUser, FindUsersByName, FindUsersByEmail, and UpdateUser) accept or return instances of the System.Web.Security.MembershipUser class, which represents a single user, and provides quite a lot of details about it. The following tables describe the instance properties and methods exposed by this class.

Property

Description

Comment

A comment (typically entered by the administrator) associated with a given user.

CreationDate

The date when the user registered.

Email

The user's e-mail address.

IsApproved

Indicates whether the account is enabled and whether the user can log in.

IsLockedOut

Indicates whether the user account was disabled after a number of invalid logins. This property is read-only, and the administrator can only indirectly set it back to false, by calling the UnlockUser method described below.

IsOnline

Indicates whether the user is currently online.

LastActivityDate

The date when the user logged in or was last authenticated. If the last login was persistent, this will not necessarily be the date of the login, but it may be the date when the user accessed the site and was automatically authenticated through the cookie.

LastLockoutDate

The date when the user was automatically locked out by the membership system, after a (configurable) number of invalid logins.

LastLoginDate

The date of the last login.

LastPasswordChangedDate

When the user last changed his or her password.

PasswordQuestion

The question asked of users who forget their password — used to prove it's really the user.

UserName

The user's username.

Method

Description

ChangePassword

Changes the user's password. The current password must be provided.

ChangePasswordQuestionAndAnswer

Changes the question and answer asked of a user who forgets his or her password. Requires the current password as input (so someone can't change this for somebody else).

GetPassword

Returns the current password. Depending on how the membership system is set up, it may require the answer to the user's password question as input and will not work if only a password hash is stored in the database.

ResetPassword

Creates a new password for the user. This is the only function to change the password if the membership system was set up to hash the password.

UnlockUser

Unlocks the user if she was previously locked out by the system because of too many invalid attempts to log in.

When you change a user property, the new value is not immediately persisted to the data store; you have to call the UpdateUser method of the Membership class for that. This is done so that with a single call you can save multiple updated properties and, thus, improve performance.

By using these two classes together, you can completely manage the accounts' data in a very intuitive and straightforward way. It's outside the scope of this book to provide a more exhaustive coverage of every method and overload, but I can show you a few examples about their usage in practice — please consult MSDN for all the details on these classes. Following is some code for registering a new account and handling the exception that may be raised if an account with the specified username or e-mail address already exists:

Dim msg As String = "User created successfully!"
Try
    Dim newUser As MembershipUser = Membership.CreateUser("Marco",
"secret", "[email protected]")
Catch exc As MembershipCreateUserException
    msg = "Unable to create the user. "
    Select Case exc.StatusCode
        Case MembershipCreateStatus.DuplicateEmail
            msg += "An account with the specified e-mail already exists."
            Exit Select
        Case MembershipCreateStatus.DuplicateUserName
            msg += "An account with the specified username already exists."
            Exit Select
        Case MembershipCreateStatus.InvalidEmail
            msg += "The specified e-mail is not valid."
            Exit Select
        Case MembershipCreateStatus.InvalidPassword
            msg += "The specified password is not valid."
            Exit Select
        Case Else
            msg += exc.Message
            Exit Select
    End Select
End Try
lblResult.Text = msg

If you want to change some of the user's information, you first retrieve a MembershipUser instance that represents that user, change some properties as desired, and then update the user, as shown here:

Dim user As MembershipUser = Membership.GetUser("Marco")
If DateTime.Now.Subtract(user.LastActivityDate).TotalHours < 2 Then
    user.Comment = "very knowledgeable user; strong forum participation!"
End If
Membership.UpdateUser(user)

Validating user credentials from a custom login form requires only a single line of code (and not even that, as you'll see shortly):

dim isValid as Boolean = Membership.ValidateUser("Marco", "secret")

In the "Solution" section of this chapter, you will use these classes to implement the following features in the site's Administration area:

  • Retrieve the total number of users and determine how many of them are currently online.

  • Find users by partial username or e-mail address.

  • Display some information about the users returned by the search, listed in a grid, such as the date of the user's last activity and whether they are active or not. In another page we will display all the details of a specific user and will allow the administrator to change some details.

The Provider Model Design Pattern

I use the term data store to refer to any physical means of persisting (saving) data — this usually means saving data in a database or in Active Directory, but .NET abstracts the actual data storage mechanism from the classes that manipulate the data. The provider class is the one that stores the data on behalf of other classes that manipulate data. This provider model design pattern, introduced in Chapter 3, is pervasive in .NET since the 2.0 release — you can frequently "plug in" a different backend provider to change the mechanism used to save and retrieve data. The Membership class uses a secondary class (called a membership provider) that actually knows the details of a particular data store and implements all the supporting logic to read and write data to/from it. You can almost think of the Membership class as a business layer class (in that it only manipulates data), and the provider class as the data access class, which provides the details of persistence (even though a pure architect might argue the semantics). Two built-in providers are available for the Membership system, and you can choose one by writing some settings in the web.config file. The built-in providers are the ones for SQL Server (SqlMembershipProvider) and for Active Directory (ActiveDirectoryMembershipProvider), but you can also write your own or find one from a third party (for use with Oracle, MySQL, DB2, and so on, or perhaps XML files). Figure 4-1 illustrates the provider model design pattern.

I find that the use of the provider model provides tremendous flexibility, because you can change the provider used by the Membership API under the hood without affecting the rest of the code, because you just access the Membership "business" class from the pages and the other business classes, and not the providers directly. Actually, you may even ignore which provider is used, and where and how the data is stored (this is the idea behind abstraction of the data store). Abstraction is obviously provided to users in the sense that they don't need to know exactly how their data will be stored, but now we also have abstraction for developers because they, too, don't always need to know how the data is stored!

Figure 4-1

Figure 4.1. Figure 4-1

To create a new provider, you can either start from scratch by building a completely new provider that inherits directly from System.Web.Security.MembershipProvider (which in turn inherits from System.Configuration.Provider.ProviderBase) or you can just customize the way some methods of an existing provider work. For example, let's assume that you want to modify the SqlMembershipProvider so that it validates a user's password to make sure that it's not equal to his username. You simply need to define your own class, which inherits from SqlMembershipProvider, and you can just override the CreateUser method like this:

Class SqlMembershipProviderEx
    Inherits SqlMembershipProvider
    Public Overloads Overrides Function CreateUser(ByVal username As String,
ByVal password As String, ByVal email As String,
ByVal passwordQuestion As String, ByVal passwordAnswer As String,
ByVal isApproved As Boolean, _
    ByVal providerUserKey As Object, ByRef status As MembershipCreateStatus)
As MembershipUser
        If username.ToLower() = password.ToLower() Then
            status = MembershipCreateStatus.InvalidPassword
            Return Nothing
        Else
            Return MyBase.CreateUser(username, password, email,
passwordQuestion, passwordAnswer, isApproved, _
            providerUserKey, status)
        End If
    End Function
End Class

The provider model design pattern is also very useful in the migration of legacy systems that already use their own custom tables and stored procedures. Your legacy database may already contain thousands of records of user information, and you want to avoid losing them, but now you want to modify your site to take advantage of the new Membership class. Instead of creating a custom application to migrate data to a new data store (or using SQL Server DTS or SQL Server Integration Services to copy the data from your tables to the new tables used by the standard SqlMembershipProvider), you can just create your own custom provider that directly utilizes your existing tables and stored procedures. If you're already using a business class to access your account's data from the ASP.NET pages, then creating a compliant provider class may be just a matter of changing the name and signature of some methods. Alternatively, you can follow this approach: keep your current business class intact but make it private, and then move it inside a new provider class that delegates the implementation of all its methods and properties to that newly private legacy business class. The advantage of doing this instead of just using your current business class "as is" is that you can change to a different data store later by just plugging it into the membership infrastructure — you wouldn't have to change anything in the ASP.NET pages that call the built-in Membership class.

Once you have the provider you want (either one of the default providers, a custom one you developed on your own, or a third-party offering), you have to tell ASP.NET which one you want to use when you call the Membership class' methods.

The web.config file is used to specify and configure the provider for the Membership system. Many of the default configuration settings are hard-coded in the ASP.NET runtime instead of being saved in the Machine.Config file. This is done to improve performance by reading and parsing a smaller XML file when the application starts, but you can still modify these settings for each application by assigning your own values in web.config to override the defaults. You can read the default settings by looking at the Machine.config.default file found in the following folder (the "xxxxx" part should be replaced with the build number of your installation):

C:<Windows Folder>Microsoft.NETFrameworkv2.0.xxxxxCONFIG

What follows is the definition of the <membership> section of the file, where the SqlMembershipProvider is specified and configured:

<system.web>
   <membership>
      <providers>
         <add name="AspNetSqlMembershipProvider"
            type="System.Web.Security.SqlMembershipProvider, System.Web,
               Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
            connectionStringName="LocalSqlServer"
            enablePasswordRetrieval="false"
            enablePasswordReset="true"
            requiresQuestionAndAnswer="true"
            applicationName="/"
            requiresUniqueEmail="false"
            passwordFormat="Hashed"
            maxInvalidPasswordAttempts="5"
            passwordAttemptWindow="10"
            passwordStrengthRegularExpression=""
         />
      </providers>
   </membership>

   <!-- other settings... -->
</system.web>

You can register more providers inside the <providers> section and choose which one you want to use by specifying its name in the defaultProvider attribute of the <membership> element (not shown above). Another attribute of <membership> is userIsOnlineTimeWindow, which specifies how many minutes after the last activity a user is still considered online. That is, if a user logs in, brings up one page, but then closes her browser immediately, she will be counted as being online for this number of minutes. We need this kind of parameter because we have no definite way to know when a user has left the site or closed down his or her browser. You can test this by checking the value returned by Membership.GetNumberOfUsersOnline as users come to your site and then leave.

For this site, we will use SQL Server 2008 Developer Edition, a simple version of SQL Server Enterprise Edition designed for developers to install on their local development workstation. It is not a full enterprise-powered version, but it is great for a developer to test against.

More Details about SqlMembershipProvider

In the last code snippet, you saw the default settings used to register the SqlMembershipProvider. The following table lists the attributes you can specify when you register the provider, in the <provider> element.

Attribute

Description

applicationName

The name of the web application; used if you want to store data on user account's for multiple websites in a single database.

connectionStringName

The name of the connection string, registered in the <connectionStrings> section of web.config, that points to the SQL Server database used to store the data.

Important: This is not the actual connection string! This is only a name that refers to web.config, where the actual connection string is stored.

description

A description for the provider.

enablePasswordReset

Indicates whether you want to enable the methods and controls for resetting a password to a new, auto-generated one.

enablePasswordRetrieval

Indicates whether you want to enable the methods and controls that allow a user to retrieve her forgotten password.

maxInvalidPasswordAttempts

The maximum number of invalid login attempts. If the user fails to log in after this number of times within the number of minutes specified by the passwordAttemptWindow attribute, the user account is "locked out" until the administrator explicitly calls the UnlockUser method of a MembershipUser instance representing the specific user.

minRequiredNonalphanumericCharacters

The minimum number of non-alphanumeric characters a password must have to be valid.

minRequiredPasswordLength

The minimum number of characters for a valid password.

name

The name used to register the provider. This is used to choose the provider by setting the defaultProvider attribute of the <membership> element.

passwordAttemptWindow

The number of minutes used to time invalid login attempts. See the description for maxInvalidPasswordAttempts.

passwordFormat

Specifies how the password is stored in the data store. Possible values are Clear, Encrypted, and Hashed.

passwordStrengthRegularExpression

The regular expression that a password must match to be considered valid.

requiresQuestionAndAnswer

Indicates whether the user must respond to a personal secret question before retrieving or resetting her password. Questions and answers are chosen by users at registration time.

requiresUniqueEmail

Indicates whether the same e-mail address can be used to create multiple user accounts.

By default, minRequiredPasswordLength is set to 7 and minRequiredNonalphanumericCharacters is set to 1, meaning that you must register with a password that is at least seven characters long and contains at least one non-alphanumeric character. Whether you leave these at their default settings or change them to suit your needs, remember to list these values on your registration page to let users know your password requirements.

These attributes let you fine-tune the membership system. For example, the capability to specify a regular expression that the password must match gives you great flexibility to meet stringent requirements. But one of the most important properties is certainly passwordFormat, used to specify whether you want passwords to be encrypted, or whether you just want a hash of them saved. Passwords are hashed or encrypted using the key information supplied in the <machineKey> element of the configuration file (you should remember to synchronize this machine key between servers if you will deploy to a server farm). The default algorithm used to calculate the password's hash is SHA1, but you can change it through the validation attribute of the machineKey element. Storing passwords in clear text offers the best performance when saving and retrieving the passwords, but it's the least secure solution. Encrypting a password adds some processing overhead, but it can greatly improve security. Hashing passwords provides the best security because the hashing algorithm is one-way, which means that the passwords cannot be retrieved in any way, even by an administrator. If a user forgets her password, she can only reset it to a new auto-generated one (typically sent by e-mail to the user). The best option always depends on the needs of each particular website: if I were saving passwords for an e-commerce site on which I might also save user credit card information, I would surely hash the password and use a Secure Sockets Layer (SSL) connection in order to have the strongest security. For our content-based website, however, I find that encrypting passwords is a good compromise. It's true that we're also building a small e-commerce store, but we're not going to store very critical information (credit cards numbers or other sensitive data) on our site.

Note

Never store passwords in clear text. The small processing overhead necessary to encrypt and decrypt passwords is definitely worth the increased security and, thus, the confidence that the users and investors have in the site.

Exploring the Default SQL Server Data Store

Even though the ASP.NET membership system is prebuilt and ready to go, this is not a good reason to ignore its design and data structures. You should be familiar with this system to help you diagnose any problems that might arise during development or deployment. Figure 4-2 shows the tables used by the SqlMembershipProvider class to store credentials and other user data. Of course, the data store's design of other providers may be completely different (especially if they are not based on relational databases).

Figure 4-2

Figure 4.2. Figure 4-2

The interesting thing you can see from Figure 4-2 is the presence of the aspnet_Applications table, which contains a reference to multiple applications (websites). Both the aspnet_Users table and the aspnet_Membership table contain a reference to a record in aspnet_Applications through the ApplicationId foreign key. This design enables you to use the same database to store user accounts for multiple sites, which can be very helpful if you have several sites using the same database server (commonly done with corporate websites or with commercial low-cost shared hosting). In a situation where you have a critical application that requires the maximum security, you'll want to store the membership data in a dedicated database that only the site administrator can access. If you are using a SQL Server 2008 Express Edition database, it this requires you to use your own private database, deployed as a simple file under the App_Data special folder. In addition to these tables, there are also a couple of views related to membership (vw_aspnet_MembershipUsers and vw_aspnet_Users) and a number of stored procedures (aspnet_membership_xxx) for the CRUD (Create, Read, Update, and Delete) operations used for authorization. You can explore all these objects by using the Visual Studio's Server Explorer window or SQL Server Management Studio, as shown in Figure 4-3.

Figure 4-3

Figure 4.3. Figure 4-3

If you configure the provider so that it uses the default SQL Server Express database named ASPNETDB located under the App_Data folder, the ASP.NET runtime will automatically create all these database objects when the application is run for the first time! Because we are using this database for our site, we don't need to do anything else to set up the data store. However, if you're using a full edition of SQL Server 2008, you'll need to set up the tables manually by running the aspnet_sql.exe tool from the Visual Studio 2008 command prompt. You can find the aspnet_sql.exe in the C:<Windows Folder>Microsoft.NETFrameworkv2.0.xxxxx folder if you want to just open it by hand. This little program lets you to choose an existing database on a specified server, and it creates all the required objects to support membership, along with caching, profiles, personalization, and more. Figure 4-4 shows a couple of screens generated by this tool.

Figure 4-4

Figure 4.4. Figure 4-4

The Graphical Login Controls

As you saw earlier, creating, validating, and managing users programmatically requires only a few lines of code. But what about writing no code at all? That's actually possible now, thanks to the new Login family of controls introduced with ASP.NET 2.0. These controls provide a premade user interface for the most common operations dealing with membership and security, such as creating a new account, logging in and out, retrieving or resetting a forgotten password, and showing different output according to the authenticated status of the current user. Figure 4-5 shows the Visual Studio IDE with the Login controls section of the Toolbox. It also shows a CreateUserWizard control dropped on a form, and its Smart Tasks pop-up window.

Figure 4-5

Figure 4.5. Figure 4-5

The CreateUserWizard Control

A wizard is an ASP.NET feature used to create a visual interface for a process that involves multiple steps. Each step has a separate visual panel or frame containing its own group of controls. After the user fills in values for controls of each step, he can press a link to advance to the next step in the wizard.

The CreateUserWizard control creates a user interface for a user to register, by providing the username, password, and e-mail address. The secret question and answer are also requested, but only if the current membership provider has the requiresQuestionAndAnswer attribute set to true; otherwise, these two last textboxes are hidden. When the Submit button is clicked, the control calls Membership.CreateUser under the hood on your behalf. By default, the code produced by the designer (and visible in the Source View) is:

<asp:CreateUserWizard ID="CreateUserWizard1" runat="server">
   <WizardSteps>
      <asp:CreateUserWizardStep runat="server">
      </asp:CreateUserWizardStep>
      <asp:CompleteWizardStep runat="server">
      </asp:CompleteWizardStep>
   </WizardSteps>
</asp:CreateUserWizard>

It contains no appearance attributes, and the control will look plain and simple, with the default font, background, and foreground colors. However, you can specify values for all the attributes used to control the appearance. An easy way to do that is by clicking Auto Format from the Smart Tasks window and selecting one of the premade styles. Figure 4-6 shows the control in the Elegant style.

Figure 4-6

Figure 4.6. Figure 4-6

The corresponding source code was automatically updated as follows:

<asp:CreateUserWizard ID="CreateUserWizard1" runat="server"
   BackColor="#F7F7DE" BorderColor="#CCCC99" BorderStyle="Solid"
   BorderWidth="1px" Font-Names="Verdana" Font-Size="10pt">
   <WizardSteps>
      <asp:CreateUserWizardStep runat="server">
      </asp:CreateUserWizardStep>
      <asp:CompleteWizardStep runat="server">
      </asp:CompleteWizardStep>
   </WizardSteps>
   <SideBarStyle BackColor="#7C6F57" BorderWidth="0px"
      Font-Size="0.9em" VerticalAlign="Top" />
   <SideBarButtonStyle BorderWidth="0px" Font-Names="Verdana"
      ForeColor="#FFFFFF" />
   <NavigationButtonStyle BackColor="#FFFBFF" BorderColor="#CCCCCC"
      BorderStyle="Solid" BorderWidth="1px" Font-Names="Verdana"
      ForeColor="#284775" />
   <HeaderStyle BackColor="#F7F7DE" BorderStyle="Solid" Font-Bold="True"
      Font-Size="0.9em" ForeColor="#FFFFFF" HorizontalAlign="Left" />
   <CreateUserButtonStyle BackColor="#FFFBFF" BorderColor="#CCCCCC"
      BorderStyle="Solid" BorderWidth="1px" Font-Names="Verdana"
      ForeColor="#284775" />
   <ContinueButtonStyle BackColor="#FFFBFF" BorderColor="#CCCCCC"
      BorderStyle="Solid" BorderWidth="1px" Font-Names="Verdana"
      ForeColor="#284775" />
   <StepStyle BorderWidth="0px" />
   <TitleTextStyle BackColor="#6B696B" Font-Bold="True" ForeColor="#FFFFFF" />
</asp:CreateUserWizard>

Back in Chapter 2, we discussed the disadvantages of having the appearance properties set in the .aspx source files, in comparison to having them defined in a separate skin file as part of an ASP.NET theme. Therefore, I strongly suggest that you not leave the auto-generated appearance attributes in the page's source code, but instead cut and paste them into a skin file. You can paste everything except for the ID property and the <WizardSteps> section, as they are not part of the control's appearance.

The <WizardSteps> section lists all the steps of the wizard. By default it includes the step with the registration form, and a second one with the confirmation message. You can add other steps between these two, and in our implementation we'll add a step immediately after the registration form, where the user can associate a profile to the new account. The wizard will automatically provide the buttons for moving to the next or previous step, or to finish the wizard, and raise a number of events to notify your program of what is happening, such as ActiveStepChanged, CancelButtonClick, ContinueButtonClick, FinishButtonClick, NextButtonClick, and PreviousButtonClick.

If the available style properties are not enough for you, and you want to change the structure of the control, that is, how the controls are laid down on the form, you can do that by defining your own template for the CreateUserWizardStep (or for the CompleteWizardStep). As long as you create the textboxes with the IDs the control expects to find, the control will continue to work without requiring you to write code to perform the registration manually. The best and easiest way to find which ID to use for each control is to have Visual Studio .NET convert the step's default view to a template (click the Customize Create User Step link in the control's Smart Tasks window), and then modify the generated code as needed. The code that follows is the result of Visual Studio's conversion and the deletion of the HTML layout tables:

<WizardSteps>
   <asp:CreateUserWizardStep runat="server">
      <ContentTemplate>
      <b>Sign Up for Your New Account</b><p></p>
      User Name: <asp:TextBox ID="UserName" runat="server" />
      <asp:RequiredFieldValidator ID="UserNameRequired" runat="server"
         ControlToValidate="UserName" ErrorMessage="User Name is required."
         ValidationGroup="CreateUserWizard1">*</asp:RequiredFieldValidator>
      <br />
      Password: <asp:TextBox ID="Password" runat="server" TextMode="Password" />
      <asp:RequiredFieldValidator ID="PasswordRequired" runat="server"
         ControlToValidate="Password" ErrorMessage="Password is required."
         ValidationGroup="CreateUserWizard1">*</asp:RequiredFieldValidator>
      <br />
      Confirm Password: <asp:TextBox ID="ConfirmPassword" runat="server"
         TextMode="Password" />
      <asp:RequiredFieldValidator ID="ConfirmPasswordRequired" runat="server"
         ControlToValidate="ConfirmPassword"
         ErrorMessage="Confirm Password is required."
         ValidationGroup="CreateUserWizard1">*</asp:RequiredFieldValidator>
      <asp:CompareValidator ID="PasswordCompare" runat="server"
         ControlToCompare="Password" ControlToValidate="ConfirmPassword"
         ErrorMessage="The Password and Confirmation Password must match."
         ValidationGroup="CreateUserWizard1"></asp:CompareValidator>
      <br />
      E-mail: <asp:TextBox ID="Email" runat="server" />
      <asp:RequiredFieldValidator ID="EmailRequired" runat="server"
         ControlToValidate="Email" ErrorMessage="E-mail is required."
         ValidationGroup="CreateUserWizard1">*</asp:RequiredFieldValidator>
      <br />
      Security Question: <asp:TextBox ID="Question" runat="server" />
      <asp:RequiredFieldValidator ID="QuestionRequired" runat="server"
         ControlToValidate="Question" ErrorMessage="Security question is required."
         ValidationGroup="CreateUserWizard1">*</asp:RequiredFieldValidator>
      <br />
Security Answer: <asp:TextBox ID="Answer" runat="server" />
      <asp:RequiredFieldValidator ID="AnswerRequired" runat="server"
         ControlToValidate="Answer" ErrorMessage="Security answer is required."
         ValidationGroup="CreateUserWizard1">*</asp:RequiredFieldValidator>
      <br />
      <asp:Literal ID="ErrorMessage" runat="server" EnableViewState="False" />
      </ContentTemplate>
   </asp:CreateUserWizardStep>
   <asp:CompleteWizardStep runat="server">
   </asp:CompleteWizardStep>
</WizardSteps>

Note

All of the various validation controls are declared with a ValidationGroup property set to the control's name, that is, CreateUserWizard1. The CreateUserWizard creates a Submit button with the same property, set to the same value. When that button is clicked, only those validators that have the ValidationGroup property set to the same value will be considered. This is a powerful feature of ASP.NET that you can use anywhere to create different logical forms for which the validation is run separately, according to which button is clicked.

For the custom-made Create User step of the wizard, the Question and Password fields are not automatically hidden if the current membership provider has the requiresQuestionAndAnswer attribute set to false, as would happen otherwise.

You can also set up the control so that it automatically sends a confirmation e-mail to users when they complete the registration process successfully. The setup is defined by the CreateUserWizard's <MailDefinition> subsection, and consists of the sender's e-mail address, the mail subject, and a reference to the text file that contains the e-mail's body. The following code shows an example:

<asp:CreateUserWizard runat="server" ID="CreateUserWizard1">
   <WizardSteps>
      ...
   </WizardSteps>
   <MailDefinition
      BodyFileName="~/RegistrationMail.txt"
      From="[email protected]"
      Subject="Mail subject here">
   </MailDefinition>
</asp:CreateUserWizard>

The RegistrationMail.txt file can contain the <% UserName %> and <% Password %> special placeholders, which at runtime will be replaced with the values taken from the new registration's data. To send the mail, you must have configured the SMTP server settings in the web.config file, through the <mailSettings> element and its subelements, as shown in the following code:

<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0">
   <system.web> <!-- some settings here...--> </system.web>
   <system.net>
      <mailSettings>
         <smtp deliveryMethod=" Network " from="[email protected]">
<network defaultCredentials="true" host="(localhost)" port="25" />
         </smtp>
      </mailSettings>
   </system.net> </configuration>

The Login Control

The Login control does exactly what its name suggests: it allows the user to log in. It provides the user interface for typing the username and password, and choosing whether the login will be persistent (saved across different sessions) or not. For the default simple appearance, you just need to declare the control as follows:

<asp:Login ID="Login1" runat="server" />

However, if you apply the Elegant prebuilt style to it, it will look as shown in Figure 4-7.

Figure 4-7

Figure 4.7. Figure 4-7

Under the covers, this control calls the Membership.ValidateUser method to check whether the provided credentials are found in the data store, and if so, it calls FormsAuthentication.RedirectFormLoginPage to create the encrypted authentication ticket, saves it into a client cookie, and redirects the user to the page that he or she originally tried to access before being redirected to the login page. The control exposes a lot of properties: many are for changing its appearance (colors, fonts, etc.), and others enable you to specify whether you want to show a link to the registration page (CreateUserText and CreateUserUrl properties), a link to the page to recover a forgotten password (PasswordRecoveryText and PasswordRecoveryUrl properties), and whether the control should be hidden when the user is already logged in (the VisibleWhenLoggedIn property). Of course, you can completely customize the way the CreateUserWizard control looks, by defining a template. Here's an example:

<asp:Login ID="Login1" runat="server">
   <LayoutTemplate>
      Username: <asp:TextBox ID="UserName" runat="server" />
      <asp:RequiredFieldValidator ID="UserNameRequired" runat="server"
         ControlToValidate="UserName" ErrorMessage="User Name is required."
         ValidationGroup="Login1">*</asp:RequiredFieldValidator>
      Password: <asp:TextBox ID="Password" runat="server" TextMode="Password" />
      <asp:RequiredFieldValidator ID="PasswordRequired" runat="server"
         ControlToValidate="Password" ErrorMessage="Password is required."
         ValidationGroup="Login1">*</asp:RequiredFieldValidator>
      <asp:CheckBox ID="RememberMe" runat="server" Text="Remember me next time." />
      <asp:Literal ID="FailureText" runat="server" EnableViewState="False" />
      <asp:Button ID="LoginButton" runat="server" CommandName="Login"
      Text="Log In" ValidationGroup="Login1" />
   </LayoutTemplate>
</asp:Login>

Remember that the only important thing is that you give textboxes, buttons, labels, and other controls the specific IDs that the parent control expects to find. If you start defining the template from the default template created by VS2008, this will be very easy.

The ChangePassword Control

The ChangePassword control allows users to change their current password through the user interface shown in Figure 4-8.

Figure 4-8

Figure 4.8. Figure 4-8

This control is completely customizable in appearance, by means of either properties or a new template. As with the CreateUserWizard control, its declaration can contain a <MailDefinition> section, where you can configure the control to send a confirmation e-mail to the user with her new credentials.

The PasswordRecovery Control

The Password Recovery control enables users to recover or reset their password, in case they forgot it. The first step, shown in Figure 4-9, is to provide the username.

Figure 4-9

Figure 4.9. Figure 4-9

For the next step, the user will be asked the question he or she chose at registration time. If the answer is correct, the control sends the user an e-mail message. As expected, there must be the usual MailDefinition, along with the current password, or a newly generated one if the membership provider's enablePasswordRetrieval attribute is set to false or if the provider's passwordFormat is hashed.

The LoginStatus, LoginName, and LoginView Controls

These last three controls are the simplest ones, and are often used together. The LoginName control shows the name of the current user. It has a FormatString property that can be used to show the username as part of a longer string, such as "Welcome {0}!", where the username will replace the {0} placeholder. If the current user is not authenticated, the control shows nothing, regardless of the FormatString value.

The LoginStatus control shows a link to log out or log in, according to whether the current user is or is not authenticated. The text of the liks can be changed by means of the LoginText and LogoutText properties, or you can use graphical images instead of plain text, by means of the LoginImageUrl and LogoutImageUrl properties. When the Login link is clicked, it redirects the user to the login page specified in the web.config file's <forms> element, or to the Login.aspx page if the setting is not present. When the Logout link is clicked, the control calls FormsAuthentication.SignOut to remove the client's authentication ticket, and then can either refresh the current page or redirect to a different one according to the values of the LogoutAction and LogoutPageUrl properties.

The LoginView allows you to show different output according to whether the current user is authenticated. Its declaration contains two subsections, <AnonymousTemplate> and <LoggedInTemplate>, where you place the HTML or ASP.NET controls that you want to display when the user is anonymous (not logged in) or logged in, respectively. The code that follows shows how to display the login control if the user is not authenticated yet, or a welcome message and a link to log out otherwise:

<asp:LoginView ID="LoginView1" runat="server">
   <AnonymousTemplate>
      <asp:Login runat="server" ID="Login1" />
   </AnonymousTemplate>
   <LoggedInTemplate>
      <asp:LoginName ID="LoginName1" runat="server" FormatString="Welcome {0}" />
      <br />
      <asp:LoginStatus ID="LoginStatus1" runat="server" />
   </LoggedInTemplate>
</asp:LoginView>

Integrating ASP.NET AJAX Authentication

Since ASP.NET 2.0 was released Microsoft has released an AJAX framework to build rich user interfaces. It was originally an add-on to the .NET Framework, but became part of the framework with the 3.5 release. The ASP.NET AJAX framework is a natural extension of programming with .NET, except that it is a JavaScript framework that executes in the browser. The main benefit of the ASP.NET AJAX framework is the capability to program in JavaScript the way you can against the .NET Framework. This should drastically reduce the learning curve needed for ASP.NET programmers to leverage this AJAX framework. While there are great AJAX frameworks available, such as JQuery, ASP.NET AJAX is specifically designed to integrate tightly with the server-side aspects of ASP.NET, such as the Membership and Profile providers.

The ASP.NET AJAX framework is composed of a series of pseudo-namespaces and -classes. I use the prefix "pseudo" because there are no true object-oriented concepts in JavaScript. But the ASP.NET AJAX framework provides a series of common classes that can be used to build the rich user experiences demanded by users today. For more information on the AJAX namespaces and classes visit http://msdn.microsoft.com/en-us/library/bb397536.aspx.

The Sys.Services namespace contains a set of classes that interact with the Membership, Role, and Profile providers on the server through web services. The Authentication class is the proxy class used to interact with the Membership provider. This class has two members, Login and Logout. The Login method performs forms authentication and sets the authentication cookie upon successful authentication. The following table describes the arguments to Login:

Argument

Description

Username

The account's username.

Password

The account's password.

isPersistent

A Boolean flag indicating to forms authentication whether to save the users' authentication token so they are not required to authenticate themselves next time.

redirectURL

URL to take the user to after a successful authentication.

customInfo

Reserved.

loginCompletedCallback

The function name the script calls when the login is completed.

failedCallback

Name of the function the script calls when an authentication fails.

userContext

Custom data that is passed to the callback function.

Similarly, the Logout function clears the form's authentication cookie, and the user must log back in to the site to access secured content areas. The Logout function takes a series of arguments, too:

Argument

Description

redirectUrl

The URL the user is redirected to after logging out.

logoutCompletedCallback

The callback function the script calls when the logout is complete.

failedCallback

The callback function the script calls if the logout fails.

userContext

Custom data that is passed to the callback function.

Before forms authentication can be executed using ASP.NET AJAX, it must be enabled to do so in the web.config file. If you are creating a new website with Visual Studio 2008, there will be a line in the web.config file that needs to be uncommented to enable grant access from the application's services. The section is actually part of the system.web.extensions section, which holds the key to implementing the ASP.NET AJAX interfaces to profiles and roles:

<system.web.extensions>
<scripting>

<webServices>

    <authenticationService enabled="true" requireSSL="false" />

    <profileService enabled="true" readAccessProperties="FirstName, LastName" />
<roleService enabled="true" />

</webServices>

<scriptResourceHandler enableCompression="true" enableCaching="true" />
</scripting>
</system.web.xtensions>

Once these sections have been officially added to the site, client-side script can be written to access these providers.

Setting Up and Using Roles

An authentication/authorization system is not complete without support for roles. Roles are used to group users for the purpose of assigning a set of permissions, or authorizations. You could decide to control authorizations separately for each user, but that would be an administrative nightmare! Instead, it's helpful to assign a user to a predetermined role and give him the permissions that accompany the role. For example, you can define an Administrator's role to control access to the restricted pages used to add, edit, and delete the site's content, and only users who belong to the Administrators role will be able to post new articles and news. It is also possible to assign more than one role to a given user. In ASP.NET 1.x, there wasn't any built-in support for roles in Forms authentication — roles were only supported with Windows security. You could have added role support manually (as shown in the first edition of this book), but that required you to create your own database tables and write code to retrieve the roles when the user logged in. You could have written the code so that the roles were retrieved at runtime — and then encrypted together by the authentication ticket on a client's cookie — so that they were not retrieved separately from the database with each request. Besides taking a considerable amount of development time that you could have spent adding value to your site, it was also a crucial task: any design or implementation bugs could impact performance, or even worse, introduce serious security holes. The good news is that as of 2.0 ASP.NET has built-in support for roles, and it does it the right way with regard to performance, security, and flexibility. In fact, as is true in many other pieces of ASP.NET (membership, sessions, profiles, personalization), it is built on the provider model design pattern: a provider for SQL Server is provided, but if you don't like some aspect of how it works, or you want to use a different data store, you can write your own custom provider or acquire one from a third party.

The role management is disabled by default to improve performance for sites that don't need roles — role support requires the execution of database queries, and consequent network traffic between the database server and the web server. You can enable it by means of the <roleManager> element in the web.config file, as shown here:

<roleManager enabled="true" cacheRolesInCookie="true" cookieName="TBHROLES" />

This element allows you to enable roles and configure some options. For example, the preceding code enables role caching in the client's cookie (instead of retrieving them from the database on each web request), which is a suggested best practice. Unless specified otherwise, the default provider will be used, with a connection string to the default local SQL Server Express database (the ASPNETDB file under the App_Data folder). If you want to use a different database, just register a new provider within the <roleManager> element, and choose it by setting the roleManager's defaultProvider attribute.

System.Web.Security.Roles is the class that allows you to access and manage role information programmatically. It exposes several static methods, the most important of which are listed in the following table.

Method

Description

AddUserToRole, AddUserToRoles, AddUsersToRole, AddUsersToRoles

Adds one or more users to one or more roles.

CreateRole

Creates a new role with the specified name.

DeleteRole

Deletes an existing role.

FindUsersInRole

Finds all users who belong to the specified role and who have a username that matches the input string. If the default provider for SQL server is used, the username can contain any wildcard characters supported by SQL Server in LIKE clauses, such as % for any string of zero or more characters, or _ for a single character.

GetAllRoles

Returns an array with all the roles.

GetRolesForUser

Returns an array with all the roles to which the specified user belongs.

GetUsersInRole

Returns the array of usernames (not MembershipUser instances) of users who belong to the specified role.

IsUserInRole

Indicates whether the specified user is a member of the specified role.

RemoveUserFromRole, RemoveUserFromRoles, RemoveUsersFromRole, RemoveUsersFromRoles

Removes one or more users from one or more roles.

RoleExists

Indicates whether a role with the specified name already exists.

Using these methods is straightforward, and you will see some practical examples in the "Solution" section of this chapter, where we implement the administration console to add and remove users to and from roles.

The roles system integrates perfectly with the standard IPrincipal security interface, which is implemented by the object returned by the page's User property. Therefore, you can use the User object's IsInRole method to check whether the current user belongs to the specified role.

The SQL Server provider retrieves and stores the data from and to the tables aspnet_Roles and aspnet_UsersInRoles. The latter links a user from the aspnet_Users table (or another user table, if you're using a custom membership provider for a custom database) to a role in the aspnet_Roles table. Figure 4-10 shows the database diagram, again, updated with the addition of these two tables.

Figure 4-10

Figure 4.10. Figure 4-10

Using Roles to Protect Pages and Functions against Unauthorized Access

Basically, you have two ways to control and protect access to sensitive pages: you can do it either imperatively (programmatically) or declaratively (using a config file). If you want to do it by code, in the page's Load event you would write something like the following snippet:

If Not Roles.IsUserInRole("Administrators") Then
    Throw New System.Security.SecurityException("Sorry, this is a restricted
function you are not authorized to perform")
End If

When you don't pass the username to the Roles.IsUserInRole method, it takes the name of the current user, and then "forwards" the call to the IsInRole method of the current user's IPrincipal interface. Therefore, you can call it directly and save some overhead by using the following code:

If Not Me.User.IsInRole("Administrators") Then
    Throw New System.Security.SecurityException("Sorry, this is a restricted
function you are not authorized to perform")
End If

When Roles.IsUserInRole is called with the overload that takes in the username (which is not necessarily equal to the current user's username), the check is done by the selected role's provider. In the case of the built-in SqlRoleProvider, a call is made to the aspnet_UsersInRoles_IsUserInRole stored procedure.

The biggest disadvantage of imperative (programmatic) security is that to secure an entire folder, you have to copy and paste this code in multiple pages (or use a common base class for them). Even worse, when you want to change the ACL (access control list) for a page or folder (because, for example, you want to allow access to a newly created role), you will need to change the code in all those files! Declarative security makes this job much easier: you define an <authorization> section in a web.config (either for the overall site or for a subfolder), which specifies the users and roles who are allowed to access a certain folder or page. The following snippet of web.config gives access to members of the Administrators role, while everyone else (*) is denied access to the current folder's pages:

<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0">
   <system.web>
      <authorization>
         <allow roles="Administrators" />
         <deny users="*" />
      </authorization>
      <!-- other settings... -->
   </system.web>
</configuration>

Authorization conditions are evaluated from top to bottom, and the first one that matches the user or role stops the validation process. This means that if you switched the two preceding conditions, the <deny> condition would match for any user, and the second condition would never be considered; as a result, nobody could access the pages. This next example allows everybody except anonymous users (those who have not logged in and who are identified by the ? character):

<authorization>
   <deny users="?" />
   <allow users="*" />
</authorization>

If you want to have different ACLs for different folders, you can have a different <authorization> section in each folder's web.config file. As an alternative, you can place all ACLs in the root web.config, within different <location> sections, as in this code:

<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0">
   <system.web>
      <!-- settings for the current folder -->
      <authorization>
         <allow users="*" />
      </authorization>
   </system.web>

   <location path="Admin">
      <!-- settings for the Admin sub-folder -->
      <system.web>
         <authorization>
            <allow roles="Administrators" />
            <deny users="*" />
         </authorization>
      </system.web>
   </location>

   <location path="Members">
      <!-- settings for the Members sub-folder -->
      <system.web>
         <authorization>
            <deny users="?" />
            <allow users="*" />
         </authorization>
</system.web>
   </location>
</configuration>

The path attribute of the <location> section can be the name of a subfolder (as shown above) or the virtual path of a single page. Using the <location> section is the only way to declaratively assign a different ACL to specific individual pages, since you can't have page-level web.config files. Although it's possible to restrict individual pages, it's more common to restrict entire subfolders.

Programmatic security checks are still useful and necessary in some cases, though, such as when you want to allow everybody to load a page but control the visibility of some visual controls (e.g., buttons to delete a record, or a link to the administration section) of that page to specific users. In these cases, you can use the code presented earlier to show, or hide, some server-side controls or containers (such as Panel) according to the result of a call to User.IsInRole. Alternatively, you can use a LoginView control that, in addition to its sections for anonymous and logged-in users, can also define template sections visible only to users who belong to specific roles. The next snippet produces different output according to whether the current user is anonymous, is logged in as a regular member or is logged in and belongs to the Administrators role:

<asp:LoginView ID="LoginView1" runat="server">
   <AnonymousTemplate>anonymous user</AnonymousTemplate>
   <LoggedInTemplate>member</LoggedInTemplate>
   <RoleGroups>
      <asp:RoleGroup Roles="Administrators">
         <ContentTemplate>administrator</ContentTemplate>
      </asp:RoleGroup>
   </RoleGroups>
</asp:LoginView>

Note that in cases where the currently logged-in user is also in the Administrators role, the LoginView control only outputs the content of the <Administrators> section, not that of the more general <LoggedInTemplate> section.

Finally, roles are also integrated with the site map, which lets you specify which roles will be able to see a particular link in the Menu or TreeView control that consumes the site map. This is a very powerful feature that makes it easy to show a user only the menu options he is actually allowed to access! For example, if you want the Admin link to be visible only to Administrators, here's how you define the map's node:

<siteMapNode title="Admin" url="~/Admin/Default.aspx" roles="Administrators">

However, to enable this to work you must also register a new provider for the SiteMap system (in the <siteMap> section of the web.config file), and set its securityTrimmingEnabled attribute to true. Registering the provider for the site map is very similar to registering a provider for the membership or roles system; in the "Solution" section you will see code examples to illustrate this.

Integrating ASP.NET AJAX Roles Service

Like the Authentication service, ASP.NET AJAX provides an interface to leverage Roles through web services on the client. The service has to be turned on by enabling in the web.config (shown in the previous section on authentication). The Sys.Services.RoleService class has three methods that need to be discussed: Load, IsUserInRole, and Roles.

The Load method loads the roles for the currently authenticated user. The Roles themselves are stored in client-side memory if the call to the load function is successful.

Argument

Description

loadCompletedCallback

The function that is called when the load operation has completed. The default is null.

failedCallback

The function that is called if loading has failed. The default is null.

userContext

User context information that is passed to the callback functions.

The IsUserInRole function checks to see if the current user belongs to a specified role and takes one parameter, role, which is the name of the role to check. The function returns true if the current user belongs to the role; otherwise, it returns false.

The Roles function returns an array listing all the roles that the current user belongs to. The Load method must be called before accessing the Roles function; otherwise, no roles will be returned.

Setting Up and Using User Profiles

In the ASP.NET 1.x days, if you wanted to associate a profile with a registered user, you typically added a custom table to your database, or stored them together with the user credentials, in the same table. You also had to write quite a lot of code for the business and data access layers, to store, retrieve, and update that data from your web pages. ASP.NET provides a built-in mechanism to manage user profiles, in an easy, yet very complete and flexible, way. This feature can save you hours or even days of work! The Profile module takes care of everything — you just need to configure what the profile will contain, that is, define the property name, type, and default value. This configuration is done in the root web.config file, within the <profile> section. The following snippet shows how to declare two properties: FavoriteTheme of type String, and BirthDate of type DateTime:

<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0">
   <system.web>
      <profile>
         <properties>
            <add name="FavoriteTheme" type="String" />
            <add name="BirthDate" type="DateTime" />
         </properties>
      </profile>
   <!-- other settings... -->
   </system.web>
</configuration>

Amazingly, that is all you need to do to set up a profile structure! When the application is run, the ASP.NET runtime dynamically adds a Profile property to the Page class, which means that you will not find such a property in the Object Browser at design time. The object returned is of type ProfileCommon (inherited from System.Web.Profile.ProfileBase); you will not find this class in the Object Browser either, or on the documentation, because this class is generated and compiled on-the-fly, according to the properties defined in the web.config file. The result is that you can just access the page's Profile property and read/write its subproperties. The following code demonstrates how to read the values of the current user's profile to show them on the page when it loads, and then updates them when a Submit button is clicked:

Protected Sub Page_Load(ByVal sender As Object, ByVal e As EventArgs)
    If Not Me.IsPostBack Then
        ddlThemes.SelectedValue = Me.Profile.FavoriteTheme
        txtBirthDate.Text = Me.Profile.BirthDate.ToShortDateString()
    End If
End Sub

Protected Sub btnSubmit_Click(ByVal sender As Object, ByVal e As EventArgs)
    Me.Profile.FavoriteTheme = ddlThemes.SelectedValue
    Me.Profile.BirthDate = DateTime.Parse(txtBirthDate.Text)
End Sub

Even though you can't see these properties in the Object Browser, Visual Studio is smart enough to compile this class in the background when the web.config file is modified, so you get full IntelliSense in the IDE, just as if the Profile properties were built-in properties of the Page class, like all the others. Figure 4-11 shows the IDE with IntelliSense in action.

Figure 4-11

Figure 4.11. Figure 4-11

Note

Having a class dynamically generated by Visual Studio with all the custom profile properties (and the IntelliSense for them) doesn't just speed up development but also helps developers reduce inadvertent coding errors. In fact, this class provides strongly typed access to the user's profile, so if you try to assign a string or an integer to a property that expects a date, you'll get a compile-time error, and you can correct the problem immediately!

When you define a profile property, you can also assign a default value to it, by means of the defaultValue attribute:

<add name="FavoriteTheme" type="String" defaultValue="Colorful" />

The default value for strings is an empty string, not null, as you may have thought. This makes it easier to read string properties, because you don't have to check whether they are null before using the value somewhere. The other data types have the same default values that a variable of the same type would have (e.g., zero for integers).

When you declare profile properties, you can also group them into subsections, as shown here:

<profile>
   <properties>
      <add name="FavoriteTheme" type="String" />
      <add name="BirthDate" type="DateTime" />
      <group name="Address">
         <add name="Street" type="String" />
         <add name="City" type="String" />
      </group>
   </properties>
</profile>

The Street property will be accessible as Profile.Address.Street. Note, however, that you can't define nested groups under each other but can only have a single level of groups. If this limitation is not acceptable to you, you can define your own custom class with subcollections and properties, and reference it in the type attribute of a new property. In fact, you are not limited to base types for profile properties; you can also reference more complex classes (such as ArrayList or Color), and your own enumerations, structures, and classes, as long as they are serializable into a binary or XML format (the format is dictated by the property's serializeAs attribute).

The Profile system is built upon the provider model design pattern. ASP.NET comes with a single built-in profile provider that uses a SQL Server database as a backing store. However, as usual, you can build your own providers or find them from third parties.

Accessing Profiles from Business Classes

Sometimes you may need to access the user's profile from a business class, such as we are doing in this book. The Profile property is dynamically added only to the ASPX pages' code-behind classes, so you can't use it in those situations. However, you can still access it through the Profile property exposed by the current HttpContext. The HttpContext class is the container for the current web request — it's used to pass around objects that are part of the request: forms, properties, ViewState, and so on. Anytime you process a page, you will have this HttpContext information, so you can always pull a Profile class instance out of the HttpContext class. The returned type is ProfileBase, though, not the ProfileCommon object generated on-the-fly that enabled you to use IntelliSense and access properties in a strongly typed manner. Therefore, the resulting Profile class instance read from the HttpContext.Current.Profile will not be strongly typed. No problem — just create a ProfileBase object and use its methods to retrieve values. I have added a series of methods to the Helpers class to help with this in the Beer House. The first step is a series of overloaded GetUserProfile methods that return a ProfileBase object. The Create method has two overloads, one that accepts just the username and one that accepts the username and if they are authenticated. I created three versions of the GetUserProfile method, one to handle each of the Create overloads and one to automatically retrieve the profile for the current user, passing in his or her authentication status.

Public Shared Function GetUserProfile() As ProfileBase
        Return ProfileBase.Create(CurrentUserName,
CurrentUser.Identity.IsAuthenticated)
End Function

Public Shared Function GetUserProfile(ByVal vUserName As String) As ProfileBase
        Return ProfileBase.Create(vUserName, CurrentUser.Identity.IsAuthenticated)
End Function

Public Shared Function GetUserProfile(ByVal vUserName As String,
ByVal isAuthenticated As Boolean) As ProfileBase
        Return ProfileBase.Create(vUserName, isAuthenticated)
End Function

The next method to investigate is the ProfileBase.GetProfileGroup method. It accepts the name of the profile group you want to access and returns it as a ProfileGroupBase object. In the following example, I return the ProfileGroup as an object instead of a ProfileGroupBase. This seems to make it easier to cast the group as a custom object, such as the shopping cart, if needed.

Private Shared Function GetProfileSubGroup(ByVal profile As ProfileBase,
ByVal vSubGroup As String) As Object
        Return profile.GetProfileGroup(vSubGroup)
End Function

The ProfileBase's GetPropertyValue and SetPropertyValue can be used to access and update profile values. If a value is a member of a SubGroup, you simply need to access the SubGroup and call these methods from it. The wrapper methods that follow demonstrate using the PropertyValue functions.

Public Shared Function GetProfileSubGroupStringProperty(
ByVal profile As ProfileBase, ByVal vSubGroup As String,
ByVal vProperty As String) As String
      If Not IsNothing(profile) AndAlso Not
IsNothing(profile.GetProfileGroup(vSubGroup)) Then
         Return profile.GetProfileGroup(vSubGroup).GetPropertyValue(vProperty)
      Else
         Return String.Empty
      End If
End Function

Public Shared Function GetProfileSubGroupIntegerProperty(ByVal profile As
ProfileBase, ByVal vSubGroup As String, ByVal vProperty As String) As Integer
      If Not IsNothing(profile) Then
         Return GetProfileSubGroup(profile, vSubGroup).GetPropertyValue(vProperty)
      Else
         Return 0
      End If
End Function

Public Shared Function GetProfileSubGroupObjectProperty(ByVal profile As
ProfileBase, ByVal vSubGroup As String, ByVal vProperty As String) As Object
      If Not IsNothing(profile) Then
         Return GetProfileSubGroup(profile, vSubGroup).GetPropertyValue(vProperty)
      Else
         Return Nothing
      End If
End Function

Public Shared Function GetProfileTheme(ByVal profile As ProfileBase) As String
      Return GetProfileSubGroupStringProperty(profile, "Preferences",
"Theme").ToString
End Function

Public Shared Sub SetProfileTheme(ByVal profile As ProfileBase,
ByVal sTheme As String)
      GetProfileSubGroup(profile, "Preferences").setpropertyvalue("Theme", sTheme)
End Sub

Public Shared Function GetSubscriptionType(ByVal profile As ProfileBase) As
 SubscriptionType
      Return CType(GetProfileSubGroupStringProperty(profile, "Preferences",
"Newsletter"), SubscriptionType)
End Function

As we proceed through the application, you will see places where these helper methods are used, such as in the Newsletter module:

' retreive all subscribers to the plain-text and HTML newsletter
Dim subscribers As New List(Of SubscriberInfo)

For Each user As MembershipUser In Membership.GetAllUsers
Dim userProfile As ProfileBase = Helpers.GetUserProfile(user.UserName, False)
      If Not Helpers.GetSubscriptionType(userProfile) <> SubscriptionType.None Then
         Dim subscriber As New SubscriberInfo(user.UserName, user.Email, _
            Helpers.GetProfileFirstName(userProfile), _
            Helpers.GetProfileLastName(userProfile),
Helpers.GetSubscriptionType(userProfile))
            subscribers.Add(subscriber)
            Newsletter.Lock.AcquireWriterLock(Timeout.Infinite)
            Newsletter.TotalMails += 1
            Newsletter.Lock.ReleaseWriterLock()

End If
Next

An option that I did not employ in the Beer House is to create a custom profile class that inherits from ProfileBase and automatically instantiates itself with properties that map to the profile properties defined in the web.config. Lee Dumond does a great job explaining this on his blog, http://leedumond.com/blog/getting-strongly-typed-profile-properties-from-a-class-library/. Either way works just fine and the choice is yours.

Accessing the Profile for Users Other Than the Current User

So far, all the examples have shown how to read and write the profile for the current user. However, you can also access other users' profiles — very useful if you want to implement an administration page to read and modify the profiles of your registered members. Your administrator must be able to read and edit the profile properties for any user. The ProfileCommon class exposes a GetProfile method that returns the profile for any specified user, and once you obtain this profile instance you can read and edit the profile properties just as you can do for the current user's profile. The only difference is that after changing some values of the retrieved profile, you must explicitly call its Save method, which is not required when you modify the profile for the current user. (In the case of the current user, Save is called automatically by the runtime when the page unloads.) Here's an example of getting a profile for a specified user and then modifying a property value in that profile:

Dim profile As ProfileCommon = Profile.GetProfile("Marco")
profile.BirthDate = New DateTime(1980, 9, 28)
profile.Save()

Adding Support for Anonymous Users

The preceding code works only for registered users who are logged in. Sometimes, however, you want to be able to store profile values for users who are not logged in. You can explicitly enable anonymous identification support by adding the following line to web.config:

<anonymousIdentification enabled="true"/>

After that, you must indicate what properties are available to anonymous users. By default, a property is only accessible for logged-in users, but you can change this by setting the property's allowAnonymous attribute to true, as follows:

<add name="FavoriteTheme" type="String"
   allowAnonymous="true" defaultValue="Colorful" />

This is useful to allow an anonymous user to select a theme for his current session. This would not be saved after his session terminates because we don't have an actual user's identity to allow us to persist the settings. Another important concern regarding profiles for anonymous users is the migration from anonymous to authenticated status. Consider the following situation: a registered user comes to the site and browses it without logging in. He or she then changes some profile properties available to anonymous users, such as the name of the favorite theme. At some point, he or she wants to access a restricted page and needs to log in. Now, because the favorite theme was selected while the user was anonymous, it was stored into a profile linked to an anonymous user ID. After the user logs in, he or she then becomes an authenticated user with a different user ID. Therefore, that user's previous profile settings are loaded, and the user will get a site with the theme selected during a previous session, or the default one. What you wanted to do, however, was to migrate the anonymous user's profile to the authenticated user's profile at the time he or she logged in. This can be done by means of the Profile_MigrateAnonymous global event, which you can handle in the Global.asax file. Once this event is raised, the HttpContext.Profile property will already have returned the authenticated user's profile, so it's too late for us to save the anonymous profile values. You can, however, get a reference to the anonymous profile previously used by the user and then copy values from it to the new profile. In the "Solution" section, you will see how to implement this event to avoid losing the user's preferences.

Integrating the ASP.NET AJAX Profile Service

Along with the Authentication and Roles, the Sys.Services.ProfileService class provides client-side access to the Profile service. There are two main functions to become familiar with: Load and Save. The Load function loads the user's profile into client memory. The method accepts an array of profile properties to load, along with the typical callback function names. If null is passed into the Load function, the full set of profile properties is loaded.

Sys.Services.ProfileService.load(null,

LoadCompletedCallback, ProfileFailedCallback, null);

The profileServer also has to be enabled in the web.config file, as shown earlier. When you enable the ProfileService class, you have to explicitly define what fields can be read and which fields can be written to. You cannot create new profile fields through the ProfileService. You also cannot change the Read/Write status of a property through the service.

Readonly properties are defined as a comma-separated list in the readAccessProperties value. A similar list can be set to the witeAccessProperties. The list does not have to match, and it can overlap.

<profileService enabled="true" readAccessProperties="FirstName, LastName" />

OpenId Identity Services

One of the usability problems all users face with the web is managing a never-ending list of passwords. The technical term that embodies the challenges and solutions to this real-world issue is "identity." The marketplace has been creating some solutions to this problem; Microsoft, for example, introduced CardSpace with the release of .NET 3.5. The developer community has also created a technology called OpenId. The reality is the two technologies are very complementary because CardSpace targets the more secure websites requiring SSL, such as sites used in online banking. OpenId is more general purpose and ideal for a site that is not mission critical, although it could be sufficient to address sites that are. Because the Beer House is a simple site that does not have a SSL certificate, which is currently required by CardSpace, we will integrate OpenId authentication as an option for our users.

OpenId was originally created by Brad Fitzpatrick as the identity system for LiveJournal. It has matured into a system that is ultimately managed by the OpenId Foundation, http://OpenId.net. The main paradigm change that OpenId brings to the table is the concept that instead of a username/password combination, an identity relies on a URL. For example, http://TheBeerHouse.MyOpenId.net. Instead of providing a username and password to the site, known as the Relaying party, the user supplies an OpenId URL. The Relaying party, your website, then redirects the user to the OpenId provider at the URL. The URL can point to any number of providers; there is no one single OpenId provider. Here, the user is authenticated, typically through a username and password, but that is not an absolute requirement, and upon successful authentication, the user is redirected back to the Relaying party.

Typically, a user remains authenticated with their OpenId provider, usually through a persistent cookie, but this is also not a requirement. Once authenticated on the site, the user decides what information he or she wants to share with the Relaying party. The user can decide at this point if he or she is comfortable sharing the required and optional data with the Relaying party. I try to keep these requests as minimal as possible to create a valid user account in the ASP.NET membership provider. If this is the user's first experience authenticating with my site, he or she will need to have an account to authenticate against. So the create user process is drastically changed from the CreateUserWizard process also described in this chapter.

When a user creates an OpenId account he or she will supply the provider with much of this information. As part of the OpenId protocol the Relaying party tells the identity provider what fields it requires to perform a successful authentication; it can also request optional information. Before the user tells the identity provider to trust the Relaying party, the user is asked if he or she is comfortable sharing personal information with the Relaying party. Figure 4-12 illustrates the steps required to perform OpenId authentication.

Figure 4-12

Figure 4.12. Figure 4-12

Ultimately, the user is more in control of his or her personal identification and has one central location in which to store this data. This should help the user when trying to authenticate with numerous sites, and save time in the registration process because the same information can be seamlessly passed back to the relaying party without having to be reentered by the user.

Designing Our Solution

So far, we have described the general features of the membership and profile services, and we can now build upon this knowledge and design exactly how we can implement these features in our particular website. Here's the summary of our design objectives regarding membership and profile features, and a description of the corresponding web pages:

  • A login box will be visible in the top-right corner of each page whenever the user is anonymous. After the user logs in, the login box will be hidden. Instead, we'll show a greeting message and links for Logout and Edit Profile.

  • A faux login dialog box will be displayed using ASP.NET AJAX and CSS that allows users to authenticate themselves using either ASP.NET AJAX or OpenId. The dialog box is embedded in the main master page for the site and will be hidden by default. It will perform authentication by calling the membership services built into ASP.NET AJAX. If a user wants to authenticate him- or herself using OpenId, an additional interface will provide for it.

  • A Register.aspx page will allow new users to register (create their own account), and we'll populate it with some profile settings upon registration. The profile will have the following first-level properties: FirstName (String), LastName (String), Gender (String), BirthDate (DateTime), Occupation (String), and Website (String). A profile group named Address will have the following subproperties: Street, PostCode, City, State, and Country, all of type String. Another group, named Contacts, will have the following string subproperties: Phone and Fax. A final group, named Preferences, will have the Theme, Culture, and Newsletter properties. The Theme property is the only one that will be made available to anonymous users.

  • Our PasswordRecovery.aspx page will allow users to recover forgotten passwords; it can e-mail the password to the user's e-mail address that is on file. This is possible because we'll configure the membership module to encrypt the password, instead of storing it as a hash (a hashed password is a one-way encryption that is not reversible). We had to decide whether we wanted the best possible security (hashing) or a more user-friendly encryption method that enables us to recover the user's password. In our scenario, we've determined that the user-friendly option is the best choice.

  • Our EditProfile.aspx page will only be accessible to registered members, and it will allow them to change their account's password and the profile information they set up at registration time.

  • We'll create some administration pages to allow the administrator to read and edit all the information about registered users (members). A ManageUsers.aspx page will help the administrator look up records for members by either their username or e-mail address (searches by partial text will also be supported). Among the data returned will be their username, e-mail address, when they registered or last accessed the site, and whether they are active or not. A second page, EditUser.aspx, will show additional details about a single user, and will allow the administrator to enable or disable the account, assign new roles to the user, remove roles from the user, and edit the user's personal profile.

Solution

We'll get right into the implementation because we've already covered the basic material and our objectives in the "Design" section of this chapter. Now we'll put all the pieces together to create the pages and the supporting code to make them work. These are the steps used to tackle our solution:

  1. Define all the settings required for membership, roles, and profiles in web.config.

  2. Create the login box on the master page, and the "access denied" page. To test the login process before creating the registration page, we can easily create a user account from the ASP.NET Web Administration Tool.

  3. Create the registration and profiling page.

  4. Create the password recovery page.

  5. Create the page to change the current password and all the profile information.

  6. Design profiles to save the user's favorite theme, and handle the migration from an anonymous user to an authenticated user so we won't lose his theme preference.

  7. Create the administration pages to display all users, as well as edit and delete them.

The Configuration File

Following is a partial snippet of the web.config file (located in the site's root folder) used to configure the authentication type, membership, role manager, profile, and site map provider (in this order):

<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0">
   <!-- other settings... -->
   <system.web>
      <authentication mode="Forms">
         <forms cookieless="AutoDetect"
            loginUrl="~/AccessDenied.aspx" name="TBHFORMAUTH" />
      </authentication>

      <membership defaultProvider="TBH_MembershipProvider"
         userIsOnlineTimeWindow="15">
         <providers>
            <add name="TBH_MembershipProvider"
               connectionStringName="LocalSqlServer"
               applicationName="/"
               enablePasswordRetrieval="true"
               enablePasswordReset="true"
               requiresQuestionAndAnswer="true"
               requiresUniqueEmail="true"
               passwordFormat="Encrypted"
               maxInvalidPasswordAttempts="5"
               passwordAttemptWindow="10"
               minRequiredPasswordLength="5"
               minRequiredNonalphanumericCharacters="0"
               type="System.Web.Security.SqlMembershipProvider, System.Web,
Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
            />
         </providers>
      </membership>

      <roleManager enabled="true" cacheRolesInCookie="true"
         cookieName="TBHROLES" defaultProvider="TBH_RoleProvider">
         <providers>
            <add name="TBH_RoleProvider"
               connectionStringName="LocalSqlServer"
               applicationName="/"
               type="System.Web.Security.SqlRoleProvider, System.Web,
Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
            />
</providers>
      </roleManager>

      <anonymousIdentification cookieless="AutoDetect"  enabled="true"/>

      <profile defaultProvider="TBH_ProfileProvider">
         <providers>
            <add name="TBH_ProfileProvider"
               connectionStringName="LocalSqlServer"
               applicationName="/"
               type="System.Web.Profile.SqlProfileProvider, System.Web,
Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"
            />
         </providers>
         <properties>
            <add name="FirstName" type="String" />
            <add name="LastName" type="String" />
            <add name="Gender" type="String" />
            <add name="BirthDate" type="DateTime" />
            <add name="Occupation" type="String" />
            <add name="Website" type="String" />
            <group name="Address">
               <add name="Street" type="String" />
               <add name="PostalCode" type="String" />
               <add name="City" type="String" />
               <add name="State" type="String" />
               <add name="Country" type="String" />
            </group>
            <group name="Contacts">
               <add name="Phone" type="String" />
               <add name="Fax" type="String" />
            </group>
            <group name="Preferences">
               <add name="Theme" type="String" allowAnonymous="true" />
               <add name="Culture" type="String" defaultValue="en-US" />
               <add name="Newsletter"
type="MB.TheBeerHouse.BLL.Newsletters.SubscriptionType" />
            </group>
         </properties>
      </profile>

      <machineKey validationKey="287C5D125D6B7E7223E1F719E3D58D17BB967703017E1BBE28
618FAC6C4501E910C7E59800B5D4C2EDD5B0ED98874A3E952D60BAF260D9D374A74C76CB741803"
         decryptionKey="5C1D8BD9DF3E1B4E1D01132F234266616E0D5EF772FE80AB"
         validation="SHA1"/>

      <siteMap defaultProvider="TBH_SiteMapProvider" enabled="true">
         <providers>
            <add name="TBH_SiteMapProvider"
               type="System.Web.XmlSiteMapProvider"
               securityTrimmingEnabled="true"
               siteMapFile="web.sitemap"
            />
         </providers>
</siteMap>
   </system.web>

   <location path="EditProfile.aspx">
      <system.web>
         <authorization>
            <deny users="?" />
            <allow users="*" />
         </authorization>
      </system.web>
   </location>

   <system.net>
      <mailSettings>
         <smtp deliveryMethod="Network">
            <network defaultCredentials="true" host="vsnetbeta2" port="25"
               from="[email protected]"></network>
         </smtp>
      </mailSettings>
   </system.net>
</configuration>

As you can see, a provider is defined and configured for all modules that support this pattern. I specified the provider settings even though they are often the same as the default providers found in machine.config.default, because I can't be sure whether the defaults will always be the same in future ASP.NET releases, and I like to have this information handy in case I might want to make further changes someday. To create these settings, I copied them from machine.config.default, and then I made a few tweaks as needed.

I defined a Newsletter profile property as type TheBeerHouse.BLL.Newsletters.SubscriptionType, which is an enumeration defined in the SubscriptionType.vb file located under /Newsletter in the class library:

Namespace BLL.Newsletters

    Public Enum SubscriptionType As Integer
        None = 0
        PlainText = 1
        Html = 2
    End Enum

End Namespace

To configure the cryptographic keys, we need to set the validationKey and decryptionKey attributes of the machineKey element. Because we are configuring the membership module to encrypt passwords, we can't leave them set at AutoGenerate, which is the default. You can find some handy utilities on the Internet that will help you set these values. You can check the following Microsoft Knowledge Base article for more information: http://support.microsoft.com/kb/313091. This article shows how to implement a class that makes use of the cryptographic classes and services to create values for these keys. Alternatively, if you want an easier way to create these keys, check out this online tool: www.aspnetresources.com/tools/keycreator.aspx.

I want to reiterate a point I made earlier in this chapter: if you'll be deploying your application to a web farm (more than one web server configured to distribute the load between the servers), then you need to specify the same machine keys for each server. In addition to password encryption, these keys are also used for session state. By synchronizing these keys with all your servers, you ensure that the same encryption will be used on each server. This is essential if there's a chance that a different server might be used to process the next posting of a page.

Creating the Login Box

The user interface for user authentication is fairly common across all websites; a username, password textbox combination, an optional Remember Me checkbox, a link to register on the site, and a link to retrieve a lost password. Something like this is ideal to collect together in a user or Web Control, which is exactly with the ASP.NET team did in ASP.NET 2.0. The Login control combined all these common user interface elements into a fully customizable control that seamlessly works with the membership provider.

Now it's time to fill that <div> with the code for the login box. ASP.NET typically uses customizable templates to control the visual rendering of many controls. In this section, we will customize the default user interface of the login controls by providing our own template. Using custom templates offers the following advantages:

  1. You have full control over the appearance of the produced output, and you can change many aspects of the behavior. For example, our custom template can be used with validator controls, and you can set their SetFocusOnError property to true (this defaults to false in the default template). This property specifies whether the validator will give the focus to the control it validates if the client-side validation fails. This is desirable in our case because we want the focus to go to the first invalid control after the user clicks the Submit button if some controls have invalid input values.

  2. If you don't redefine the TextBox controls, the SetInputControlsHighlight method we developed in Chapter 2 will not find them, and thus the textboxes will not get the special highlight behavior that gives users a visual cue as to which TextBox currently has the focus.

Here's the complete code that uses a LoginView to display a login box. This login box contains links to register a new account or to recover a forgotten password when the user is anonymous, or it will contain a welcome message, a logout link, and a link to the EditProfile page if the user is currently logged in:

<div id="loginbox">
<asp:LoginView ID="LoginView1" runat="server">
   <AnonymousTemplate>
      <asp:Login ID="Login" runat="server" Width="100%"
         FailureAction="RedirectToLoginPage">
         <LayoutTemplate>
            <table border="0" cellpadding="0" cellspacing="0" width="100%">
               <tr>
                  <td width="60px">Username:</td>
                  <td><asp:TextBox id="UserName" runat="server" Width="95%" /></td>
                  <td width="5px" align="right">
                     <asp:RequiredFieldValidator ID="valRequireUserName"
                        runat="server" SetFocusOnError="true" Text="*"
ControlToValidate="UserName" ValidationGroup="Login" />
                  </td>
               </tr>
               <tr>
                  <td>Password:</td>
                  <td><asp:TextBox ID="Password" runat="server"
                     TextMode="Password"  Width="95%" /></td>
                  <td width="5px" align="right">
                     <asp:RequiredFieldValidator ID="valRequirePassword"
                        runat="server" SetFocusOnError="true" Text="*"
                        ControlToValidate="Password" ValidationGroup="Login" />
                  </td>
               </tr>
            </table>
            <table border="0" cellpadding="0" cellspacing="0" width="100%">
               <tr>
                  <td><asp:CheckBox ID="RememberMe" runat="server"
                     Text="Remember me"></asp:Checkbox></td>
                  <td align="right">
                     <asp:ImageButton ID="Submit" runat="server"
                        CommandName="Login" ImageUrl="~/images/go.gif"
                        ValidationGroup="Login" />
                  </td>
                  <td width="5px" align="right">&nbsp;</td>
               </tr>
            </table>
            <div style="border-top: solid 1px black; margin-top: 2px">
               <asp:HyperLink ID="lnkRegister" runat="server"
                  NavigateUrl="~/Register.aspx">Create new account
               </asp:HyperLink><br />
               <asp:HyperLink ID="lnkPasswordRecovery" runat="server"
                  NavigateUrl="~/PasswordRecovery.aspx">I forgot my password
               </asp:HyperLink>
            </div>
         </LayoutTemplate>
      </asp:Login>
   </AnonymousTemplate>
   <LoggedInTemplate>
      <div id="welcomebox">
         <asp:LoginName ID="LoginName1" runat="server"
            FormatString="Hello  {0}!" /><br />
         <small>
         <font face="Webdings">4</font>
         <asp:HyperLink ID="lnkProfile" runat="server" Text="Edit Profile"
            NavigateUrl="~/EditProfile.aspx" />
         <font face="Webdings">3</font><br />
         <font face="Webdings">4</font>
         <asp:LoginStatus ID="LoginStatus1" Runat="server" />
         <font face="Webdings">3</font>
         </small>
      </div>
   </LoggedInTemplate>
</asp:LoginView>
</div>

Absolutely no code is needed in the master page's code-behind files. In fact, because we used the right IDs for the textboxes and other controls in the template sections of the Login control, it will continue working autonomously as if it were using the default UI. To test the control, first create a new user through the ASP.NET Website Configuration Tool described earlier, and then try to log in. In Figure 4-13, you can see what the home page looks like from an anonymous user's and an authenticated user's point of view.

Figure 4-13

Figure 4.13. Figure 4-13

Observe the login box in the first window (only displayed for anonymous users), and the new greeting message and links in the second window that are displayed after the user logs in. Also, note that an Admin link is visible on the second browser's menu bar. That Admin link only appears for users who have been assigned the Administrators role. The web.sitemap file is used to generate the menu, and the item representing the Admin link was modified by adding the roles attribute, which was set to Administrators:

<siteMap xmlns="http://schemas.microsoft.com/AspNet/SiteMap-File-1.0">
   <siteMapNode title="Home" url="~/Default.aspx">
      <!-- other items -->
      <siteMapNode title="Admin" url="~/Admin/Default.aspx"
         roles="Administrators" />
   </siteMapNode>
</siteMap>

Of course, you can test the site-map-controlled menu by assigning the Administrators role to your sample user. You can even do this role assignment from the online configuration application!

The AccessDenied.aspx Page

If you look a few pages back, you'll see that the loginUrl attribute of the web.config's <forms> is set to AccessDenied.aspx. As its name clearly suggests, this is the URL of the page we want to redirect control to when the user tries to access a protected page to which he doesn't have permission. In many cases, you would place the login box in this page, hence the attribute name (loginUrl). In our case, however, we have a site that lets anonymous users access many different pages, and we only require the user to log in to gain access to a small number of pages, so we want to make sure the login box is visible from any page when the user is anonymous. The login box invites users to log in if they already have an account, or to register if they don't have one. This AccessDenied page is also loaded when users try to log in but give invalid credentials, or when they are already logged in but they don't have a role required by the page they requested. Therefore, the page has three possible messages, and the following code uses three labels for them:

<div id="ContentTitleDiv">
      <asp:Literal runat="server" ID="ltlTitle"></asp:Literal></div>
   <div id="ContentBodyDiv">
      <asp:Image ID="imgLock" runat="server" ImageUrl="~/images/lock.gif"
 ImageAlign="left"
         AlternateText="Access denied" />

      <asp:Label runat="server" ID="lblLoginRequired" Font-Bold="true">
You must be a registered user to access this page. If you already have an account,
please login with
your credentials in the box on the upper-right corner. Otherwise <a
href="Register.aspx">click here</a> to register now for free.
      </asp:Label>
      <asp:Label runat="server" ID="lblInsufficientPermissions" Font-Bold="true">
Sorry, the account you are logged with does not have the permissions required to
access this page.
      </asp:Label>
      <asp:Label runat="server" ID="lblInvalidCredentials" Font-Bold="true">
The submitted credentials are not valid. Please check they are correct and try
again.
If you forgot your password, <a href="PasswordRecovery.aspx">click here</a> to
recover it.
      </asp:Label>
   </div>

Since OpenId has been added to the authentication options, the Login control has been changed to have a LayoutTemplate defined. This affords us the flexibility of using the built-in authentication logic that comes with the Login control but lets us extend the available layout to include some extra controls to work with OpenId. When you create a custom layout for the Login control or any of the stock membership controls, special care must be taken to create controls with the same ID values the stock control uses. Otherwise the control will look for them and throw exceptions.

<asp:Login runat="server" ID="adLogin" CreateUserUrl="~/Register.aspx"
 Width="300">
            <LayoutTemplate>
                <table border="0" cellpadding="0" cellspacing="0" width="100%">
                    <tr>
                        <td width="60px">
                            <asp:Label runat="server" ID="lblUserName"
AssociatedControlID="UserName" Text="Username:"
                                meta:resourcekey="lblUserNameResource1" />
                        </td>
                        <td>
                            <asp:TextBox ID="UserName" runat="server" Width="95%"
Columns="30" meta:resourcekey="UserNameResource2" />
                        </td>
                        <td width="5px" align="right">
                            <asp:RequiredFieldValidator ID="valRequireUserName"
runat="server" SetFocusOnError="True"
                                ControlToValidate="UserName" Text="*"
ValidationGroup="Login" meta:resourcekey="valRequireUserNameResource1" />
                        </td>
                    </tr>
                    <tr>
                        <td>
                            <asp:Label runat="server" ID="lblPassword"
AssociatedControlID="Password" Text="Password:"
                                meta:resourcekey="lblPasswordResource1" />
                        </td>
                        <td>
                            <asp:TextBox ID="Password" runat="server"
TextMode="Password" Width="95%" meta:resourcekey="PasswordResource2" />
                        </td>
                        <td width="5px" align="right">
                            <asp:RequiredFieldValidator ID="valRequirePassword"
runat="server" SetFocusOnError="True"
                                ControlToValidate="Password" Text="*"
ValidationGroup="Login" meta:resourcekey="valRequirePasswordResource1" />
                        </td>
                    </tr>
                </table>
                <table border="0" cellpadding="0" cellspacing="0" width="100%">
                    <tr>
                        <td>
                            <asp:CheckBox ID="RememberMe" runat="server"
Text="Remember me" meta:resourcekey="RememberMeResource1">
                            </asp:CheckBox>
                        </td>
                        <td align="right">
                            <asp:ImageButton ID="Submit" runat="server"
 AlternateText="Login" CommandName="Login"
                                ImageUrl="~/images/go.gif" ValidationGroup="Login"
 meta:resourcekey="SubmitResource1" />
                        </td>
                        <td width="5px" align="right">
                            &nbsp;
                        </td>
                    </tr>
                </table>
                <table border="0" cellpadding="0" cellspacing="0" width="100%">
                    <tr>
                        <td>
                            <asp:TextBox runat="server" ID="txtOpenId" Width="200"
class="openid_identifier" />
                        </td>
                        <td>
<asp:ImageButton runat="server" ID="btnLogon"
AlternateText="Login Using OpenId"
                                ImageUrl="~/images/open-id-login.gif"
onclick="btnLogon_Click" /><br />
                        </td>
                        <td width="5px" align="right">
                            &nbsp;
                        </td>
                    </tr>
                </table>
                <div style="border-top: solid 1px black; margin-top: 2px;
padding-top: 2px">
                    <asp:HyperLink ID="lnkRegister" runat="server" NavigateUrl=
"~/Register.aspx" meta:resourcekey="lnkRegisterResource1">Create new
account</asp:HyperLink><br />
                    <asp:HyperLink ID="lnkPasswordRecovery" runat="server"
 NavigateUrl="~/PasswordRecovery.aspx"
                        meta:resourcekey="lnkPasswordRecoveryResource1">
I forgot my password</asp:HyperLink>
                </div>
            </LayoutTemplate>
        </asp:Login>

The Page_Load event handler in the code-behind file contains the logic for showing the proper label and hiding the other two. You need to do some tests to determine which of the three cases applies:

  • If the querystring contains a loginfailure parameter set to 1, it means that the user tried to log in but the submitted credentials were not recognized.

  • If the user is not authenticated and there is no loginfailure parameter on the querystring, it means that the user tried to access a page that is not available to anonymous users.

  • If the current user is already authenticated and the page is loaded anyway, it means the user does not have sufficient permission (read "does not belong to a required role") to access the requested page.

Here is how to translate this description to code:

Partial Public Class AccessDenied
    Inherits BasePage

    Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs)
Handles Me.Load
        lblInsufficientPermissions.Visible = Me.User.Identity.IsAuthenticated
        lblLoginRequired.Visible = Not (Me.User.Identity.IsAuthenticated) AndAlso _
            String.IsNullOrEmpty(Me.Request.QueryString("loginfailure"))
        lblInvalidCredentials.Visible = Not (Me.Request.QueryString("loginfailure")
 = Nothing) AndAlso _
            Me.Request.QueryString("loginfailure") = "1"

        If OpenID.IsOpenIdRequest Then
            Dim data As OpenIdData = OpenID.Authenticate()
If data.IsSuccess Then

                Dim mu As MembershipUser = Membership.GetUser(data.Identity)

                If IsNothing(mu) Then
                    mu =
Membership.GetUser(Membership.GetUserNameByEmail(data.Parameters("email")))
                End If

                If IsNothing(mu) Then
                    Response.Redirect(String.Format(
"Register.aspx?fullname={0}&email={1}&dob={2}&gender={3}
&postcode&{4}&country{5}", _
                    data.Parameters("fullname"), data.Parameters("email"),
data.Parameters("dob"), _
                    data.Parameters("gender"), data.Parameters("postcode"),
 data.Parameters("country")))
                End If

                FormsAuthentication.RedirectFromLoginPage(mu.UserName,
adLogin.RememberMeSet)

            End If

        End If

    End Sub

Implementing OpenId Authentication

Now that OpenId has been explained, it is time to actually implement it in the Beer House. There are several ASP.NET solutions available (http://code.google.com/p/dotnetopenid), including full-blown libraries with Web Controls and the like. However, for now I want to keep the implementation simple, and Mads Kristensen provides a bare bones implementation (http://blog.madskristensen.dk/post/OpenId-implementation-in-Csharp-and-ASPNET.aspx) that is easy to integrate. This is a single-class solution that handles the base needs of interacting with an identity provider. It takes care of all the fundamental requirements of requesting an OpenId authentication through the designated OpenId URL (identity provider) and managing the subsequent response.

If the user decides to authenticate using OpenId, the btnLogon click event is fired to initiate the OpenId process. A simple call to the shared Login method, with some key parameters, and the process is managed for us.

Protected Sub btnLogon_Click(ByVal sender As Object, ByVal e As
System.Web.UI.ImageClickEventArgs)
        Dim success As Boolean =
OpenID.Login(DirectCast(adLogin.FindControl("txtOpenId"), TextBox).Text, _
                            "email,fullname",
"country,language,nickname,dob,gender,postcode")

        If Not success Then
            Response.Write("The OpenID is not valid")
End If
    End Sub

The Login method takes the user's OpenId URL (identity), a comma-separated list of required parameters (email and fullname) and a comma-separated list of optional parameters. The method then builds a StringDictionary of values to pass the OpenId URL and redirects the user to their URL.

Public Shared Function Login(ByVal identity As String, ByVal requiredParameters As
String, ByVal optionalParameters As String) As Boolean
        Try
            Dim dic As StringDictionary = GetIdentityServer(identity)
            Dim server As String = dic("openid.server")
            Dim delgate As String = If(dic("openid.delegate"), identity)

            If Not String.IsNullOrEmpty(server) Then
                Dim redirectUrl As String = CreateRedirectUrl(requiredParameters,
 optionalParameters, delgate, identity)

                ' Add the provided data to session so it can be tracked after
authentication
                Dim data As New OpenIdData(identity)
                HttpContext.Current.Session("openid") = data

                HttpContext.Current.Response.Redirect(server + redirectUrl, True)
            End If
        Catch generatedExceptionName As Exception
        End Try

        Return False
End Function

Once users complete the verification and confirmation process at their OpenId provider they are redirected back to the Access Denied page. In a similar fashion, as we check to see if the page is in a PostBack or not, the Page Load event handler checks to see if the page is being loaded in response to an OpenId request, if so and the authentication was successful, the user is reconciled with an account and authenticated.

Public Shared ReadOnly Property IsOpenIdRequest() As Boolean
      Get
         ' All OpenID request must use the GET method
         If Not HttpContext.Current.Request.HttpMethod.Equals("GET",
StringComparison.OrdinalIgnoreCase) Then
            Return False
         End If

         Return HttpContext.Current.Request.QueryString("openid.mode")
IsNot Nothing
      End Get
End Property

The OpenId.Authenticate method returns an OpenIdData object, which contains all the requested data, including if the authentication was successful (isSuccess), the user's OpenId identity and any returned parameters.

Public Shared Function Authenticate() As OpenIdData
      Dim data As OpenIdData = DirectCast(HttpContext.Current.Session("openid"),
OpenIdData)

      ' Make sure the client has been through the Login method
      If data Is Nothing Then
         Return New OpenIdData(String.Empty)
      End If

      Dim query As NameValueCollection = HttpContext.Current.Request.QueryString

      ' Make sure the incoming request's identity matches the one stored in session
      If query("openid.claimed_id") <> data.Identity Then
         Return data
      End If

      data.IsSuccess = query("openid.mode") = "id_res"

      For Each name As String In query.Keys
         If name.StartsWith("openid.sreg.") Then
            data.Parameters.Add(name.Replace("openid.sreg.", String.Empty),
query(name))
         End If
      Next

      HttpContext.Current.Session.Remove("openid")
      Return data
End Function

The following routine first checks to see if there is an account that corresponds to the OpenId Identity value, if not it tries to retrieve the membership by the e-mail supplied by the identity provider. If the account still cannot be reconciled the user is then passed to the Register page with the supplied credentials in tow. I chose to place this code in the main MasterPage's Page Load event handler.

If OpenID.IsOpenIdRequest Then
Dim data As OpenIdData = OpenID.Authenticate()
If data.IsSuccess Then

            Dim mu As MembershipUser = Membership.GetUser(data.Identity)

            If IsNothing(mu) Then
               mu = Membership.GetUser(Membership.GetUserNameByEmail(
data.Parameters("email")))
            End If

            If IsNothing(mu) Then
               Response.Redirect(String.Format("Register.aspx?fullname=
{0}&email={1}&dob={2}&gender={3}&postcode&{4}&country{5}", _
               data.Parameters("fullname"), data.Parameters("email"),
data.Parameters("dob"), _
               data.Parameters("gender"), data.Parameters("postcode"),
data.Parameters("country")))
            Else
               FormsAuthentication.RedirectFromLoginPage(mu.UserName,
adLogin.RememberMeSet)
            End If
End If

End If

Figure 4-14 shows the Access Denied page.

Figure 4-14

Figure 4.14. Figure 4-14

The UserProfile Control

The user interface and the logic required to show and update a user's profile are contained in a user control named UserProfile.ascx and placed under the Controls folder. Profile properties can be edited in the registration page, the page that users access to change their profile, and the site's administration area, so it makes sense to put this code in a user control that can easily be reused in multiple places. The user interface part consists of simple HTML code to lay out a number of server-side controls (textboxes and drop-down lists) that will show users their profile properties and let them edit those properties:

<%@ Control Language="VB" AutoEventWireup="true" CodeFile="UserProfile.ascx.vb"
 Inherits="UserProfile" %>
<div class="sectionsubtitle">Site preferences</div>
<p></p>
<table cellpadding="2">
   <tr>
      <td width="130" class="fieldname">Newsletter:</td>
<td width="300">
         <asp:DropDownList runat="server" ID="ddlSubscriptions">
            <asp:ListItem Text="No subscription" Value="None" Selected="true" />
            <asp:ListItem Text="Subscribe to plain-text version"
               Value="PlainText" />
            <asp:ListItem Text="Subscribe to HTML version" Value="Html" />
         </asp:DropDownList>
      </td>
   </tr>
   <tr>
      <td class="fieldname">Language:</td>
      <td>
         <asp:DropDownList runat="server" ID="ddlLanguages">
            <asp:ListItem Text="English" Value="en-US" Selected="true" />
            <asp:ListItem Text="Italian" Value="it-IT" />
         </asp:DropDownList>
      </td>
   </tr>
</table>
<BR/>
<table cellpadding="2" width="95%">
    <tr>
        <td><img alt="Avatar" src="<%= GetAvatarURL() %>" /></td>
        <td><a href="http://www.gravatar.com">More About Gravatar</a></td>
    </tr>
   <tr>
      <td style="width: 110px;" class="fieldname"><asp:Label runat="server" ID=
"lblAvatarUrl" AssociatedControlID="txtAvatarUrl" Text="Avatar Url:" /></td>
      <td><asp:TextBox runat="server" ID="txtAvatarUrl" Width="99%" /></td>
   </tr>
   <tr>
      <td class="fieldname"><asp:Label runat="server" ID="lblSignature"
AssociatedControlID="txtSignature" Text="Signature:" /></td>
      <td><asp:TextBox runat="server" ID="txtSignature" Width="99%"
MaxLength="500" TextMode="multiLine" Rows="4" /></td>
   </tr>
</table>
<p></p>
<div class="sectionsubtitle">Personal details</div>
<p></p>
<table cellpadding="2">
   <tr>
      <td width="130" class="fieldname">First name:</td>
      <td width="300">
         <asp:TextBox ID="txtFirstName" runat="server" Width="99%"></asp:TextBox>
      </td>
   </tr>
   <tr>
      <td class="fieldname">Last name:</td>
      <td>
         <asp:TextBox ID="txtLastName" runat="server" Width="99%" />
</td>
   </tr>
   <tr>
      <td class="fieldname">Gender:</td>
      <td>
         <asp:DropDownList runat="server" ID="ddlGenders">
            <asp:ListItem Text="Please select one..." Value="" Selected="True" />
            <asp:ListItem Text="Male" Value="M" />
            <asp:ListItem Text="Female" Value="F" />
         </asp:DropDownList>
      </td>
   </tr>
   <tr>
      <td class="fieldname">Birth date:</td>
      <td>
         <asp:TextBox ID="txtBirthDate" runat="server" Width="99%"></asp:TextBox>
         <asp:CompareValidator runat="server" ID="valBirthDateFormat"
            ControlToValidate="txtBirthDate"
            SetFocusOnError="true" Display="Dynamic" Operator="DataTypeCheck"
            Type="Date" ErrorMessage="The format of the birth date is not valid."
            ValidationGroup="EditProfile">
            <br />The format of the birth date is not valid.
         </asp:CompareValidator>
      </td>
   </tr>
   <tr>
      <td class="fieldname">Occupation:</td>
      <td>
         <asp:DropDownList ID="ddlOccupations" runat="server" Width="99%">
            <asp:ListItem Text="Please select one..." Value="" Selected="True" />
            <asp:ListItem Text="Academic" />
            <asp:ListItem Text="Accountant" />
            <asp:ListItem Text="Actor" />
            <asp:ListItem Text="Architect" />
            <asp:ListItem Text="Artist" />
            <asp:ListItem Text="Business Manager" />
            <%-- other options... --%>
            <asp:ListItem Text="Other" />
         </asp:DropDownList>
      </td>
   </tr>
   <tr>
      <td class="fieldname">Website:</td>
      <td>
         <asp:TextBox ID="txtWebsite" runat="server" Width="99%" />
      </td>
   </tr>
</table>
<p></p>
<div class="sectionsubtitle">Address</div>
<p></p>
<table cellpadding="2">
   <tr>
      <td width="130" class="fieldname">Street:</td>
      <td width="300">
         <asp:TextBox runat="server" ID="txtStreet" Width="99%" />
      </td>
</tr>
   <tr>
      <td class="fieldname">City:</td>
      <td><asp:TextBox runat="server" ID="txtCity" Width="99%" /></td>
   </tr>
   <tr>
      <td class="fieldname">Zip / Postal code:</td>
      <td><asp:TextBox runat="server" ID="txtPostalCode" Width="99%" /></td>
   </tr>
   <tr>
      <td class="fieldname">State / Region:</td>
      <td><asp:TextBox runat="server" ID="txtState" Width="99%" /></td>
   </tr>
   <tr>
      <td class="fieldname">Country:</td>
      <td>
         <asp:DropDownList ID="ddlCountries" runat="server"
            AppendDataBoundItems="True" idth="99%">
            <asp:ListItem Text="Please select one..." Value="" Selected="True" />
         </asp:DropDownList>
      </td>
   </tr>
</table>
<p></p>
<div class="sectionsubtitle">Other contacts</div>
<p></p>
<table cellpadding="2">
   <tr>
      <td width="130" class="fieldname">Phone:</td>
      <td width="300"><asp:TextBox runat="server" ID="txtPhone" Width="99%" /></td>
   </tr>
   <tr>
      <td class="fieldname">Fax:</td>
      <td><asp:TextBox runat="server" ID="txtFax" Width="99%" /></td>
   </tr>
</table>

There is a custom StateDropDownList control that was added to the class library project (in the controls folder). It derives from the DropDownList control and overrides the Init method to bind the states. The DataTextField and DataValueField names are set and the CreateDataSource method is called. This populates the list. There are two additional properties, IncludeCanada and IncludeUS. These are simple Boolean flags that tell the CreateDataSource method if it should bind the provinces and states for each of the two countries. I hope you can see that this control could be expanded to include all the states, provinces, or any other geographical region for any of the numerous countries on the globe. But for simplicity's sake I limited it to these two countries.

<ToolboxData("<{0}:StateDropDownList runat=server></{0}:StateDropDownList>")> _
Public Class StateDropDownList
        Inherits DropDownList

        Protected Sub CreateDataSource()

            Me.Items.Clear()

            If IncludeUS And IncludeCanada Then
Me.Items.Add(New ListItem("Choose a State/Province", "0"))
            ElseIf IncludeUS And IncludeCanada = False Then
                Me.Items.Add(New ListItem("Choose a State", "0"))
            ElseIf IncludeUS = False And IncludeCanada Then
                Me.Items.Add(New ListItem("Choose a Province", "0"))
            End If

            If IncludeUS Then
                Me.Items.Add(New ListItem("Alabama", "AL"))
                Me.Items.Add(New ListItem("Alaska", "AK"))
                Me.Items.Add(New ListItem("Arizona", "AZ"))

'All 50 US States, but could add all terrirtories too!
                Me.Items.Add(New ListItem("Wisconsin", "WI"))
                Me.Items.Add(New ListItem("Wyoming", "WY"))
            End If

            If IncludeCanada Then
                Me.Items.Add(New ListItem("British Columbia", "BC"))
                Me.Items.Add(New ListItem("Manitoba", "MB"))
                Me.Items.Add(New ListItem("New Brunswick", "NB"))
                Me.Items.Add(New ListItem("Newfoundland and Labrador", "NL"))
                Me.Items.Add(New ListItem("Northwest Territories", "NT"))
                Me.Items.Add(New ListItem("Nova Scotia", "NS"))
                Me.Items.Add(New ListItem("Nunavut", "NU"))
                Me.Items.Add(New ListItem("Ontario", "ON"))
                Me.Items.Add(New ListItem("Prince Edward Island", "PE"))
                Me.Items.Add(New ListItem("Quebec", "QC"))
                Me.Items.Add(New ListItem("Saskatchewan", "SK"))
                Me.Items.Add(New ListItem("Yukon Territories", "YT"))
            End If

        End Sub

        <Category("Control Flags"), DefaultValue("True")> _
        Public Property IncludeCanada() As Boolean
            Get
                If Not IsNothing(ViewState("Canada")) Then
                    Return CBool(ViewState("Canada"))
                End If
                Return True
            End Get
Set(ByVal Value As Boolean)
                ViewState("Canada") = Value
            End Set
        End Property

        <Category("Control Flags"), DefaultValue("True")> _
        Public Property IncludeUS() As Boolean
            Get
                If Not IsNothing(ViewState("US")) Then
                    Return CBool(ViewState("US"))
                End If
                Return True
            End Get
            Set(ByVal Value As Boolean)
                ViewState("US") = Value
            End Set
        End Property

        Protected Overloads Overrides Sub OnInit(ByVal e As EventArgs)

            Me.DataTextField = "Text"
            Me.DataValueField = "Value"
            CreateDataSource()
            MyBase.OnInit(e)
        End Sub

End Class

There is also a DropDownList control that enables users to select their country. This is another custom Web Control that derives from the DropDownList control. The control's Init method is overridden to set the control's data source, a list of countries, and their corresponding codes:

<ToolboxData("<{0}:CountryDropDownList runat=server></{0}:CountryDropDownList>")> _
Public Class CountryDropDownList
        Inherits DropDownList

        Protected Sub CreateDataSource()

            Me.Items.Clear()

            Me.Items.Add(New ListItem("Choose a Country", "0"))
            Me.Items.Add(New ListItem("United States", "US"))
            Me.Items.Add(New ListItem("Canada", "CA"))
            Me.Items.Add(New ListItem("Afghanistan", "AF"))
            Me.Items.Add(New ListItem("Albania", "AL"))
            Me.Items.Add(New ListItem("Algeria", "DZ"))

'Long list of Countries here!
            Me.Items.Add(New ListItem("Zambia", "ZM"))
            Me.Items.Add(New ListItem("Zimbabwe", "ZW"))

        End Sub

        Protected Overloads Overrides Sub OnInit(ByVal e As EventArgs)

            Me.DataTextField = "Text"
            Me.DataValueField = "Value"
            CreateDataSource()
            MyBase.OnInit(e)

        End Sub

End Class

Persisting Properties through the New Control State

Because this control will be used in the administration area to read and edit the profile for any user, it needs a public property that stores the name of the user for whom you are querying. In ASP.NET 1.x, the typical way to make a property value persistent across postbacks is to save it in the control's ViewState collection, so that it is serialized into a blob of base-64 encoded text, together with all the other property values, and saved in the __VIEWSTATE HTML hidden field. This is better than session state because it doesn't use server resources to save temporary values because the values are saved as part of the overall page. The user won't see the hidden values, but they are there in the user's browser and he'll post them back to the server along with the other form data each time he does a postback. The problem with view state is that it can be disabled, by setting the page's or control's EnableViewState property to False. Disabling this feature is a common way to minimize the volume of data passed back and forth across the network. Unfortunately, controls that use view state to store values will not work correctly if view state is disabled on any particular host page. To resolve this issue, ASP.NET 2.0 introduced a new type of state called control state, which is a similar to view state, except that it's only related to a particular control, and it can't be disabled. Values that a control wants to save across postbacks can be saved in the control state, while values that are not strictly necessary can go into the view state. In reality, both categories of values will be serialized and saved into the same single __VIEWSTATE field, but the internal structure makes a difference between them. With the view state, you would just read and write the property value from and to the control's ViewState object from inside the property's accessor functions. To use control state, you need to do the following:

  1. Define the property so that it uses a private field to store the value.

  2. Call the parent page's RegisterRequiresControlState method, from the control's Init event handler. This notifies the page that the control needs to store some control state information.

  3. Override the control's SaveControlState method to create and return an array of objects you want to save in the control state. The array values consist of the property values you need to be persisted, plus the base class' control state (this always goes into the array's first slot).

  4. Override the LoadControlState method to unpack the input array of objects and initialize your properties' private fields with the values read from the array.

Following is the complete code needed to define and persist a control's UserName property:

Partial Class UserProfile
    Inherits System.Web.UI.UserControl

#Region " Properties "

    Public ReadOnly Property FirstName() As String
        Get
            Return FullName.Split(" ")(0)
        End Get
    End Property

    Public ReadOnly Property LastName() As String
        Get
            Return FullName.Split(" ")(FullName.Split(" ").Length - 1)
End Get
    End Property

    Public ReadOnly Property FullName() As String
        Get
            Return Request("fullname")
        End Get
    End Property

    Public ReadOnly Property EMail() As String
        Get
            Return Me.Request.QueryString("Email")
        End Get
    End Property

    Public ReadOnly Property DOB() As String
        Get
            Return Request("dob")
        End Get
    End Property

    Public ReadOnly Property Gender() As String
        Get
            Return Request("gender")
        End Get
    End Property

    Public ReadOnly Property PostCode() As String
        Get
            Return Request("postcode")
        End Get
    End Property

    Public ReadOnly Property Country() As String
        Get
            Return Request("country")
        End Get
    End Property

#End Region

    Private _userName As String = String.Empty
    Public Property Username() As String
        Get
            If String.IsNullOrEmpty(_userName) Then
                Return Helpers.CurrentUser.Identity.Name
            End If
            Return _userName
        End Get
        Set(ByVal value As String)
            _userName = value
        End Set
    End Property

    Protected Sub Page_Init(ByVal sender As Object, ByVal e As System.EventArgs)
Handles Me.Init
        Me.Page.RegisterRequiresControlState(Me)
    End Sub

    Protected Overrides Sub LoadControlState(ByVal savedState As Object)
        Dim ctlState As Object() = CType(savedState, Object())
        MyBase.LoadControlState(savedState)
        _userName = CStr(ctlState(1))
    End Sub

    Protected Overrides Function SaveControlState() As Object
        Dim ctlState() As Object
        ReDim ctlState(1) ' Initializes the array with 2 objects
        ctlState(0) = MyBase.SaveControlState()
        ctlState(1) = _userName
        Return ctlState
  End Function
End Class

This is somewhat more complicated than the older method of using the host page's view state, but you gain the advantage of independence from that page's configuration. You have to weigh the added complexity against the needs of your application. If you'll have a large application with many pages, it is probably wise to use control state because you can't be sure if one of the host pages might have view state disabled (in a large system it's almost guaranteed that some pages will have it disabled). Also, if your controls might be used by other applications within your company, or even other companies, you should definitely use control state to give you the added peace of mind to know that your controls will always work.

Loading and Editing a Profile

Now you can write some code for handling user profiles within a control. In the control's code-behind, you should handle the Load event to load the specified user's profile, so you can populate the various input controls with profile values. If no username is specified, the current user's profile will be loaded:

Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs)
Handles Me.Load
      If Not Me.IsPostBack Then

         ' if the UserName property contains an empty string, retrieve the profile
         ' for the current user, otherwise for the specified user
         Dim profile As ProfileBase = GetProfile()
         ddlSubscriptions.SelectedValue = profile.GetProfileGroup("Preferences")
.GetPropertyValue("Newsletter").ToString
         ddlLanguages.SelectedValue = profile.GetProfileGroup("Preferences")
.GetPropertyValue("Culture")
         txtFirstName.Text = profile.GetPropertyValue("FirstName")
         txtLastName.Text = profile.GetPropertyValue("LastName")

         If String.IsNullOrEmpty(Gender) Then
            ddlGenders.SelectedValue = Gender
         Else
            ddlGenders.SelectedValue = profile.GetPropertyValue("Gender")
End If

         If Not profile.GetPropertyValue("BirthDate") = DateTime.MinValue Then
            txtBirthDate.Text = profile.GetPropertyValue("BirthDate")
.ToShortDateString
         End If
         ddlOccupations.SelectedValue = profile.GetPropertyValue("Occupation")
         txtWebsite.Text = profile.GetPropertyValue("Website")
         txtStreet.Text = profile.GetProfileGroup("Address")
.GetPropertyValue("Street")
         txtCity.Text = profile.GetProfileGroup("Address").GetPropertyValue("City")
         txtPostalCode.Text =
profile.GetProfileGroup("Address").GetPropertyValue("PostalCode")
         ddlState.SelectedValue =
profile.GetProfileGroup("Address").GetPropertyValue("State")

         If String.IsNullOrEmpty(Country) Then
            ddlCountry.SelectedValue = Country
         Else
            ddlCountry.SelectedValue =
profile.GetProfileGroup("Address").GetPropertyValue("Country")
         End If

         txtPhone.Text = profile.GetProfileGroup("Contacts")
.GetPropertyValue("Phone")
         txtFax.Text = profile.GetProfileGroup("Contacts").GetPropertyValue("Fax")
         txtAvatarUrl.Text = GetAvatarURL()
         txtSignature.Text = profile.GetProfileGroup("Forum")
.GetPropertyValue("Signature")
      End If
End Sub

This control doesn't have a Submit button to initiate saving profile values, so create a public method named SaveProfile that the host page will call when needed:

Public Sub SaveProfile()
      ' if the UserName property contains an emtpy string, save the
current user's profile,
      ' othwerwise save the profile for the specified user
      Dim profile As ProfileBase = GetProfile()

      profile.GetProfileGroup("Preferences").SetPropertyValue("Newsletter",
CType([Enum].Parse( _
         GetType(SubscriptionType), ddlSubscriptions.SelectedValue),
SubscriptionType))
      profile.GetProfileGroup("Preferences").SetPropertyValue("Culture",
ddlLanguages.SelectedValue)
      profile.SetPropertyValue("FirstName", txtFirstName.Text)
      profile.SetPropertyValue("LastName", txtLastName.Text)
      profile.SetPropertyValue("Gender", ddlGenders.SelectedValue)
      If txtBirthDate.Text.Trim().Length > 0 Then
         profile.SetPropertyValue("BirthDate", DateTime.Parse(txtBirthDate.Text))
End If
      profile.SetPropertyValue("Occupation", ddlOccupations.SelectedValue)
      profile.SetPropertyValue("Website", txtWebsite.Text)
      profile.GetProfileGroup("Address").SetPropertyValue("Street", txtStreet.Text)
      profile.GetProfileGroup("Address").SetPropertyValue("City", txtCity.Text)
      profile.GetProfileGroup("Address").SetPropertyValue("PostalCode",
txtPostalCode.Text)
      profile.GetProfileGroup("Address").SetPropertyValue("State",
ddlState.SelectedValue)
      profile.GetProfileGroup("Address").SetPropertyValue("Country",
ddlCountry.SelectedValue)
      profile.GetProfileGroup("Contacts").SetPropertyValue("Phone", txtPhone.Text)
      profile.GetProfileGroup("Contacts").SetPropertyValue("Fax", txtFax.Text)
      profile.GetProfileGroup("Forum").SetPropertyValue("AvatarUrl",
txtAvatarUrl.Text)
      profile.GetProfileGroup("Forum").SetPropertyValue("Signature",
txtSignature.Text)
      profile.Save()

   End Sub

The Register Page

Users can create an account for themselves through the Register.aspx page that is linked just below the login box. This page uses the CreateUserWizard control described earlier. The first step is to create the account; the user interface for this is implemented by our custom template. The second step allows the user to fill in some profile settings, and uses the UserProfile control we just developed. The registration code that follows is pretty long, but it should be easy to follow without further comments:

<asp:CreateUserWizard runat="server" ID="CreateUserWizard1" AutoGeneratePassword
="False"
      ContinueDestinationPageUrl="~/Default.aspx"
         FinishDestinationPageUrl="~/Default.aspx">
      <WizardSteps>
         <asp:CreateUserWizardStep ID="CreateUserWizardStep1" runat="server">
            <ContentTemplate>
               <div class="sectiontitle">
                  Create your new account</div>
               <p>
               </p>
               <table cellpadding="2">
                  <tr>
                     <td style="width: 110px;" class="fieldname">
                        <asp:Label runat="server" ID="lblUserName"
AssociatedControlID="UserName" Text="Username:" /></td>
                     <td style="width: 300px;">
                        <asp:TextBox runat="server" ID="UserName" Width="100%" />
</td>
                     <td>
                        <asp:RequiredFieldValidator ID="valRequireUserName"
runat="server" ControlToValidate="UserName"
SetFocusOnError="true" Display="Dynamic" ErrorMessage
="Username is required."
                           ToolTip="Username is required." ValidationGroup=
"CreateUserWizard1">*</asp:RequiredFieldValidator>
                     </td>
                  </tr>
                  <tr>
                     <td class="fieldname">
                        <asp:Label runat="server" ID="lblPassword"
AssociatedControlID="Password" Text="Password:" /></td>
                     <td>
                        <asp:TextBox runat="server" ID="Password" TextMode=
"Password" Width="100%" /></td>
                     <td>
                        <asp:RequiredFieldValidator ID="valRequirePassword" runat=
"server" ControlToValidate="Password"
                           SetFocusOnError="true" Display="Dynamic" ErrorMessage=
"Password is required."
                           ToolTip="Password is required." ValidationGroup=
"CreateUserWizard1">*</asp:RequiredFieldValidator>
                        <asp:RegularExpressionValidator ID="valPasswordLength"
runat="server" ControlToValidate="Password"
                           SetFocusOnError="true" Display="Dynamic"
ValidationExpression="w{5,}" ErrorMessage="Password must be at least 5
characters long."
                           ToolTip="Password must be at least 5 characters long."
ValidationGroup="CreateUserWizard1">*</asp:RegularExpressionValidator>
                     </td>
                  </tr>
                  <tr>
                     <td class="fieldname">
                        <asp:Label runat="server" ID="lblConfirmPassword"
AssociatedControlID="ConfirmPassword"
                           Text="Confirm password:" /></td>
                     <td>
                        <asp:TextBox runat="server" ID="ConfirmPassword" TextMode=
"Password" Width="100%" /></td>
                     <td>
                        <asp:RequiredFieldValidator ID="valRequireConfirmPassword"
runat="server" ControlToValidate="ConfirmPassword"
                           SetFocusOnError="true" Display="Dynamic" ErrorMessage=
"Confirm Password is required."
                           ToolTip="Confirm Password is required." ValidationGroup
="CreateUserWizard1">*</asp:RequiredFieldValidator>
                        <asp:CompareValidator ID="valComparePasswords" runat=
"server" ControlToCompare="Password"
                           SetFocusOnError="true" ControlToValidate=
"ConfirmPassword" Display="Dynamic"
                           ErrorMessage="The Password and Confirmation Password
must match." ValidationGroup="CreateUserWizard1">*</asp:CompareValidator>
                     </td>
                  </tr>
<tr>
                     <td class="fieldname">
                        <asp:Label runat="server" ID="lblEmail"
AssociatedControlID="Email" Text="E-mail:" /></td>
                     <td>
                        <asp:TextBox runat="server" ID="Email" Width="100%" Text=
'<%# Email %>' ></asp:TextBox></td>
                     <td>
                        <asp:RequiredFieldValidator ID="valRequireEmail" runat=
"server" ControlToValidate="Email"
                           SetFocusOnError="true" Display="Dynamic" ErrorMessage=
"E-mail is required." ToolTip="E-mail is required."
                           ValidationGroup="CreateUserWizard1">*
</asp:RequiredFieldValidator>
                        <asp:RegularExpressionValidator runat="server" ID=
"valEmailPattern" Display="Dynamic"
                           SetFocusOnError="true" ValidationGroup=
"CreateUserWizard1" ControlToValidate="Email"
                           ValidationExpression="w+([-+.']w+)*@w+([-.]w+)*.
w+([-.]w+)*" ErrorMessage="The e-mail address you specified is not
well-formed.">*
</asp:RegularExpressionValidator>
                     </td>
                  </tr>
                  <tr>
                     <td class="fieldname">
                        <asp:Label runat="server" ID="lblQuestion"
AssociatedControlID="Question" Text="Security question:" /></td>
                     <td>
                        <asp:TextBox runat="server" ID="Question" Width=
"100%" /></td>
                     <td>
                        <asp:RequiredFieldValidator ID="valRequireQuestion" runat=
"server" ControlToValidate="Question"
                           SetFocusOnError="true" Display="Dynamic" ErrorMessage=
"Security question is required."
                           ToolTip="Security question is required."
ValidationGroup="CreateUserWizard1">*</asp:RequiredFieldValidator>
                     </td>
                  </tr>
                  <tr>
                     <td class="fieldname">
                        <asp:Label runat="server" ID="lblAnswer"
AssociatedControlID="Answer" Text="Security answer:" /></td>
                     <td>
                        <asp:TextBox runat="server" ID="Answer" Width=
"100%" /></td>
                     <td>
                        <asp:RequiredFieldValidator ID="valRequireAnswer" runat=
"server" ControlToValidate="Answer"
                           SetFocusOnError="true" Display="Dynamic" ErrorMessage=
"Security answer is required."
ToolTip="Security answer is required." ValidationGroup=
"CreateUserWizard1">*</asp:RequiredFieldValidator>
                     </td>
                  </tr>
                  <tr>
                     <td colspan="3" style="text-align: right;">
                        <asp:Label ID="ErrorMessage" SkinID="FeedbackKO" runat=
"server" EnableViewState="False"></asp:Label>
                     </td>
                  </tr>
               </table>
               <asp:ValidationSummary ValidationGroup="CreateUserWizard1" ID=
"ValidationSummary1"
                  runat="server" ShowMessageBox="True" ShowSummary="False" />
            </ContentTemplate>
         </asp:CreateUserWizardStep>
         <asp:WizardStep ID="WizardStep1" runat="server" Title="Set preferences">
            <div class="sectiontitle">
               Set-up your profile</div>
            <p>
            </p>
            All settings in this section are optional. The address information is required only if you want to order products from our e-store. However, we ask you to fill in these details in all cases, because they help us know our target audience, and improve the site and its contents accordingly. Thank you for your cooperation!
            <p>
               &nbsp;</p>
            <uc1:UserProfile ID="UserProfile1" runat="server" />
         </asp:WizardStep>
         <asp:CompleteWizardStep ID="CompleteWizardStep1" runat="server">
         </asp:CompleteWizardStep>
      </WizardSteps>
      <MailDefinition BodyFileName="~/RegistrationMail.txt" From=
"[email protected]"
         Subject="The Beer House: Your registration ">
      </MailDefinition>
   </asp:CreateUserWizard>

The CreateUserWizard's <MailDefinition> section contains all the settings needed for sending the confirmation mail. The most interesting property is BodyFileName, which references a disk file containing the mail's body text. In this file, you will typically write a welcome message, and maybe the credentials used to register, so that users will be reminded of the username and password that they selected for your site Following is the content of RegistrationMail.txt that specifies the body text:

Thank you for registering to The Beer House website! Following are the
credentials you selected for logging in:
UserName: <% UserName %>
Password: <% Password %>

See you online!
- The Beer House Team

This example is e-mailing the username and password because this is a low-risk site, and we chose user-friendliness over the tightest possible security. Besides, I wanted to demonstrate how to use placeholders in the body text file (<% UserName %> and <% Password %>). For serious e-commerce sites or in situations where your company (or your client) doesn't approve of e-mailing usernames and passwords, do not follow this example!

The page's code-behind file is impressively short: you only need to handle the wizard's FinishButtonClick event and have it call the UserProfile's SaveProfile method. The code that implements the registration it not placed here because it's handled by the user control:

Protected Sub CreateUserWizard1_FinishButtonClick(ByVal sender As Object,
ByVal e As System.Web.UI.WebControls.WizardNavigationEventArgs)
Handles CreateUserWizard1.FinishButtonClick
        UserProfile1.SaveProfile()
End Sub

Figure 4-15 shows the registration page on the first step of the wizard.

Figure 4-15

Figure 4.15. Figure 4-15

Figure 4-16 shows the second step, enabling users to set up their profile.

Figure 4-16

Figure 4.16. Figure 4-16

The PasswordRecovery Page

Under the login box is a link to PasswordRecover.aspx, which allows a user to recover a forgotten password, by sending an e-mail with the credentials. The page uses a PasswordRecovery control with a custom template for the two steps (entering the username and answering the secret question). The code follows:

<%@ Page Language="VB" MasterPageFile="~/Template.master" AutoEventWireup="true"
   CodeFile="PasswordRecovery.aspx.vb" Inherits="PasswordRecovery"
   Title="The Beer House - Password Recovery" %>
<asp:Content ID="MainContent" ContentPlaceHolderID="MainContent" Runat="Server">
<div class="sectiontitle">Recover your password</div>
<p></p>If you forgot your password, you can use this page to have it sent to you
by e-mail. <p></p>
<asp:PasswordRecovery ID="PasswordRecovery1" runat="server">
   <UserNameTemplate>
      <div class="sectionsubtitle">Step 1: enter your username</div>
      <p></p>
      <table cellpadding="2">
         <tr>
            <td width="80" class="fieldname">Username:</td>
            <td width="300">
               <asp:TextBox ID="UserName" runat="server" Width="100%" />
</td>
            <td>
               <asp:RequiredFieldValidator ID="valRequireUserName" runat="server"
                  ControlToValidate="UserName" SetFocusOnError="true"
                  Display="Dynamic" ErrorMessage="Username is required."
                  ValidationGroup="PasswordRecovery1">*
               </asp:RequiredFieldValidator>
            </td>
         </tr>
         <td colspan="3" align="right">
            <asp:Label ID="FailureText" runat="server" SkinID="FeedbackKO"
               EnableViewState="False" />
            <asp:Button ID="SubmitButton" runat="server" CommandName="Submit"
               Text="Submit" ValidationGroup="PasswordRecovery1" />
         </td>
      </table>
   </UserNameTemplate>
   <QuestionTemplate>
      <div class="sectionsubtitle">Step 2: answer the following question</div>
      <p></p>
      <table cellpadding="2">
         <tr>
            <td width="80" class="fieldname">Username:</td>
            <td width="300">
               <asp:Literal ID="UserName" runat="server" />
            </td>
            <td></td>
         </tr>
         <tr>
            <td class="fieldname">Question:</td>
            <td><asp:Literal ID="Question" runat="server"></asp:Literal></td>
            <td></td>
         </tr>
         <tr>
            <td class="fieldname">Answer:</td>
            <td><asp:TextBox ID="Answer" runat="server" Width="100%" />
            </td>
            <td>
               <asp:RequiredFieldValidator ID="valRequireAnswer" runat="server"
                  ControlToValidate="Answer" SetFocusOnError="true"
                  Display="Dynamic" ErrorMessage="Answer is required."
                  ValidationGroup="PasswordRecovery1">*
               </asp:RequiredFieldValidator>
            </td>
         </tr>
         <tr>
            <td colspan="3" align="right">
               <asp:Label ID="FailureText" runat="server"
                  SkinID="FeedbackKO" EnableViewState="False" />
               <asp:Button ID="SubmitButton" runat="server" CommandName="Submit"
                  Text="Submit" ValidationGroup="PasswordRecovery1" />
            </td>
</tr>
      </table>
   </QuestionTemplate>
   <SuccessTemplate>
      <asp:Label runat="server" ID="lblSuccess" SkinID="FeedbackOK"
         Text="Your password has been sent to you." />
   </SuccessTemplate>
   <MailDefinition
      BodyFileName="~/PasswordRecoveryMail.txt"
      From="[email protected]"
      Subject="The Beer House: your password">
   </MailDefinition>
</asp:PasswordRecovery>
</asp:Content>

The body of the mail sent with the credentials is almost the same as the previous one, so we won't show it again here. Figure 4-17 shows a couple of screenshots for the two-step password recovery process.

Figure 4-17

Figure 4.17. Figure 4-17

The EditProfile Page

Once users log in, they can go to the EditProfile.aspx page, linked in the top-right corner of any page, and change their password or other profile settings. The password-changing functionality is implemented by way of a custom ChangePassword control, and the profile settings functionality is handled by the UserProfile control we already developed. Following is the code for the EditProfile.aspx file (with some layout code removed for clarity and brevity):

<div class="sectiontitle">
      Change your password</div>
   <p>
   </p>
   <asp:ChangePassword ID="ChangePassword1" runat="server">
      <ChangePasswordTemplate>
         <table cellpadding="2">
            <tr>
               <td style="width: 110px;" class="fieldname">
                  <asp:Label runat="server" ID="lblCurrentPassword"
AssociatedControlID="CurrentPassword"
                     Text="Current password:" />
               </td>
               <td style="width: 300px;">
                  <asp:TextBox ID="CurrentPassword" TextMode="Password"
 runat="server" Width="100%"></asp:TextBox>
               </td>
               <td>
                  <asp:RequiredFieldValidator ID="valRequireCurrentPassword"
 runat="server" ControlToValidate="CurrentPassword"
                     SetFocusOnError="true" Display="Dynamic"
ErrorMessage="Password is required."
                     ToolTip="Password is required."
ValidationGroup="ChangePassword1">*</asp:RequiredFieldValidator>
               </td>
            </tr>
            <tr>
               <td class="fieldname">
                  <asp:Label runat="server" ID="lblNewPassword"
 AssociatedControlID="NewPassword" Text="New password:" />
               </td>
               <td>
                  <asp:TextBox ID="NewPassword" TextMode="Password"
runat="server" Width="100%"></asp:TextBox>
               </td>
               <td>
                  <asp:RequiredFieldValidator ID="valRequireNewPassword"
runat="server" ControlToValidate="NewPassword"
                     SetFocusOnError="true" Display="Dynamic"
ErrorMessage="New Password is required."
                     ToolTip="New Password is required."
ValidationGroup="ChangePassword1">*</asp:RequiredFieldValidator>
                  <asp:RegularExpressionValidator ID="valPasswordLength"
runat="server" ControlToValidate="NewPassword"
                     SetFocusOnError="true" Display="Dynamic"
ValidationExpression="w{5,}" ErrorMessage="New Password must be at
least 5 characters long."
                     ToolTip="New Password must be at least 5 characters long."
ValidationGroup="ChangePassword1">*</asp:RegularExpressionValidator>
               </td>
            </tr>
<tr>
               <td class="fieldname">
                  <asp:Label runat="server" ID="lblConfirmPassword"
AssociatedControlID="ConfirmNewPassword"
                     Text="Confirm password:" />
               </td>
               <td>
                  <asp:TextBox ID="ConfirmNewPassword" TextMode="Password" runat=
"server" Width="100%"></asp:TextBox>
               </td>
               <td>
                  <asp:RequiredFieldValidator ID="valRequireConfirmNewPassword"
runat="server" ControlToValidate="ConfirmNewPassword"
                     SetFocusOnError="true" Display="Dynamic" ErrorMessage=
"Confirm Password is required."
                     ToolTip="Confirm Password is required." ValidationGroup=
"ChangePassword1">*</asp:RequiredFieldValidator>
                  <asp:CompareValidator ID="valComparePasswords" runat="server"
ControlToCompare="NewPassword"
                     ControlToValidate="ConfirmNewPassword" SetFocusOnError=
"true" Display="Dynamic"
                     ErrorMessage="The Confirm Password must match the New
Password entry." ValidationGroup="ChangePassword1">*</asp:CompareValidator>
               </td>
            </tr>
            <td colspan="3" style="text-align: right;">
               <asp:Label ID="FailureText" runat="server" SkinID=
"FeedbackKO" EnableViewState="False" />
               <asp:Button ID="ChangePasswordPushButton" runat="server"
CommandName="ChangePassword"
                  Text="Change Password" ValidationGroup="ChangePassword1" />
            </td>
         </table>
         <asp:ValidationSummary runat="server" ID="valChangePasswordSummary"
 ValidationGroup="ChangePassword1"
            ShowMessageBox="true" ShowSummary="false" />
      </ChangePasswordTemplate>
      <SuccessTemplate>
         <asp:Label runat="server" ID="lblSuccess" SkinID="FeedbackOK" Text=
"Your password has been changed successfully." />
      </SuccessTemplate>
      <MailDefinition BodyFileName="~/ChangePasswordMail.txt" From=
"[email protected]"
         Subject="The Beer House: password changed">
      </MailDefinition>
   </asp:ChangePassword>
   <p>
   </p>
   <hr style="width: 100%; height: 1px;" noshade="noshade" />
   <div class="sectiontitle">
      Change your profile</div>
   <p>
   </p>
   All settings in this section are required only if you want to order products from our e-store. However, we ask you to fill in these details in all cases,
because they help us know our target audience, and improve the site and its contents accordingly. Thank you for your cooperation!
   <p>
   </p>
   <uc1:UserProfile ID="UserProfile1" runat="server" />
   <table cellpadding="2" style="width: 525px;">
      <tr>
         <td style="text-align: right;">
            <asp:Label runat="server" ID="lblFeedbackOK" SkinID="FeedbackOK" Text
="Profile updated successfully"
               Visible="false" />
            <asp:Button runat="server" ID="btnUpdate" ValidationGroup=
"EditProfile" Text="Update Profile"
               OnClick="btnUpdate_Click" />
         </td>
      </tr>
   </table>

In the code-behind, you don't have to do anything except handle the Update Profile Submit button's Click event, where you call the UserProfile control's SaveProfile method, and then show a confirmation message:

Protected Sub btnUpdate_Click(ByVal sender As Object, ByVal e As System.EventArgs)
Handles btnUpdate.Click
      UserProfile1.SaveProfile()
      lblFeedbackOK.Visible = True
End Sub

Figure 4-18 shows part of this page at runtime.

Figure 4-18

Figure 4.18. Figure 4-18

The last thing to do on this page is to ensure that anonymous users — who, of course, don't have a password or profile to change — cannot access this page. To do this, you can create a <location> section for this page in the root web.config and then write an <authorization> subsection that denies access to the anonymous user (identified by "?") and grant access to everyone else. This is the code you should add to the web.config file:

<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0">
   <system.web> <!-- some settings here...--> </system.web>
   <location path="EditProfile.aspx">
      <system.web>
         <authorization>
            <deny users="?" />
            <allow users="*" />
         </authorization>
      </system.web>
   </location>
</configuration>

Creating an AJAX Login Dialog

The access denied page is great for protecting resources from users that do not have permission to view them, but in today's competitive market, richer user experiences are driving competition. With the use of some CSS, AJAX, and the ASP.NET AJAX AuthenticateService, we can add a rich login experience that offers both username/password and OpenId authentication. This solution consists of two DIV tags that are hidden by default and located at the bottom of the page's markup. When the user clicks the Login link located in the page's header, the faux login dialog is rendered on top of the page. The PopUpBackGround DIV has an opaque gray background that simulates the feel of the site being disabled and even a sense of security. This looks somewhat similar to the experience a user has when they use CardSpace. Since the DIV is set to expand to the full size of the browser window, the user cannot click or interact with anything on the page, also fostering this sense of security.

Layered on top of the PopUpBackGround DIV is the LoginDlg DIV. This DIV contains the controls needed to collect the user's credentials or OpenId URL. It is composed of some HTML markup and HTML input elements. Raw HTML elements are used because Web Controls are much more complicated to work with when AJAX is involved because of the names generated by the ASP.NET framework on the server. While working with Web Controls is possible, it is very complicated compared to using pure HTML elements.

<div id="PopUpBackGround">
   </div>
   <div id="LoginDlg">
      <div id="dLoginError" />
      <div id="AnonymousView">
         <img src="~/images/lock.gif" runat="server" hspace="5" vspace="12" align
="left" alt="Login to the Beer House." />
         Username:
         <input id="txtUsername" type="text" onkeypress="ValidateLoginValues();"
/><br />
         Password:
         <input id="pwdPassword" type="password" onkeypress=
"ValidateLoginValues();" /><br />
<input id="chkRememberMe" type="checkbox" />Remember Me<br />
         <input id="btnCancel" type="button" value="Cancel" /><input id=
"btnLogIn" type="button"
            value="Login" /><br />
         <a href="~/Register.aspx" runat="server" class=
"LoginDialog">Register</a>&nbsp;
         <a href="~/PasswordRecovery.aspx" runat="server" class="LoginDialog">
Retrieve Password</a><br />
         <div id="dOpenId">
            Login with your OpenId.<br />
            <asp:TextBox runat="server" type="text" ID="txtOpenId" class=
"openid_identifier_dlg" />
            <asp:Button runat="server" ID="btnLoginOpenId" Text="Go" />
         </div>
      </div>
      <div id="LoggedInView" style="display: none;">
         Logged in.<br />
        <input id="btnLogOut" type="button" value="Log Out" />
      </div>
   </div>

<div id="dLoginLinks">
<div id="dLoginMenu">

<a id="btnDisplayLogin" href="javascript:DisplayLoginHandler();" class=
"LoginLink">Login</a></div>
<div id="dLogoutMenu">
         <div id="dUserName"></div>

<a id="btnDisplayLogout" href="javascript:logoutHandler();" class=
"LoginLink">Log Out</a>
</div>
</div>

The corresponding style properties:

/* Modal Dialog */
#PopUpBackGround
{
    z-index: 1400;
    position: fixed;
    top: 0;
    left: 0;
    height: 100%;
    width: 100%;
    background-color: #333333;
    filter: alpha(opacity=70);
    opacity: 0.7;
    display:none;
}

#LoginDlg
{
    background-color : White;
left: 275px;
    top: 150px;
    width:325px;
    height : 190px;
    z-index : 1500;
    position :fixed;
    border: thin ridge #CC6600;
    padding: 2px 2px 2px 2px;
    display :none;
}

The username and password input fields both call the ValidateLoginValues function as keys are pressed. This does a couple of things; it checks to see if the user pressed the Enter key and verifies that there is text in each field before the authentication occurs. Users typically try pressing the Enter key to submit the page instead of clicking buttons, so handling this action is important.

function ValidateLoginValues() {

   if (event.which || event.keyCode) {
      if ((event.which == 13) || (event.keyCode == 13)) {

        Username = $get('txtUsername'),
        Password = $get('pwdPassword'),

        if (Username.value.length > 0 && Password.value.length > 0) {

           ProcessEnter('btnLogIn'),

        } else {

           alert('You must enter both a Username and a Password to authenticate.'),

        }
     }
   }
}

The ProcessEnter function simulates the Submit button being pressed, thus initiating the authentication process.

function ProcessEnter(btnSubmit) {

   if (event.which || event.keyCode) {
      if ((event.which == 13) || (event.keyCode == 13)) {
         $get(btnSubmit).click();
         return false;
      }
   } else {
      return true;
   };

}

The reason that you do not have to explicitly hook up a method to the Submit button's click event is it was registered in the site's JavaScript file when it was loaded. The ASP.NET AJAX framework has the concept of wiring event handlers in a very similar fashion as we do in server-side code. The $addHandler method takes the element ID, the client-side event name and the function to associate to manage this for us. Here are some examples from the Beer House's TBH.js file:

// Hook up the click events of the log in and log out buttons.
if ($get('btnLogIn') != null) {
    $addHandler($get('btnLogIn'), 'click', loginHandler);
}
if (null != $get('btnLogOut')) {
    $addHandler($get('btnLogOut'), 'click', logoutHandler);
}
if (null != $get('btnCancel')) {
    $addHandler($get('btnCancel'), 'click', CancelLoginHandler);
}

Here are the corresponding client-side functions:

var ssa = Sys.Services.AuthenticationService;

function CancelLoginHandler() {
    $get('LoginDlg').style.display = 'none';
    $get('PopUpBackGround').style.display = 'none';
}

function loginHandler() {
    var username = $get('txtUsername').value;
    var password = $get('pwdPassword').value;
    var isPersistent = $get('chkRememberMe').checked;
    var customInfo = null;
    var redirectUrl = null;
    // Log them in.
    ssa.login(username,
                          password,
                          isPersistent,
                          customInfo,
                          redirectUrl,
                          onLoginComplete,
                          onError);

}

function logoutHandler() {
    // Log them out.
    var redirectUrl = null;
    var userContext = null;
    ssa.logout(redirectUrl,
                           onLogoutComplete,
                           onError,
                           userContext);
}

The loginHandler function gets the username and password supplied by the user and calls the Authentication Services Login method. It passes the name of the function to call upon a completed login (onLoginComplete) and when an error occurs (onError).

The onLoginComplete function checks the result, meaning whether authentication was successful, and changes the on-screen display of different sections of the page for an authenticated user. If the authentication failed, a simple alert dialog is displayed to inform the user that the credentials failed authentication.

function onLoginComplete(result, context, methodName) {

    if (result) {
        // Logged in.  Hide the anonymous view.
        $get('LoggedInView').style.display = 'block';
        $get('AnonymousView').style.display = 'none';
        $get('LoginDlg').style.display = 'none';
        $get('PopUpBackGround').style.display = 'none';

        $get('dLoginMenu').style.display = 'none';
        $get('dLogoutMenu').style.display = 'block';

        LoadProfile();
        loadRoles();


    } else {
        alert('Sorry those Credentials did not work.'),
        $get('txtUsername').value = '';
        $get('pwdPassword').value = '';

    }
}

Similarly, when a user logs out of the site, the visible display of the page is changed.

function onLogoutComplete(result, context, methodName) {
    $get('dLoginMenu').style.display = '';
    $get('dLogoutMenu').style.display = 'none';
    $get("adminView").style.display = 'none';
}

A simple error alert dialog is displayed if an exception was thrown. This method passes the exception message to the alert dialog. That's not necessarily the best idea for security or user-friendliness, but it serves the purpose for now.

function onError(error, context, methodName) {
    alert(error.get_message());
}

If you review the onLoginComplete function, you will see that it calls the LoadProfile function. This function loads the profile service and provides access to the user's associated profile settings. Once the profile is loaded, the site displays a simple welcome message containing the user's first name.

var profileService = Sys.Services.ProfileService;
var propertyNames = new Array();

// Loads the profile of the current
// authenticated user.
function LoadProfile() {

    Sys.Services.ProfileService.load(null,
    OnLoadCompleted, OnProfileFailed, null);

}

// Reads the profile information and displays it.
function OnLoadCompleted(numProperties, userContext, methodName) {

    firstName = Sys.Services.ProfileService.properties.FirstName;

    if (firstName.length > 0){
        alert("Welcome " + firstName);
    }

}

The ShowProfileInfo function checks to see if a full name can be displayed; if so, it sets the innerHTML of the dUserName DIV, located in the site's header, to a link for the user to edit his profile with his name hyperlinked. If the full name cannot be composed, then the user simply sees an Update Profile link in the same location.

function ShowProfileInfo() {

   if (null != $get('dUserName')) {

      if (undefined != Sys.Services.ProfileService.properties.FirstName &&
         undefined != Sys.Services.ProfileService.properties.LastName) {

         $get('dUserName').innerHTML =
            '<a href="editprofile.aspx">' +

         Sys.Services.ProfileService.properties.FirstName + ' ' +
         Sys.Services.ProfileService.properties.LastName + '</a><BR/>' +
         '<a href="ProfileEdit.aspx">Edit Profile</a>';


      } else {
         $get('dUserName').innerHTML =
         '<a href="editprofile.aspx">Update Profile</a><BR/>'
      }
   }

}

Another function called by onLoginComplete is the loadRoles. It calls RoleService to load the role information for the site. Like the authentication and profile services, it also has on complete, failed, and error method callbacks that need to be set.

function loadRoles() {
   Sys.Services.RoleService.load(onLoadRolesCompleted, onLoadRolesFailed, null);
}

function onLoadRolesCompleted(result, userContext, methodName) {
   if (Sys.Services.RoleService.isUserInRole("Administrators")) {
$get("adminView").style.display = '';
      $get("adminView").innerHTML = "Hello Admin";
   }
}

function onLoadRolesFailed(error, userContext, methodName) {
   alert(error.get_message());
}

If the user wants to authenticate him- or herself via OpenId, this is handled by calling the GoOpenId function. This will open the user's OpenId URL so he can be authenticated.

function GoOpenId() {
   OpenIdURL = $get(txtOpenId);
   window.open(OpenIdURL.value, 'MyOpenId', null, null);
}

Persisting the Favorite Theme between Sessions

In Chapter 2, we created a base page class from which all other pages inherit. One of the tasks of this class is to set the page's Theme property according to what is stored in a Session variable, which tells us what theme the user selected from the Themes drop-down list near the top-right corner. The problem with Session variables is that they only exist as long as the user's session is active, so we'll have to store this value in a persistent location. Thankfully, it turns out that we have a great place to store this — in the user profile. The following code highlights the changes done to the base page to allow us to save the Profile.Preferences.Theme property in the user's profile, and because we're putting it in the base page class, we will not have to do this in all the other pages:

Private Sub Page_PreInit(ByVal sender As Object, ByVal e As System.EventArgs)
Handles Me.PreInit

Dim id As String = Helpers.ThemesSelectorID


If String.IsNullOrEmpty(id) = False Then

            ' if this is a postback caused by the theme selector's dropdownlist,
            ' retrieve the selected theme and use it for the current page request

If Me.Request.Form("__EVENTTARGET") = id AndAlso _
                  Not String.IsNullOrEmpty(Me.Request.Form(id)) Then
               Me.Theme = Me.Request.Form(id)
               Helpers.SetProfileTheme(profile, Me.Theme)
            Else
               ' if not a postback, or a postback caused by controls other then
               ' the theme selector,
               ' set the page's theme with the value found in the user's profile,
               ' if present
               If Not String.IsNullOrEmpty(Helpers.GetProfileTheme(profile)) Then
                  Me.Theme = Helpers.GetProfileTheme(profile)
               End If
End If
End If

End Sub

As mentioned before, we must also handle the Profile_MigrateAnonymous global event, so that when an anonymous user selects a theme and then logs in, his or her favorite theme will be migrated from the anonymous profile to the authenticated one. After this, the old profile can be deleted from the data store, and the anonymous ID can be deleted as well. Following is the complete code:

Sub Profile_MigrateAnonymous(ByVal sender As Object, ByVal e As
ProfileMigrateEventArgs)
      ' get a reference to the previously anonymous user's profile
      Dim anonProfile As ProfileBase = ProfileBase.Create(e.AnonymousID)
      ' if set, copy its Theme to the current user's profile
      If Not String.IsNullOrEmpty(anonProfile.GetProfileGroup("Preferences")
.GetPropertyValue("Theme")) Then
         Profile.Preferences.Theme = anonProfile.GetProfileGroup("Preferences")
.GetPropertyValue("Theme")
      End If
      ' delete the anonymous profile
      ProfileManager.DeleteProfile(e.AnonymousID)
      AnonymousIdentificationModule.ClearAnonymousIdentifier()
End Sub

The Administration Section

Now that the end-user part of the work is done, we only have the administration section to complete. The ~/Admin/Default.aspx page linked to the Admin menu item is the administrator's home page, which contains links to all the administrative functions. First, we will develop the page used to manage users and their profiles. To protect all the pages placed under the Admin folder against unauthorized access, you should add a web.config file under this ~/Admin folder, and write an <authorization> section that grants access to the Administrators role and denies access to everyone else. In the Design section is a sample snippet to demonstrate this.

The Admin Master Page

In Chapter 2, we discussed the use of master pages and CSS to create a consistent user interface for the public facing website. The same techniques are just as important for the site's administration. The administrative section has its own dedicated master page, which primarily differs from the public site's layout by having an accordion panel to use for navigation in the admin area.

The Accordion control is actually a part of the ASP.NET AJAX control toolkit, a set of free controls produced by Microsoft that extend the ASP.NET AJAX framework (www.codeplex.com/AjaxControlToolkit). The version I am using with this site is 3.0.20820; you should check the project's page to use the most current bits as they change from time to time.

The Accordion control allows multiple panes to be defined, but only one is visible at a time. Each of the panels is collapsible, meaning that they are neatly hidden from the user when they are not needed.

The user can select the pane he or she wants to see by clicking on the visible header. In the Beer House administrative section, the header defines a module's administrative pages and tasks.

<ajaxToolkit:Accordion ID="aAdminMenu" runat="server" FadeTransitions="false"
 FramesPerSecond="100"
 TransitionDuration="250" CssClass="accordion" HeaderCssClass="header"
ContentCssClass="content" RequireOpenedPane="True" AutoSize="None">
<Panes>

<ajaxToolkit:AccordionPane ID="apArticles" runat="server">

<!-- Content (ListView) -->

</ajaxToolkit:AccordionPane>

<!-- Other Panes -->
</Panes>
</ajaxToolkit:Accordion>

Inside each pane is a dedicated ListView containing links to the administrative duties of the module. Typically, this includes links to manage any items as well as adding new items. The lists are actually manually built by calling a series of methods in a AdminMenuItems class, but you could drive them from a table or configuration setting. Considering the size and scope of the Beer House site, it is simpler to create a class to manually build the navigation. Each of the methods in the AdminMenuItems class is a shared method that builds an ArrayList of AdminMenuItems. An AdminMenuItem contains a MenuName, ImageURL and URL property. The MenuName is the test displayed in the hyperlink to the page. The ImageURL is the path to an associated image icon. The URL is the path to the actual page to be loaded.

Public Shared Function FetchSecurityMenuItems() As ArrayList

      Dim userItems As New ArrayList()
      userItems.Add(New AdminMenuItem("Users", "ManageUsers.aspx"))
      userItems.Add(New AdminMenuItem("New User", "AddEditUser.aspx"))
      userItems.Add(New AdminMenuItem("Roles", "ManageRoles.aspx"))
      userItems.Add(New AdminMenuItem("New Role", "AddEditRole.aspx"))

      Return userItems

End Function

The AdminMenuItem has several overloaded constructors; I am using the version that accepts the MenuName and URL. This creates a simple text-based navigation list. The FetchSecurityMenuItems method returns an ArrayList of AdminMenuItems that are ultimately bound to the ListView in the security panel of the Accordion control.

Private Sub BindNavItems()

      'Other Admin Menu ListView binding code

      lvSecurity.DataSource = AdminMenuItems.FetchSecurityMenuItems
      lvSecurity.DataBind()

End Sub

Each AccordionPane in the Accordion has a header that list what the pane contains, for example the Security pane holds links to manage users and roles. The Header template defines the content listed in the pane's header, which is always visible. When the user clicks on the header, it will expand if it is not the currently visible pane, and the open pane will contract.

The Content template defines the elements contained in the expanded pane. For the administration navigation, this means the ListView bound to the module's admin links. Each of the ListView holds an unordered list (<UL>), with each list item (<LI>) containing the navigational link.

<ajaxToolkit:AccordionPane ID="apSecurity" runat="server">
<Header>

<div>
               <span>Security</span>

</div>
</Header>
<Content>

<asp:ListView ID="lvSecurity" runat="server">

<LayoutTemplate>

   <ul>

      <li id="itemPlaceholder" runat="server" />

   </ul>

</LayoutTemplate>

<ItemTemplate>

   <li><a href="<%#Eval("URL")%>">

      <%#Eval("MenuName")%></a></li>

</ItemTemplate>

</asp:ListView>
</Content>
</ajaxToolkit:AccordionPane>

The Accordion completes its visual look by applying a series of styles that assigns background images and font colors.

/* Accordion */

.accordion
{
    font-size:8.5pt;
    font-family:Tahoma;
    background:url(images/item_bg.gif);
}
.accordion .header
{
    cursor: pointer;
    background: url(images/hd_bg.gif) repeat-x;
    border-bottom: solid 1px #57566f;
    border-bottom-color: #800000;
}
.accordion .header DIV
{
    cursor:pointer;
    height:30px;
    padding-left:40px;
    background-color:Transparent;
    background-position:center left;
    background-repeat:no-repeat;
}
.accordion .header SPAN
{
    cursor:pointer;
    font-weight:bold;
    display:block;
    padding-top:8px;
    color:#fff;
}
.accordion .header:hover
{
    cursor:pointer;
    height:31px;
    background-color:Transparent;
    background:url(images/hd_hover_bg.gif) repeat-x;
    border-bottom:none;
}
.accordion UL
{
    padding:0;
    margin:5px 5px 5px 15px;
    list-style-type:none;
}
.accordion LI
{
    background-color:Transparent;
    background-repeat:no-repeat;
    background-position:left center;
    vertical-align:middle;
    padding:6px 5px 6px 25px;
    cursor:hand;
}
.accordion LI DIV
{
    padding-left: 10px;
    color: #993333;
    cursor: hand;
}
.accordion LI:hover
{
    text-decoration:underline;
}

The balance of the Admin master page is very similar to the public master page. The menu links refer to the primary management pages for the related modules instead of the public pages.

The ManageUsers Administrative Page

This page's user interface can be divided into three parts:

  1. The first part tells the administrator the number of registered users and how many of them are currently online.

  2. The second part provides controls for finding and listing the users. There is an "alphabet bar" with all the letters of the alphabet; when one is clicked, a grid is filled with all the users whose names start with that letter. Additionally, an All link is present to show all users with a single click. The search functionality allows administrators to search for users by providing a partial username or e-mail address.

  3. The third part of the page contains a grid that lists users and some of their properties.

The following code provides the user interface for the first two parts:

<asp:UpdatePanel runat="server" ID="uppnlUsers">
                  <ContentTemplate>
                     <table style="width: 95%">
                        <tr>
                           <td>
                              <p>
                              </p>
                              <b>- Total registered users:
                                 <asp:Literal runat="server" ID="lblTotUsers" />
<br />
                                 - Users online now:
                                 <asp:Literal runat="server" ID="lblOnlineUsers" />
</b>
                              <p>
                              </p>
                              Click one of the following link to display all
users whose name begins with that
                              letter:
                              <p>
                              </p>
                              <asp:ListView runat="server" ID="lvAlphabet">
                                 <LayoutTemplate>
                                    <span runat="server" id="itemPlaceHolder" />
                                 </LayoutTemplate>
                                 <ItemTemplate>
                                    <span>
                                       <asp:LinkButton ID="LinkButton1" runat=
"server" Text='<%# Container.DataItem %>'
                                          CommandArgument='<%# Container.DataItem
%>' />&nbsp;&nbsp;</span>
</ItemTemplate>
                              </asp:ListView>
                              <p>
                              </p>
                              Otherwise use the controls below to search users
by partial username or e-mail:
                              <p>
                              </p>
                              <asp:DropDownList runat="server" ID=
"ddlSearchTypes" CssClass="formStyleDropDown">
                                 <asp:ListItem Text="UserName" Selected="true" />
                                 <asp:ListItem Text="E-mail" />
                              </asp:DropDownList>
                              contain
                              <asp:TextBox runat="server" ID="txtSearchText"
CssClass="formField" />
                              <asp:Button runat="server" ID="btnSearch" Text=
"Search" />
                              <p>
                              </p>
                           </td>
                        </tr>
                        <tr>
                           <td align="right">
                              <asp:Literal runat="server" ID="ltlstatus" />
                              <asp:Button ID="Button1" runat="server" Text=
"New User" />
                           </td>
                        </tr>

As you see, the alphabet bar is built by a ListView control, not a fixed list of links. The ListView will be bound to an array of characters, displayed as links. I used a ListView instead of static links for a couple of reasons. First, this will make it much easier to change the bar's layout, if you want to do so later, because you only need to change the template, not a series of links. Second, if you decide to add localization to this page later, the ListView's template can remain exactly the same, and it is sufficient to bind it to a different array containing the selected language's alphabet.

The third part of the page, which lists users and some of their properties, contains a ListView. A ListView can automatically take care of sorting, paging, and editing, without requiring us to write a lot of code. Following is the complete code used to define the ListView:

<asp:ListView ID="lvUsers" runat="server">
   <LayoutTemplate>
      <table cellspacing="0" cellpadding="0" class="AdminList">
         <tr class="AdminListHeader">
            <td>
               UserName
            </td>
            <td>
               Edit
            </td>
            <td>
Delete
            </td>
         </tr>
         <tr id="itemPlaceholder" runat="server">
         </tr>
      </table>
   </LayoutTemplate>
   <EmptyDataTemplate>
      <tr>
         <td colspan="3">
            <p>
               Sorry there are no Users available at this time.</p>
         </td>
      </tr>
   </EmptyDataTemplate>
   <ItemTemplate>
      <tr>
         <td class="ListTitle">
            <a href="AddEditUser.aspx?CurrentUserName=<%#Eval("UserName")%>">
               <%#Eval("UserName")%></a>
         </td>
         <td>
            <a href="AddEditUser.aspx?CurrentUserName=<%#Eval("UserName")%>">
               <img src="../images/edit.gif" alt="" width="16" height="16"
class="AdminImg" /></a>
         </td>
         <td>
<asp:ImageButton ID="btnDelete" AlternateText="Delete this User?" ImageUrl=
'~/images/delete.gif' CausesValidation="False" runat="server" CommandName=
"DeleteUser" OnClientClick="ConfirmMsg('User')," /></td>
      </tr>
   </ItemTemplate>
</asp:ListView>
<div class="pager">
   <asp:DataPager ID="pagerBottom" runat="server" PageSize="10" PagedControlID
="lvUsers"
QueryStringField="pageNo">
<Fields>
   <asp:NextPreviousPagerField ButtonCssClass="command" FirstPageText="«"
 PreviousPageText="<"
      RenderDisabledButtonsAsLabels="true" ShowFirstPageButton="true"
ShowPreviousPageButton="true"
      ShowLastPageButton="false" ShowNextPageButton="false" />
   <asp:NumericPagerField ButtonCount="7" NumericButtonCssClass="command"
 CurrentPageLabelCssClass="current"
      NextPreviousButtonCssClass="command" />
   <asp:NextPreviousPagerField ButtonCssClass="command" LastPageText="»"
 NextPageText=">"
      RenderDisabledButtonsAsLabels="true" ShowFirstPageButton="false"
ShowPreviousPageButton="false"
      ShowLastPageButton="true" ShowNextPageButton="true" />
</Fields>
   </asp:DataPager>
</div>

Deleting a user account is a serious action that can't be undone, so have the administrator confirm this action before proceeding! This can be done by adding a JavaScript "confirm" in the link's onclick client-side event, through the button's new OnClientClick property. This is done by adding the JavaScript to the button's markup, OnClientClick="ConfirmMsg('User'),", which calls the ConfirmMsg function defined in the TBH.js file. It will be used throughout the site to question users before they perform a delete operation.

function ConfirmMsg(entityName) {
   return confirm('Warning: This will delete the ' + entityName + ' from the
 database.'),
}

Before looking at the code-behind file, I want to point out another small, but handy, new feature: the ListView has a <EmptyDataTemplate> section that contains the HTML markup to show when the list is bound to an empty data source. This is a very cool feature because you can use this template to show a message when a search produces no results.

In the page's code-behind file there is a class-level MemershipUserCollecion object that is initialized with all the user information returned by Membership.GetAllUsers. The Count property of this collection is used in the page's Load event to show the total number of registered users, together with the number of online users. In the same event, we also create the array of letters for the alphabet bar, and bind it to the ListView control. The code is:

Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs)
 Handles Me.Load

      If Not IsPostBack Then
         BindAlphabet()
      End If

   End Sub

   Private Sub BindAlphabet()
      Dim alphabet As Char() = {"A"c, "B"c, "C"c, "D"c, "E"c, "F"c, _
                  "G"c, "H"c, "I"c, "J"c, "K"c, "L"c, _
                  "M"c, "N"c, "O"c, "P"c, "Q"c, "R"c, _
                  "S"c, "T"c, "U"c, "V"c, "W"c, "X"c, _
                  "Y"c, "Z"c}

      lvAlphabet.DataSource = alphabet
      lvAlphabet.DataBind()

      lblTotUsers.Text = allUsers.Count.ToString()
      lblOnlineUsers.Text = Membership.GetNumberOfUsersOnline().ToString()
   End Sub

   Private Sub BindUsers()

      BindAlphabet()

      Dim users As MembershipUserCollection = Nothing

      Dim lSearchText As StringBuilder = New StringBuilder()
      If String.IsNullOrEmpty(lvUsers.Attributes("SearchText")) = False Then
lSearchText.Append(lvUsers.Attributes("SearchText"))
      End If

      Dim searchByEmail As Boolean = False
      If String.IsNullOrEmpty(lvUsers.Attributes("SearchByEmail")) = False Then
         searchByEmail = Boolean.Parse(lvUsers.Attributes("SearchByEmail"))
      End If

      If lSearchText.Length > 0 Then
         If (searchByEmail) Then
            users = Membership.FindUsersByEmail(lSearchText.ToString())
         Else
            users = Membership.FindUsersByName(lSearchText.ToString())
         End If
      Else
         users = allUsers
      End If

      lvUsers.DataSource = users
      lvUsers.DataBind()

End Sub

The grid is not populated when the page first loads, but rather after the user clicks a link on the alphabet bar or runs a search. This is done in order to avoid unnecessary processing and, thus, have a fast-loading page. When a letter link is clicked, the ListView's ItemCommand event is raised. You handle this event to retrieve the clicked letter and then run a search for all users whose name starts with that letter. If the All link is clicked, you'll simply show all users. Because this page also supports e-mail searches, a "SearchByEmail" attribute is added to the control and set to false, to indicate that the search is by username by default. This attribute is stored in the grid's Attributes collection so that it is persisted in the view state and doesn't get lost during a postback. Here's the code:

Private Sub lvAlphabet_ItemCommand(ByVal sender As Object, ByVal e As
System.Web.UI.WebControls.ListViewCommandEventArgs) Handles lvAlphabet.ItemCommand
      lvUsers.Attributes.Add("SearchByEmail", False.ToString())
      If (e.CommandArgument.ToString().Length = 1) Then

         lvUsers.Attributes.Add("SearchText", e.CommandArgument.ToString() + "%")
         BindUsers()
      Else
         lvUsers.Attributes.Add("SearchText", "")
         BindUsers()
      End If

   End Sub

The code that actually runs the query and performs the binding is in the BindUsers method. It takes a Boolean value as an input parameter that indicates whether the allUsers collection must be repopulated (necessary just after a user is deleted). The text to search for and the search mode (e-mail or username) are not passed as parameters, but rather are stored in the grid's Attributes. Here is the code:

Private Sub BindUsers()

      Dim users As MembershipUserCollection = Nothing

      Dim lSearchText As StringBuilder = New StringBuilder()
If String.IsNullOrEmpty(lvUsers.Attributes("SearchText")) = False Then
         lSearchText.Append(lvUsers.Attributes("SearchText"))
      End If

      Dim searchByEmail As Boolean = False
      If String.IsNullOrEmpty(lvUsers.Attributes("SearchByEmail")) = False Then
         searchByEmail = Boolean.Parse(lvUsers.Attributes("SearchByEmail"))
      End If

      If lSearchText.Length > 0 Then
         If (searchByEmail) Then
            users = Membership.FindUsersByEmail(lSearchText.ToString())
         Else
            users = Membership.FindUsersByName(lSearchText.ToString())
         End If
      Else
         users = allUsers
      End If

      lvUsers.DataSource = users
      lvUsers.DataBind()

   End Sub

The BindUsers method is also called when the Search button is clicked. In this case, the SeachByEmail attribute will be set according to the value selected in the ddlSearchTypes drop-down list, and the SearchText will be equal to the entered search string with the addition of a leading and a trailing "%", so that a full LIKE query is performed:

Protected Sub btnSearch_Click(ByVal sender As Object, ByVal e As EventArgs)
Handles btnSearch.Click
      Dim searchByEmail As Boolean = (ddlSearchTypes.SelectedValue = "E-mail")
      lvUsers.Attributes.Add("SearchText", "%" + txtSearchText.Text + "%")
      lvUsers.Attributes.Add("SearchByEmail", searchByEmail.ToString())
      BindUsers()

   End Sub

When the trashcan icon is clicked, the ListView raises the ItemDeleting event. Notice the username is not accessible through the DataKeys property, but rather by accessing the DataItem property of the ListView. This is because the users were bound to the ListView as an array of strings and, therefore, do not have a primary key or even a field to access the values. This event handler then calls the DeleteUser method, passing the username. From inside this event handler, you can use the static methods of the Membership and ProfileManager classes to delete the user account and its accompanying profile. After that, BindUsers is called again with true as a parameter, so that the collection of all users is refreshed, and the label displaying the total number of users is also refreshed:

Private Sub lvUsers_ItemDeleting(ByVal sender As Object, ByVal e As
System.Web.UI.WebControls.ListViewDeleteEventArgs) Handles lvUsers.ItemDeleting
DeleteUser(lvUsers.Items(e.ItemIndex).DataItem)
   End Sub

   Private Sub DeleteUser(ByVal UserName As String)

      If Membership.DeleteUser(UserName) Then

         BindUsers()

         ltlstatus.Text = "The user was deleted."

      Else

         ltlstatus.Text = "The user was not deleted."

      End If

End Sub

Figure 4-19 shows the page listing all current users.

Figure 4-19

Figure 4.19. Figure 4-19

The AddEditUser Administrative Page

The AddEditUser.aspx page is linked from a row of the ManagedUsers.aspx list. It takes a username parameter on the querystring, and allows an administrator to see all the membership details about that user (i.e., the properties of the MembershipUser object representing that user), and supports editing the user's personal profile. The user interface of the page is simple and is divided in three sections:

  1. The first section shows the data read from MembershipUser. All controls are read-only, except for those that are bound to the IsApproved and IsLockedOut properties. For IsLockedOut, you can set it to false to unlock a user account, but you can't set it to true to lock a user account, as only the membership provider can lock out a user.

  2. The second section contains a CheckBoxList that displays all the roles defined for the application, and allows the administrator to add or remove users to and from roles. There is also a TextBox control and a button to create a new role.

  3. The third and last section displays a user's profile and allows edits to the profile, through the UserProfile user control developed earlier.

Following is the code for AddEditUser.aspx:

<table id="Table1" cellspacing="1" cellpadding="1" width="525" align="center"
border="0">
               <tr>
                  <td>
                     <asp:Literal ID="ltlMessage" runat="server"></asp:Literal>
                  </td>
               </tr>
               <tr>
                  <td>
                     <table id="Table2" cellspacing="0" cellpadding="0" width=
"100%" align="center" border="0">
                        <tr>
                           <td class="fieldname">
                              Username
                           </td>
                           <td width="10">
                           </td>
                           <td>
                              <asp:Literal runat="server" ID="ltlUserName">
</asp:Literal>
                              <div runat="server" id="dUserName">
                                 <asp:TextBox ID="txtUserName" runat="server"
CssClass="formField"></asp:TextBox><sup><font
                                    color="red" size="2">*</font></sup>&nbsp;
                                 <asp:RequiredFieldValidator ID=
"RequiredFieldValidator3" runat="server" ControlToValidate="txtUserName"
                                    Display="None" ErrorMessage="You must supply
a Username."></asp:RequiredFieldValidator>
                                 <asp:ValidatorCalloutExtender ID=
"ValidatorCalloutExtender1" runat="server" TargetControlID=
"RequiredFieldValidator1">
                                 </asp:ValidatorCalloutExtender>
                              </div>
</td>
                        </tr>
                        <tr>
                           <td class="fieldname">
                              E-Mail
                           </td>
                           <td width="10">
                           </td>
                           <td>
                              <asp:Literal runat="server" ID="ltlEMail">
</asp:Literal>
                              <div runat="server" id="dEMail">
                                 <asp:TextBox ID="txtEmail" runat="server"
CssClass="formField"></asp:TextBox><span
                                    style="font-size: 10pt; vertical-align: super;
color: #ff0000">*</span>
                                 <asp:RequiredFieldValidator ID=
"RequiredFieldValidator2" runat="server" ControlToValidate="txtEmail"
                                    Display="None" ErrorMessage="You must supply
a valid E-Mail"></asp:RequiredFieldValidator>
                                 <asp:ValidatorCalloutExtender ID=
"ValidatorCalloutExtender2" runat="server" TargetControlID=
"RequiredFieldValidator2">
                                 </asp:ValidatorCalloutExtender>
                              </div>
                           </td>
                        </tr>
                        <tr runat="server" id="trQuestion">
                           <td class="fieldname">
                              <asp:Label runat="server" ID="lblQuestion"
AssociatedControlID="Question" Text="Security question:" />
                           </td>
                           <td width="10">
                           </td>
                           <td>
                              <asp:TextBox runat="server" ID="Question" Width
="100%" />
                              <asp:RequiredFieldValidator ID="valRequireQuestion"
runat="server" ControlToValidate="Question"
                                 SetFocusOnError="true" Display="Dynamic"
ErrorMessage="Security question is required."
                                 ToolTip="Security question is required."
ValidationGroup="CreateUserWizard1">*</asp:RequiredFieldValidator>
                           </td>
                        </tr>
                        <tr runat="server" id="trAnswer">
                           <td class="fieldname">
                              <asp:Label runat="server" ID="lblAnswer"
AssociatedControlID="Answer" Text="Security answer:" />
                           </td>
                           <td width="10">
                           </td>
                           <td>
                              <asp:TextBox runat="server" ID="Answer" Width=
"100%" />
                              <asp:RequiredFieldValidator ID="valRequireAnswer"
runat="server" ControlToValidate="Answer"
                                 SetFocusOnError="true" Display="Dynamic"
ErrorMessage="Security answer is required."
                                 ToolTip="Security answer is required."
ValidationGroup="CreateUserWizard1">*</asp:RequiredFieldValidator>
                           </td>
                        </tr>
                        <tr runat="server" id="trRegistered">
                           <td class="fieldname">
                              Registered:
                           </td>
                           <td width="10">
                           </td>
                           <td>
                              <asp:Literal runat="server" ID="lblRegistered" />
                           </td>
                        </tr>
                        <tr runat="server" id="trLastLogin">
                           <td class="fieldname">
                              Last Login:
                           </td>
                           <td width="10">
                           </td>
                           <td>
                              <asp:Literal runat="server" ID="lblLastLogin" />
                           </td>
                        </tr>
                        <tr runat="server" id="trLastActivity">
                           <td class="fieldname">
                              Last Activity:
                           </td>
                           <td width="10">
                           </td>
                           <td>
                              <asp:Literal runat="server" ID="lblLastActivity" />
                           </td>
                        </tr>
                        <tr>
                           <td colspan="3">
                              <table runat="server" id="tUserStatus" cellspacing
="0" cellpadding="0" width="100%"
                                 align="center" border="0">
                                 <tr>
                                    <td class="fieldname">
                                       <asp:Label runat="server" ID
="lblOnlineNow" AssociatedControlID="chkOnlineNow" Text="Online Now:" />
                                    </td>
                                    <td width="10">
                                    </td>
                                    <td>
                                       <asp:CheckBox runat="server" ID
="chkOnlineNow" Enabled="false" />
</td>
                                 </tr>
                                 <tr>
                                    <td class="fieldname">
                                       <asp:Label runat="server" ID
="lblApproved" AssociatedControlID="chkApproved" Text="Approved:" />
                                    </td>
                                    <td width="10">
                                    </td>
                                    <td>
                                       <asp:CheckBox runat="server" ID=
"chkApproved" AutoPostBack="true" />
                                    </td>
                                 </tr>
                                 <tr>
                                    <td class="fieldname">
                                       <asp:Label runat="server" ID=
"lblLockedOut" AssociatedControlID="chkLockedOut" Text="Locked Out:" />
                                    </td>
                                    <td width="10">
                                    </td>
                                    <td>
                                       <asp:CheckBox runat="server" ID=
"chkLockedOut" AutoPostBack="true" />
                                    </td>
                                 </tr>
                              </table>
                           </td>
                        </tr>
                        <tr>
                           <td colspan="3">
                           </td>
                        </tr>
                        <tr>
                           <td colspan="3">
                              <div runat="server" id="dPassword">
                                 Password &nbsp;&nbsp;
                                 <asp:TextBox ID="txtPwd" runat="server"
TextMode="Password" CssClass="formField"></asp:TextBox><span
                                    style="vertical-align: super; color: red">*
                                    <asp:RequiredFieldValidator ID
="RequiredFieldValidator1" runat="server" ControlToValidate="txtPwd"
                                       Display="None" ErrorMessage="You must
supply a Password."></asp:RequiredFieldValidator>
                                 </span>&nbsp;<br />
                                 <asp:ValidatorCalloutExtender ID
="ValidatorCalloutExtender3" runat="server" TargetControlID=
"RequiredFieldValidator3">
                                 </asp:ValidatorCalloutExtender>
                                 Confirm Password
                                 <asp:TextBox ID="txtPwdConfirm" runat="server"
TextMode="Password" CssClass="formField"></asp:TextBox>
                                 <asp:CompareValidator ID="CompareValidator1"
runat="server" ControlToCompare="txtPwdConfirm"
ControlToValidate="txtPwd" Display="None"
ErrorMessage="The Passwords do not Match."></asp:CompareValidator>
                                 <asp:ValidatorCalloutExtender ID=
"ValidatorCalloutExtender4" runat="server" TargetControlID="CompareValidator1">
                                 </asp:ValidatorCalloutExtender>
                              </div>
                           </td>
                        </tr>
                        <tr>
                           <td colspan="3">
                           </td>
                        </tr>
                     </table>
                  </td>
               </tr>
               <tr>
                  <td>
                     <div class="sectiontitle">
                        Edit user's roles</div>
                  </td>
               </tr>
               <tr>
                  <td>
                     <asp:CheckBoxList ID="cblRoles" runat="server"
 RepeatColumns="3">
                     </asp:CheckBoxList>
                  </td>
               </tr>
               <tr>
                  <td>
                  <uc1:UserProfile ID="UserProfile1" runat="server" />
                  </td>
               </tr>
               <tr>
                  <td>
                     <table id="Table11" cellspacing="1" cellpadding="1" width
="300" align="center" border="0">
                        <tr>
                           <td class="AdminDetailsAction">
                              <asp:LinkButton ID="lbDelete" CssClass=
"AdminButton" runat="server" CausesValidation="false">Delete</asp:LinkButton>
                           </td>
                           <td class="AdminDetailsAction">
                              <asp:LinkButton ID="lbCancel" CssClass=
"AdminButton" runat="server" CausesValidation="false">Cancel</asp:LinkButton>
                           </td>
                           <td class="AdminDetailsAction">
                              <asp:LinkButton ID="lbUpdate" CssClass=
"AdminButton" runat="server">Update</asp:LinkButton>
                           </td>
                        </tr>
                     </table>
                  </td>
</tr>
            </table>

When the page loads, the username parameter is read from the querystring, a MembershipUser instance is retrieved for that user, and the values of its properties are shown by the first section's controls. Remember, as an administrator you are authenticated, so you must keep the username you want to edit separate from your account credentials. The membership provider assumes your account is to be used, if one is not specified, so there is some work required to keep these separated. The CurrentUserName property helps with this problem:

Private Property CurrentUserName() As String
      Get
         If Not IsNothing(ViewState("CurrentUserName")) Then
            Return ViewState("CurrentUserName").ToString
         Else
            If Not IsNothing(Request("CurrentUserName")) Then
               Return Request("CurrentUserName").ToString
            Else
               Return String.Empty
            End If
         End If
      End Get
      Set(ByVal value As String)
         ViewState("CurrentUserName") = value
      End Set
   End Property

The Page Load event handler checks to see if a username was passed and either loads the user's information to be edited or clears the controls so a new user can be added to the site.

Protected Sub Page_Load1(ByVal sender As Object, ByVal e As System.EventArgs)
Handles Me.Load

      If Not IsPostBack Then

         If String.IsNullOrEmpty(CurrentUserName) = False Then 'Better make sure
            BindUser()
         Else
            ClearUser()
         End If

      End If

   End Sub

The GetMember function wraps the Membership.GetUser method so that it can be consistently called as needed in the site.

Private Function GetMember() As MembershipUser
      Dim mu As MembershipUser = Membership.GetUser(CurrentUserName)
      Return mu
   End Function

The BindUser method gets the Membership user object for the desired user and binds the controls to the corresponding values.

Private Sub BindUser()

      Dim mu As MembershipUser = GetMember()

      dUserName.Visible = False
      dEMail.Visible = False
      dPassword.Visible = False
      tUserStatus.Visible = True
      trRegistered.Visible = True
      trLastActivity.Visible = True
      trLastLogin.Visible = True
      trQuestion.Visible = False
      trAnswer.Visible = False

      lbUpdate.Text = "Update"

      ltlUserName.Text = mu.UserName
      ltlEMail.Text = mu.Email
      lblRegistered.Text = mu.CreationDate.ToString("f")
      lblLastLogin.Text = mu.LastLoginDate.ToString("f")
      lblLastActivity.Text = mu.LastActivityDate.ToString("f")

      chkOnlineNow.Checked = mu.IsOnline
      chkApproved.Checked = mu.IsApproved
      chkLockedOut.Checked = mu.IsLockedOut
      chkLockedOut.Enabled = mu.IsLockedOut

      BindRoles()

   End Sub

In the Page_Load event handler you also call the BindRoles method, as follows, which fills a CheckBoxList with all the available roles and then retrieves the roles the user belongs to, and finally selects them in the CheckBoxList:

Protected Sub BindRoles()
        BindRoles(String.Empty)
End Sub

Protected Sub BindRoles(ByVal vUserName As String)

        cblRoles.DataSource = Roles.GetAllRoles
        cblRoles.DataBind()

        For Each role As String In Roles.GetRolesForUser(vUserName)
            cblRoles.Items.FindByText(role).Selected = True
        Next

End Sub

When the Update Roles button is pressed, the user is first removed from all her roles, and then is added to the selected ones. The removal that occurs first is necessary because a call to Roles.AddUserToRole will fail if the user is already a member of that role. This is a list collection that enables you to specify the datatype you wish to support for objects stored in the list. When you declare an instance of this collection, you have to indicate which datatype you want to store in it by enclosing it in angle brackets. Therefore, if you say "List<string>" you are asking for a list collection that is strongly typed to accept strings. You could have also asked for a collection of any other datatype, including any custom class you might create to hold related data.

Here's the code for the UpdateRoles method:

Private Sub UpdateRoles(ByVal mu As MembershipUser)

        Dim currRoles() As String = Roles.GetRolesForUser(mu.UserName)

        If currRoles.Length > 0 Then
            Roles.RemoveUserFromRoles(UserName, currRoles)
        End If

        ' and then add the user to the selected roles
        Dim newRoles As New List(Of String)
        For Each item As ListItem In cblRoles.Items
            If item.Selected Then
                newRoles.Add(item.Text)
            End If
        Next
        Dim userNames() As String = {mu.UserName}
        Roles.AddUsersToRoles(userNames, newRoles.ToArray)

        lblRolesFeedbackOK.Visible = True

End Sub

As you see, you don't make individual calls to Roles.AddUserToRole for each selected role. Instead, you first fill a list of strings with the names of the selected roles and then make a single call to Roles.AddUserToRoles. When the Create Role button is pressed, you first check to see if a role with the same is already present, and if not, you create it. Then, the BindRoles method is called to refresh the list of available roles:

Protected Sub btnNewRole_Click(ByVal sender As Object, ByVal e As EventArgs)
Handles btnNewRole.Click
        If Not Roles.RoleExists(txtNewRole.Text.Trim) Then
            Roles.CreateRole(txtNewRole.Text.Trim)
            BindRoles()
        End If
    End Sub

When the Approved checkbox is clicked, an auto-postback is made, and in its event handler you update the MembershipUser object's IsApproved property according to the checkbox's value, and then save the change:

Protected Sub chkApproved_CheckedChanged(ByVal sender As Object, ByVal e As
System.EventArgs) Handles chkApproved.CheckedChanged
        Dim User As MembershipUser = Membership.GetUser(CurrentUserName)
        If Not IsNothing(User) Then
            User.IsApproved = chkApproved.Checked
            Membership.UpdateUser(User)
        End If
End Sub

It works in a similar way for the Locked Out checkbox, except that the corresponding MembershipUser property is read-only, and the user is unlocked by calling the UnlockUser method. After this is done, the checkbox is made read-only because you can't lock out a user here (as mentioned previously). Take a look at the code:

Protected Sub chkLockedOut_CheckedChanged(ByVal sender As Object, ByVal e As
System.EventArgs) Handles chkLockedOut.CheckedChanged
        If chkLockedOut.Checked = False Then
            Dim user As MembershipUser = Membership.GetUser(CurrentUserName)
            If Not IsNothing(user) Then
                user.UnlockUser()
                chkLockedOut.Enabled = False
            End If
        End If
End Sub

Finally, when the Update button is clicked, a call to the UserProfile's SaveProfile is made, as you've done in other pages:

Protected Sub lbUpdate_Click(ByVal sender As Object, ByVal e As System.EventArgs)
Handles lbUpdate.Click

        UpdateUser(Membership.GetUser(CurrentUserName))
        UserProfile1.SaveProfile()

End Sub

Figure 4-20 shows this page.

Figure 4-20

Figure 4.20. Figure 4-20

ManageRoles.aspx Page

The ManageRoles page provides a list of the site's roles with links to edit and delete them. The two role administration pages both derive from RoleAdminPage, which in turn derives from AdminPage. The RoleAdminPage contains the RoleName property, which is built upon the PrimaryKeyIdAsString property defined in the BasePage class.

Public Class RoleAdminPage
        Inherits AdminPage

#Region " Properties "

        Protected Property RoleName() As String
            Get
                Return PrimaryKeyIdAsString("Role")
            End Get
            Set(ByVal value As String)
                PrimaryKeyIdAsString("Role") = value
            End Set
        End Property

#End Region

End Class

The PrimaryKeyIdAsString property is a catch-all sort of pattern designed to catch string parameters entered in the querystring or stored in the page's view state. I call it the PrimaryKeyIdAsString property because it is primarily used to manage values associated with primary keys in tables. However it can be used for any value that might potentially be passed in the querystring. The main difference between PrimaryKeyId and PrimaryKeyIdAsString property is the string version returns an empty string when no matching parameter is found. The PrimaryKeyId returns 0 for an empty integer parameter.

Public Property PrimaryKeyIdAsString(ByVal vPrimaryKey As String) As String
Get
                If Not IsNothing(ViewState(vPrimaryKey)) Then
                    Return ViewState(vPrimaryKey)
                ElseIf Not IsNothing(Request.QueryString(vPrimaryKey)) Then
                    ViewState(vPrimaryKey) = Request.QueryString(vPrimaryKey)
                    Return Request.QueryString(vPrimaryKey)
                End If
                Return String.Empty
End Get
Set(ByVal Value As String)
                ViewState(vPrimaryKey) = Value
End Set
End Property

The ManageRoles page relies on a ListView, which follows a common pattern with a table used for the layout, and a column to display the role, and edit and delete it. The first row of the table defines a header row, with column titles and applies the AdminList style class. In the case of the DarkBeer theme, this uses a maroon background and bold white font.

The ItemTemplate defines the binding responsible for rendering the content for each record. This is more than just echoing the text, it also renders the links and buttons for the user to edit and delete an existing role. The role name itself is wrapped in an anchor tag that loads the AddEditRole.aspx page, passing the role to modify.

The delete ImageButton leverages the built-in Deleting event handler of the ListView to handle the deleting process. Notice the ListView's DataKeyNames property is left blank, this is because the collection of roles bound to the ListView is a collection of strings, and therefore does not contain any primary key values.

<asp:ListView ID="lvRoles" runat="server" DataKeyNames="Role">
                     <LayoutTemplate>
                       <table cellspacing="0" cellpadding="0" class="AdminList">
                          <tr class="AdminListHeader">
                            <td>
                              Role
                            </td>
                            <td>
                              Edit
                            </td>
                            <td>
                              Delete
                            </td>
                          </tr>
                          <tr id="itemPlaceholder" runat="server">
                          </tr>
                         </table>
<!--Common Data Pager Control Here -->
                     </div>
                     </LayoutTemplate>
                     <EmptyDataTemplate>
                       <tr>
                          <td colspan="3">
                            <p>
                               Sorry there are no Roles available at this time.</p>
                          </td>
                       </tr>
                     </EmptyDataTemplate>
                     <ItemTemplate>
                       <tr>
                          <td class="ListTitle">
                            <a href="<%# String.Format("AddEditRole.aspx?Role
={0}", Container.DataItem.ToString()) %>">
                              <%# Container.DataItem.ToString() %></a>
                          </td>
                          <td>
                            <a href="<%# String.Format("AddEditRole.aspx?Role
={0}", Container.DataItem.ToString()) %>">
                              <img src="../images/edit.gif" alt="" width="16"
height="16" class="AdminImg" /></a>
                          </td>
                          <td>
                            <asp:ImageButton runat="server" ID=
"btnDeleteEvents" CommandArgument='<%# Eval("Role")%>'
                              CommandName="Delete" ImageUrl=
"~/images/delete.gif" AlternateText="Delete" CssClass="AdminImg"
                              OnClientClick="return confirm('Warning: This
will delete the Role from the database.')," />
                          </td>
                       </tr>
                     </ItemTemplate>
                   </asp:ListView>

Similarly to the ManageUsers page, when a user clicks the trash can icon to delete a row the ItemDeleting event fires, and the role name is passed to the DeleteRoles function. This function first purges any users from the role before it deletes it from the site.

Private Sub lvRoles_ItemDeleting(ByVal sender As Object, ByVal e As
 System.Web.UI.WebControls.ListViewDeleteEventArgs) Handles lvRoles.ItemDeleting
      DeleteRole(lvRoles.Items(e.ItemIndex).DataItem)
   End Sub

   Private Sub DeleteRoles(ByVal vRole As String)
      If Roles.GetUsersInRole(vRole).Length > 0 Then
         Roles.RemoveUsersFromRole(Roles.GetUsersInRole(vRole), vRole)
      End If
      Roles.DeleteRole(vRole)
      BindRoles()
End Sub

AddEditRole.aspx Page

In this edition of the Beer House, the user and user and role management is broken into two distinct groups of pages that enables you to add a little more functionality. The AddEditRole page allows us to add new roles, edit existing roles, and add users to a specific role.

When the page is loaded, it checks to see if there has been a role name passed to the page, if not it clears the controls by calling the ClearRoleInfo method. If there is a role to work on, it binds the role and any associated users to the controls.

Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs)
Handles Me.Load

      If Not IsPostBack Then

         MultiView1.ActiveViewIndex = 0

         If String.IsNullOrEmpty(RoleName) = False Then
            BindRoleInfo()
         Else
            clearRoleinfo()
         End If

      End If

   End Sub

    Private Sub ClearRoleInfo()

        txtRole.Text = String.Empty
flUser.visible = False
        lvUsers.Items.Clear

    End Sub

The BindRoleInfo method sets the text of the txtRole to the role, if it exists. I did this because, if the role does not exist, it needs to be added. If the role exists, then it is bound to the txtRole and a list of UsersInRole is bound to the lvUsers ListView. The list just shows the users in this role. There is also a delete button to remove the user from the list.

Private Sub BindRoleInfo()
        BindRoleInfo(RoleName)
    End Sub

    Private Sub BindRoleInfo(ByVal sRole As String)

        If Roles.RoleExists(sRole) = False Then
            txtRole.Text = String.Empty

            Exit Sub
        End If

        txtRole.Text = sRole

        lvUsers.DataSource = Roles.GetUsersInRole(sRole)
        lvUsers.DataBind()

    End Sub

The MultiView control, introduced with ASP.NET 2.0, allows the definition of different panes of information that can be displayed based on runtime logic. It is great for exchanging what is visible based on server-side logic. In the case of administering a role, it can display either the role information or a view where a new user can be added. When the Add User to Role button is clicked, the second view is displayed and the first view is hidden. In fact the content of the hidden view is not even sent to the browser, making the actual content sent to the client thinner. However, this does mean that any activity to hide or display a view must involve a round trip to the server to determine which view should be displayed. We will see in the next chapter how to hide and display DIVs using JavaScript in a similar fashion, but all the activity is managed on the client.

A TextBox is used to collect the username to be added to the role. A TextBox is a better solution in this situation because the list of usernames could be very large. The TextBox is extended with an AutoCompleteExtender that retrieves a list of potential usernames as the administrator types characters in the TextBox. The AutoCompleteExtender is part of the ASP.NET AJAX Control Toolkit, discussed earlier in this chapter. The AutoCompleteExtender provides a suggestion effect that allows the administrator to see potential accounts without being overwhelmed with a large list of usernames.

To make the AutoCompleteExtender work, there must be a web service with a web method it can call to retrieve the suggestions. The method signature has to be in the form of a String and an Integer. The String is the phrase to be used as the search filter, and the Integer is a count limit the AutoCompleteExtender wants. This is used to limit the size of the list displayed below the TextBox. If there were no limit, the list could be infinitely long. The MaximumPrefixLength property signals to the AutoCompleteExtender how many characters need to be entered before it queries the web service for a list. This again is useful to help limit any potential long list of results. The ServicePath is the URL to the web service, and the ServiceMethod is the name of the method in the web service used to perform the filtering.

<asp:View runat="server" ID="vAddUserToRole">
   <fieldset id="Fieldset1">
      <legend>Assign New User</legend>
      <br />
      Role:
      <asp:Literal runat="server" ID="ltlRole" />
      <br />
      User
      <asp:TextBox ID="txtUser" runat="server" CssClass=
"formField"></asp:TextBox>&nbsp;&nbsp;&nbsp;
      <asp:Button runat="server" ID="btnAddUserToRole2" Text=
"Add User To Role" />&nbsp;&nbsp;&nbsp;
      <asp:Button runat="server" ID="btnCancelAddUserToRole" Text="Cancel" /><br />
      <asp:AutoCompleteExtender runat="server" ID="autoComplete1" TargetControlID
="txtUser"
         ServicePath="~/MembersService.asmx" ServiceMethod="SearchUsersByName"
 MinimumPrefixLength="1"
         CompletionInterval="1000" EnableCaching="true" CompletionSetCount="12" />
   </fieldset>
</asp:View>

The SearchUsersByName method uses the same search routine used in the ManageUsers.aspx page, Membership.FindUsersByName method. The AutoCompleteExtender passes in each character as it is typed and builds a list of potential usernames that match the characters already entered. The SearchUsersByName method retrieves the list of potential MembershipUsers and adds the usernames to a StringCollection. This is then copied to an array of Strings and returned to the AutoCompleteExtender to display as a list below the targeted TextBox control.

<WebMethod()> _
<System.Web.Script.Services.ScriptMethod()> _
Public Shared Function SearchUsersByName(ByVal vName As String,
ByVal count As Integer) As String()

        Dim saUsers As String()
        Dim UserNames As New StringCollection

        Dim users As MembershipUserCollection = Membership.FindUsersByName(vName)

        For Each mu As MembershipUser In users
            UserNames.Add(mu.UserName)
        Next

        Dim saUsers As String() = New String(UserNames.Count - 1) {}
        UserNames.CopyTo(saUsers, 0)

        Return saUsers
End Function

Summary

This chapter covered a great deal of information regarding the membership and profiling features introduced in ASP.NET. The "Solution" section contains surprisingly little code yet produces a complete membership system! We even managed to reimplement and extend the Security area of the ASP.NET Web Administration Tool. In addition, much of the code is for HTML layout, which gives the pages a good appearance, but the code-behind code we've written is just over a hundred lines.

We have also extended the membership, role, and profile systems to the client using ASP.NET AJAX and the corresponding systems. While the code has a certain coolness factor to it, there is much more that could be exploited to make an even sexier experience for our users.

One thing that can be improved upon is the fact that the membership module only supports users and roles, not individual permissions. It might be useful in some cases to define a list of permissions, associate them with a role, add users to the role, and then check for the presence of a permission from code, instead of just checking whether the user belongs to a role. This would give much finer granularity to the security settings and is something that we did in the custom security module developed in the first edition of this book. However, while that level of security control is almost always required in large browser-based applications, it is overkill for many small-to-medium websites and unnecessarily complicates the code. By sticking with simple built-in role security, we are able to completely meet our requirements, and we can do so with simpler code that is easier to test and deploy. If you decide that your particular application requires a fine amount of control that can be enumerated in a list of permissions, you can extend the membership support by writing your own permissions module that links to the current users and roles.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset