ASP.NET Security: An Introductory Guide to Building and Deploying More Secure Sites with ASP.NET and IIS, Part 2

MSDN Magazine

ASP.NET Security

An Introductory Guide to Building and Deploying More Secure Sites with ASP.NET and IIS, Part 2

Jeff Prosise

This article assumes you're familiar with the .NET Framework

Level of Difficulty     1   2   3 

Download the code for this article: ASPSec2.exe (45KB)

SUMMARY Forms authentication is one of the most compelling and useful new features of ASP.NET. It enables developers to declaratively specify which files on their site can be accessed and by whom, and allows identification of a login page. When an unauthenticated user attempts to retrieve a page protected by forms authentication, ASP.NET automatically redirects them to the login page and asks them to identify themselves. Included here is an overview of forms authentication and what you need to know to put it to work. Also included is hard-to-find information on the security of cookie authentication and on combining forms authentication with role-based URL authorizations.

The world is full of Web applications that need additional protection beyond the system-level security that a Web server provides. Application-level security enables resources to be made available to authorized users but protected from unauthorized users. This kind of security also lets pages be personalized for individual users.
      Last month, in the first half of this two-part series, I described how ASP.NET can be combined with Windows® authentication to provide rock-solid security. This month I'll add to that by introducing forms authentication, which is more suited to general Internet use because it lets you authenticate users without creating Windows logon accounts for them in your server's domain.

Forms Authentication

      Forms authentication is one of the coolest new features in ASP.NET. Simply put, it's a security mechanism that authenticates a user by asking him or her to type credentials (typically a user name and a password) into a Web form. Through entries in Web.config, you identify the login page and tell ASP.NET which resources the login page protects. The first time a user attempts to access a protected resource, ASP.NET transparently redirects them to your login page. If the login is successful, ASP.NET then issues the user an authentication ticket in the form of a cookie and redirects them to the page they originally requested. The ticket allows that user to revisit protected portions of your site without having to repeatedly log in. You can control the ticket's lifetime, thereby determining how long the login is good for.
      Forms authentication replaces reams of code in ASP applications that check to see whether a user has logged in at the top of every page and manually redirects them to a login page if the answer is negative. It then redirects them to the page they originally requested following a successful login. It's perfect for enacting the kind of authentication featured on sites like eBay, where you have to type in a user name and password before viewing personalized pages or placing bids on auction items. Forms authentication also plays well on the Internet, where Windows authentication is seldom practical.

A Simple Sample App

      Just how easy is forms authentication? Check out the application in Figure 1 and you be the judge. The application's user interface consists of two pages: PublicPage.aspx, which can be viewed by anyone, and ProtectedPage.aspx, which is available only to authenticated users. Authenticated users includes anyone who has logged in through a third page, LoginPage.aspx, which asks for a user name and a password. Valid user names and passwords are stored in Web.config.
      Before you dive into the source code, take the application for a test drive. Here's how:

  1. Copy PublicPage.aspx, LoginPage.aspx, and Web.config (application root) to wwwroot or a virtual directory.

  2. Create a subdirectory named Secret in the virtual root and copy ProtectedPage.aspx and Web.config (Secret subdirectory) to it.

  3. Call up PublicPage.aspx in your browser. If you copied it to wwwroot, the proper URL is https://localhost/publicpage.aspx.

  4. Click the View Secret Message button.

  5. View Secret Message uses Response.Redirect to go to Secret/ProtectedPage.aspx. But because ProtectedPage.aspx is viewable only by authenticated users, ASP.NET displays the login form in LoginPage.aspx (see Figure 2).

    Figure 2 Login Form
    Figure 2 Login Form

  6. Type "Jeff" into the user name field and "imbatman" into the password field.

  7. ProtectedPage.aspx appears. Because you're now an authenticated user, you've been issued an authentication ticket that accompanies subsequent requests as a cookie.

  8. Go back to PublicPage.aspx.

  9. Click the View Secret Message button again.

  10. ProtectedPage.aspx appears again, this time without asking you for a user name and password. Why? Because the authentication cookie transmitted with the request identified you to the ASP.NET forms authentication module (which listens in on every request) as an authenticated user and even identified you as "Jeff." Note the personalized greeting on the page (see Figure 3).

    Figure 3 Greetings,
    Figure 3 Greetings, "Jeff"

  11. Close your browser. Then start it again and call up PublicPage.aspx.

  12. Click View Secret Message once more. You're asked to log in again because the cookie containing your authentication ticket is a session cookie, which means it's destroyed when you close your browser.

      What did it take to prevent unauthenticated users from seeing ProtectedPage.aspx and to direct them to the login form when they attempt to call it up? Not a lot, really. The secret lies in Web.config—to be specific, in the two Web.config files that accompany this application. The Web.config file in the application root enables forms authentication and identifies the login page:

  <authentication mode="Forms">
<forms loginUrl="LoginPage.aspx">
•••
</forms>
</authentication>


It also contains a <credentials> section listing valid user names and passwords:

  <credentials passwordFormat="Clear">
<user name="Jeff" password="imbatman" />
<user name="John" password="redrover" />
<user name="Bob" password="mxyzptlk" />
<user name="Alice" password="nomalice" />
<user name="Mary" password="contrary" />
</credentials>

      The Web.config file in the Secret subdirectory plays an equally important role in securing the application. In it, the following statements denote a URL authorization.

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


This instructs the ASP.NET URL authorization module (System.Web.Security.UrlAuthorizationModule) to deny unauthenticated users access to any ASP.NET files in the host directory. The "?" stands for anonymous users, which is another way of saying unauthenticated users. When someone attempts to view a file in this directory, ASP.NET checks to see if a valid authentication cookie is attached to the request. If the cookie exists, ASP.NET unencrypts it, validates it to ensure it hasn't been tampered with, and extracts identity information that it assigns to the current request. (Encryption and validation can be turned off, but are enabled by default.) If the cookie doesn't exist, ASP.NET redirects the request to the login page.
      The actual authentication—soliciting a user name and password and checking their validity—is performed by LoginPage.aspx. The following statement passes the user name and password that the user entered to the static System.Web.Security.FormsAuthentication method named Authenticate, which returns true if the user name and password are valid (that is, if they appear in the <credentials> section of Web.config) and false if they're not:

  if(FormsAuthentication.Authenticate(UserName.Text,Password.Text))
  

      If Authenticate returns true, the next statement creates an authentication cookie, attaches it to the outgoing response, and redirects the user to the page that they originally requested:

  FormsAuthentication.RedirectFromLoginPage(UserName.Text,false);
  

The second parameter passed to RedirectFromLoginPage specifies whether the authentication should be a session cookie (false) or a persistent cookie (true). Many sites that use forms authentication present the user with a checkbox that lets him or her decide which type of cookie to issue. If you see a checkbox labeled "Keep me logged in on this site" or something to that effect, checking the box generally issues an authentication cookie whose lifetime is independent of the browser session.
      ProtectedPage.aspx is the only ASPX file in the Secret subdirectory, but if there were others, they too would be protected by the login form. Protection is applied on a directory-by-directory basis. Applying two different protection levels to two sets of files requires you to host those files in separate directories. Web.config files in each directory specify exactly how the files are to be protected.

Real-world Forms Authentication

      The application in the previous section isn't very realistic. It's unreasonable to store passwords in clear text. ASP.NET has a fix for that, but it becomes a moot point in light of another problem—namely, that it's completely unrealistic to store thousands (or hundreds of thousands) of names and passwords in Web.config. In the real world, you'd store that information in a database. Storing user names and passwords in a database and still using forms authentication is precisely what this section is about.
      Figure 4 shows a modified version of the application that stores user names and passwords in a Microsoft® SQL Server™ database named WebLogin. The database's Users table contains a list of valid user names and passwords (see Figure 5).

Figure 5 SQL Server Database
Figure 5 SQL Server Database

Only two source code files changed—these being LoginPage.aspx and Web.config (the one in the application root). The others are exactly the same, so they don't appear in the code. Web.config no longer has a <credentials> section containing user names and passwords. LoginPage.aspx no longer uses FormsAuthentication.Authenticate to validate user credentials. Instead, it calls a local method named CustomAuthenticate, which uses a SQL query to determine whether the credentials are valid. If the user types "Jeff" into the user name field and "imbatman" into the password field, the query looks like this:

  select count (*) from users where username = 'Jeff' and
cast(rtrim(password) as varbinary)=cast('imbatman' as varbinary)


This query returns a count of the number of records containing "Jeff" in the UserName field and "imbatman" in the Password field. A return value of 1 means the credentials are valid. A return value of 0 means they're invalid because no such record exists in the database.
      The purpose of the CAST operators in the query is to make the password comparison case-sensitive. By default, most SQL databases ignore case when performing string comparisons. Casting strings to varbinaries has SQL treat them as binary values rather than strings and is a common trick for making string comparisons case-sensitive. The RTRIM operator applied to the Password field strips trailing spaces from the string. SQL ignores trailing spaces when comparing strings, but not when performing binary comparisons. Casting the password to varbinary also prevents spoofing with passwords that are actually SQL commands. (At least I think it prevents spoofing; you never know what clever workarounds evildoers might devise. To be certain, slap RegularExpressionValidators on the TextBox controls to reject input containing anything besides letters and numbers. For good form, throw in a couple of RequiredFieldValidators, too.)
      This version of LoginPage.aspx has one other feature that the previous version did not: a checkbox that lets the user decide whether the authentication cookie issued to them is temporary or persistent. LoginPage.aspx passes the value of the checkbox's Checked property to RedirectFromLoginPage:

  FormsAuthentication.RedirectFromLoginPage (UserName.Text,
Persistent.Checked);


      Checking the box produces a persistent authentication cookie by passing true to RedirectFromLoginPage, and leaving the box unchecked produces a temporary (session) authentication cookie by passing false. Check the box before logging in and you'll be able to get back to ProtectedPage.aspx without logging in again, even if you shut down your machine and don't come back until days later.
      Before testing the new version of the application, you must create the WebLogin database. The code download contains a script named WebLogin.sql that creates it for you. Simply open a command prompt window, go to the directory where WebLogin.sql is stored, and type

  osql -U sa -P -i weblogin.sql
  

By default, the script creates the database on drive C:. If you installed SQL Server on another drive and would like the database to be created there as well, open WebLogin.sql with Notepad and edit the drive letter in the following statement:

  FILENAME = 'C:\program files\...\weblogin.mdf',
  

The installation script will only work, of course, if SQL Server is installed on your PC.

      When you call RedirectFromLoginPage and pass false as the second argument, ASP.NET issues a session authentication cookie containing a time stamp that limits the cookie's validity to 30 minutes, even if the browser session extends longer than that. The timeout value of 30 minutes is controlled by the timeout attribute attached to the <forms> element in Machine.config:

  <forms ... timeout="30">
  

      You can change the timeout by editing Machine.config or including a timeout attribute in a local Web.config file. The following Web.config file enables forms authentication and extends the validity of the authentication cookie to seven days (10,080 minutes):

  <configuration>
<system.web>
<authentication mode="Forms">
<forms loginUrl="/LoginPage.aspx" timeout="10080" />
</authentication>
</system.web>
</configuration>


      When a session timeout cookie is returned to ASP.NET in a subsequent request, ASP.NET automatically renews it (updates the time stamp) if the cookie's lifetime is more than half over. Thus, even the default timeout of 30 minutes enables you to access a protected page for hours on end as long as the browser remains open and you submit the cookie to ASP.NET at least once every 15 minutes.
      If the user checks the "Keep me signed in" box in the login page of the application in the previous section, LoginPage.aspx issues a persistent authentication cookie by passing true to FormsAuthentication.RedirectFromLoginPage. Here's that statement one more time:

  FormsAuthentication.RedirectFromLoginPage (UserName.Text,
Persistent.Checked);


      One drawback to issuing a persistent authentication cookie this way is that the cookie remains valid for 50 years. Furthermore, there is no configuration setting that lets you change this. The timeout attribute has no effect on a persistent authentication cookie. Suppose you'd like to issue a persistent authentication cookie but you'd also like to limit its lifetime to, say, seven days. How do you go about it?
      The solution is to programmatically modify the authentication cookie before returning it in the response. Figure 6 shows a modified version of OnLogIn (the handler that's called when the user clicks the LoginPage.aspx Log In button) that sets the authentication cookie's lifetime to seven days—provided, of course, that the cookie is persistent.
      If CustomAuthenticate returns true, indicating that the user entered valid credentials, this version of OnLogIn uses FormsAuthentication.GetRedirectUrl to grab the URL of the page that the user originally requested. Then it calls FormsAuthentication.SetAuthCookie to create an authentication cookie and add it to the cookies going out in the response. Before calling Response.Redirect to go to the requested page, OnLogIn modifies the cookie by retrieving it from the response's Cookies collection and setting its Expires property to a date seven days hence. This simple modification ensures that the user will have to go through your login page again after seven days. Of course, you can set the lifetime to any length you want by modifying the TimeSpan value. You'll see this technique used in this article's final sample program. But first, there's one more topic I need to cover: role-based security.

Forms Authentication and Role-based Security

      The previous sample program demonstrated how to combine forms authentication with user names and passwords stored in a SQL Server database. The next one shows how to use role membership to allow some users to view ProtectedPage.aspx while hiding it from others.
      The following statement in the Secret directory's Web.config file prevents unauthenticated users from accessing the ASPX files located in that directory:

  <deny users="?" />
  

The only problem with this statement is that it allows any authenticated user to view ProtectedPage.aspx. It's not unrealistic to imagine that in some scenarios you might want to allow some authenticated users to view ProtectedPage.aspx without permitting all authenticated users to view it. Suppose John and Alice are managers who should be able to call up ProtectedPage.aspx, but Jeff, Bob, and Mary are mere developers who should not. One way to keep Jeff, Bob, and Mary out is to deny access to all users (users="*"), but specifically allow access to John and Alice. Here's a Web.config file that does just that:

  <configuration>
<system.web>
<authorization>
<allow users="John, Alice" />
<deny users="*" />
</authorization>
</system.web>
</configuration>


You could also specifically deny access to Jeff, Bob, and Mary:

  <configuration>
<system.web>
<authorization>
<deny users="Jeff, Bob, Mary" />
<allow users="*" />
</authorization>
</system.web>
</configuration>


Be aware that when you use <allow> and <deny> in this manner, the entries are order-sensitive. The statements

  <deny users="*" />
<allow users="John, Alice" />


are not equivalent to

  <allow users="John, Alice" />
<deny users="*" />


because ASP.NET will stop at <deny users="*"> and ignore any statements that appear after it.

     These Web.config files work just fine, but they're not very practical for sites that serve large volumes of users. Just imagine what a nightmare it would be to edit multimegabyte Web.config files every time someone enters or leaves your company or gets a promotion. For large sites, roles provide a practical solution to the problem of granting access to only some authenticated users without granting access to all of them. And roles work well with forms authentication provided you're willing to write a little code to help out.
      Look again at the WebLogin database that serves my site (shown in Figure 5). In addition to storing user names and passwords, the Users table has a field named Role that stores each user's role membership, if any. John and Alice are assigned manager roles, while Jeff, Bob, and Mary are assigned the role of developer. Is it possible to use these role memberships to grant John and Alice—and anyone else assigned the role of manager—access to ProtectedPage.aspx while keeping others away? You bet. It only requires two simple modifications to the code I've already written.
      The first step is the easy one. It involves editing the Secret directory's Web.config file to grant access to managers or deny access to developers. Here's a Web.config file that grants access to managers:

  <configuration>
<system.web>
<authorization>
<allow roles="Manager" />
<deny users="*" />
</authorization>
</system.web>
</configuration>


The roles attribute take the place of the users attribute and grants or denies access not to individual users, but to groups of users based on the role or roles that they've been assigned.
      The second step is more involved. Somehow you have to map the roles stored in the database to user accounts in each and every request so ASP.NET can determine whether the requestor is a manager or a developer. The best place to do the mapping is in the Application_AuthenticateRequest events that fire at the beginning of every request. You can process Application_AuthenticateRequest events in a custom HTTP module or in Global.asax. The code in Figure 7 shows you a Global.asax file that layers roles onto forms authentication.
      How does it work? After verifying that the user has indeed been authenticated (for forms authentication, "is authenticated" means a valid authentication cookie is attached to the request) and that the authentication was performed via forms authentication, Application_AuthenticateRequest extracts the user name from the cookie. It doesn't touch the cookie directly; instead, it casts User.Identity to FormsIdentity, which works fine as long as the user was authenticated using forms authentication and reads the user name from the FormsIdentity object's Name property.
      If the user name is "Jeff," Application_AuthenticateRequest creates a new GenericPrincipal object containing the role name "Developer" and assigns it to the current request by writing it to the User property of the request's HttpContext. GenericPrincipal is a device for representing user identities independent of the authentication protocol being used. When code executed in this request attempts to redirect to ProtectedPage.aspx, ASP.NET compares the role name in the GenericPrincipal to the roles granted access through Web.config. Since Jeff is a developer but the Secret directory's Web.config file only allows access to managers, Jeff is denied access to ProtectedPage.aspx. But change the statement

  app.Context.User = new GenericPrincipal (identity,
new string[] { "Developer" });


to the following statement, and Jeff will be able to view ProtectedPage.aspx just fine.

  app.Context.User = new GenericPrincipal (identity,
new string[] { "Manager" });


      Figure 8 contains the third and final version of the PublicPage/ProtectedPage application. It includes three features that the previous version did not:

  • It retrieves roles from the WebLogin database and assigns them to incoming requests (assuming the requests are authenticated using forms authentication).
  • Its Web.config file (the one in the Secrets directory) allows access to managers, but not to anyone else.
  • It returns an authentication cookie whose lifetime is seven days rather than 50 years if the login page's "Keep me signed in" box is checked.

      To experience role-based security in action, click PublicPage.aspx's View Secret Message button and type "Jeff" and "imbatman" into the login form. Because Jeff is identified as a developer in the database, you won't be able to view ProtectedPage.aspx. But log in as John (password "redrover") and you'll pull up ProtectedPage.aspx just fine. Why? Because John's role is manager, and managers are specifically allowed to access resources in the Secrets directory.
      By the way, if clicking the View Secret Message button bypasses the login form and goes straight to ProtectedPage.aspx, that's because the cookie you were issued when you tested the previous version of the application still identifies you as an authenticated user. If it's a session cookie, simply restarting your browser will destroy the cookie and let you see the login page again. If it's a persistent cookie, you'll have to delete it. The easiest way to do that is to use your browser's Delete Cookies command. In Microsoft Internet Explorer, you'll find it under Tools | Internet Options.
      As a practical matter, you might prefer to consolidate all your URL authorizations in the top-level Web.config file rather than divide them among Web.config files in individual directories. ASP.NET supports that, too. Figure 9 contains a Web.config file that goes in the application root. It enables forms authentication and specifies that only managers are allowed access to resources in the subdirectory named Secret.
      The ability to specify configuration settings for multiple directories in one Web.config file isn't limited to URL authorizations; it works for other configuration settings, too.

Multiple Roles

      It's not uncommon to encounter organizations in which employees are (or can be) assigned multiple roles. Alice might be a manager, but she could be a developer also or at least want access to material that developers have access to. Does ASP.NET's brand of role-based security support multiple role memberships? It sure does. The second parameter passed to GenericPrincipal's constructor isn't a string; it's an array of strings. To indicate that a given security principal (user) belongs to two or more roles, simply submit an array of role names, as shown here:

  app.Context.User = new GenericPrincipal (identity,
new string[] { "Developer", "Manager" });


Now Alice can access any resources that managers or developers enjoy access to.
      You can also use <allow> and <deny> elements to allow or deny access to multiple roles. For example, the following statements in a Web.config file grant access to developers and managers while denying access to everyone else.

  <allow roles="Manager, Developer" />
<deny users="*" />


Signing Out

      Many sites that rely on forms-style authentication allow users to sign out as well as sign in. Calling any FormsAuthentication method that attaches an authentication cookie to the response effectively signs in the user. The FormsAuthentication.SignOut method does the opposite: it signs out an authenticated user. It works by returning a Set-Cookie header that sets the cookie's value to a null string and sets the cookie's expiration date to a date in the past, effectively destroying the authentication cookie. Here's a snippet of code from a Web form that logs out the current user when the Log Out button is clicked:

  <asp:Button Text="Log Out" OnClick="OnLogOut" RunAt="server" />
•••
<script language="C#" runat="server">
void OnLogOut (Object sender, EventArgs e)
{
FormsAuthentication.SignOut ();
}
</script>


The result is that the next time this user visits a protected portion of your site, he or she will have to log in again.

      The <forms> element supports the five attributes listed in Figure 10. Most of these attributes are self-explanatory, but protection deserves special mention. It specifies the desired level of protection for the authentication cookies that ASP.NET uses to identify authenticated users. The default is "All," which instructs ASP.NET to both encrypt and validate authentication cookies. Validation works exactly the same for authentication cookies as it does for view state: the <machineKey> element's validationKey is appended to the cookie, the resulting value is hashed, and the hash is appended to the cookie. When the cookie is returned in a request, ASP.NET verifies that it wasn't tampered with by rehashing the cookie and comparing the new hash to the one accompanying the cookie. Encryption works by encrypting the cookie—hash value and all—with <machineKey>'s decryptionKey attribute.

      Validation consumes less CPU time than encryption and prevents tampering. It does not, however, prevent someone from intercepting an authentication cookie and reading its contents. Nonetheless, if you want ASP.NET to validate but not encrypt authentication cookies, set the <forms> element's protection attribute as follows:

  <forms ... protection="Validation" />
  

Encryption provides a double dose of insurance against tampering and prevents the cookie's contents from being read, too. If you'd like ASP.NET to encrypt authentication cookies but skip the validation procedure, do this:

  <forms ... protection="Encryption" />
  

      Finally, if you want neither validation nor encryption performed, do this:

  <forms ... protection="None" />
  

The "None" value is useful when authentication cookies travel over HTTPS. After all, there's no need to encrypt them twice.

      Speaking of HTTPS, encrypted cookies can't be read or altered, but they can be stolen and used illicitly. Time-outs are the only protection a cookie offers against replay attacks, and they apply to session cookies only. The most reliable way to prevent someone from spoofing your site with a stolen authentication cookie is to use an encrypted communications link. If you'd prefer not to encrypt communications to all parts of your site, consider at least submitting user names and passwords over HTTPS. (When you see buttons on commercial sites that say "Sign in using a secure link," that's exactly what they're doing.) The following <forms> element protects plaintext user names and passwords from prying eyes by connecting to the login form over a secure link:

  <forms ...
loginUrl="https://www.wintellect.com/secure/login.aspx" />


This assumes, of course, that your server supports HTTPS and that Login.aspx is stored in a directory configured to use HTTPS.
      The path attribute can also play a role in securing authentication cookies. Say you place public files in the virtual root and protected files in a subdirectory configured for HTTPS. If you accept the default path of /, the authentication cookie you acquire is transmitted in all requests to the Web site, not just the ones directed to the Secret directory. An intruder can intercept the cookie on its way to a public page and use it to gain access to protected pages. Here's the solution:

  <forms path="/Secret" />
  

Now the cookie will only be transmitted in requests for resources in the Secret subdirectory and its subdirectories, meaning it's only transmitted over secure channels.

Caveat Emptor

      I'll close with a word of warning regarding forms authentication—something that's vitally important to understand, but easily overlooked by many folks.
      Forms authentication protects only ASP.NET files. I'll say it again: forms authentication protects only ASP.NET files. It guards ASPX files, ASMX files, and other file types registered to ASP.NET, but it doesn't protect files that don't belong to ASP.NET—for example, files with HTM or HTML extensions. To prove this to yourself, put a ProtectedPage.html file in the Secret directory used in my forms authentication samples. You have to go through the login page to get to ProtectedPage.aspx, but ProtectedPage.html requires no login. Why is that? The reason is that ASP.NET never sees (and therefore can't intercept and redirect) requests for file types that aren't registered to it.
      One possible solution is to assign the file name extension ASPX to HTML files and other non-ASP.NET files that you want to protect with forms authentication. You'll incur additional overhead when accessing the files, but at least you won't leave them alone and unprotected.

Conclusion

      Security should be an integral part of a Web application's design, not an afterthought. If you're building an ASP.NET app for the company intranet and want to secure access to some of its pages, look to Windows authentication and access control list authorization to provide the protection you need. If it's an eBay-style app you're putting together—that is, one designed to serve the population at large—forms authentication and URL authorization are just the ticket. Whichever you choose, ASP.NET is up to the job.
For related articles see:
Simple Forms Authentication

The Forms Authentication Provider

ASP.NET Authentication

.NET Framework SDK

Jeff Prosise makes his living programming Windows and teaching others to do the same. He is also a cofounder of Wintellect, a developer training and consulting firm that specializes in .NET developer training. He is the author of Programming Microsoft .NET (Microsoft Press, 2002). Contact Jeff at wicked@microsoft.com.

From the May 2002 issue of MSDN Magazine