Wednesday, October 12, 2011

Mixing Forms and Windows Security in ASP.NET

Summary: ASP.NET developers have been asking for a way to combine Forms and Windows security. Paul Wilson provides a solution that does just that; it captures the Windows username, if possible, and otherwise redirects users to a logon screen. (8 printed pages)
Download the source code for this article.

Contents

Introduction
Forms Authentication
IIS Windows Security
IIS Custom 401 Errors
Forms Custom Logon
Redirect Original URL
Conclusion

Introduction

I've seen many Microsoft® ASP.NET developers asking for a way to combine Forms and Microsoft® Windows® security. The answer has always been something like, "ASP.NET does not support mixed authentication." So lets look at this problem from a business point of view, and avoid the technical details. What we really want is a way to automatically capture the username for intranet users, while still redirecting other users to a custom logon screen for username and password. Is that doable? Most definitely; we just need to quit trying to recreate what's built-in.

Forms Authentication

The first thing to do is to decide on what type of ASP.NET authentication to use. While you might like to just combine Forms and Windows authentication, its not that easy. Each ASP.NET application can only have one authentication type, so you need to pick just one. Windows authentication only provides the username, whether it be the ASP.NET process user, or the actual client user if Microsoft® Internet Information Services (IIS) is configured to disable anonymous access—nothing more. Once you accept this, it should be clear that only Forms authentication is customizable.
So let's proceed by setting Forms authentication in our application's web.config file. This is also a good time to point out that you need to make sure your ASP.NET application is in fact an IIS application also, since this is required for all authentication types. You also need to specify your authorization in web.config, which is to deny anonymous users. The authentication forms sub-tag defines several attributes, one of which is the loginUrl, which is where ASP.NET will automatically redirect all un-authorized users on any access.
You next need to decide which page in your ASP.NET application should be your loginUrl. In all the cases of Forms authentication that I've seen, this would be a Login.aspx page. However, this is not at all that you want to do, since you want to first try to authenticate your users with Integrated Windows security to completely avoid the logon page if possible. This means that your loginUrl should be a page that will use Integrated Windows security. So finish setting up Forms authentication by setting the loginUrl to be WinLogin.aspx.

IIS Windows Security

The next thing you need to do is to handle the Windows Integrated security at WinLogin.aspx. There's actually several pieces to this, and separating them out will help implement it. This process consists of denying anonymous users, getting the client's Windows credentials, capturing the client's Windows username, and then plugging this into Forms authentication. Please note that we will deal with the case where Integrated Windows security fails later. Also note there is no html in WinLogin.aspx; it's just an Integrated Windows security test.
Let's proceed by denying anonymous users and getting the client's Windows credentials. This description of the problem should make it clear that IIS is the solution you need here, since it already has both of these capabilities built into it—you just need to use them. Using the IIS Manager, right-click the WinLogin.aspx file, click Properties, and then go to the File Security tab to edit the authentication and access control for this single file. Then simply un-check Enable anonymous access and check Integrated Windows authentication.
Unfortunately, this does not automatically give you the username; you need a little more. The solution can be found with a page trace, or by using a decompiler on the OnEnter method of the WindowsAuthenticationModule. The username is a server variable named LOGON_USER. All you need to do is capture the username using Request.ServerVariables["LOGON_USER"], and then call FormsAuthentication.RedirectFromLoginPage to plug into Forms authentication. This single method both sets the authentication cookie and redirects to the original page.
ms972958.mixedsecurity_fig01(en-us,MSDN.10).gif
Figure 1. Disable anonymous access and enable Windows

IIS Custom 401 Errors

That's all that you would need to do if you only wanted to use Integrated Windows security. But you also want to handle the case where Integrated Windows security fails for some users, by somehow redirecting them to your own custom logon screen to get their user credentials. Also note that the implementation of Integrated Windows security depends on the browser, since non-Internet Explorer browsers present the user with a logon dialog that they can cancel to fail. So you need to understand what happens when the Integrated Windows security check fails.
What does happen when Integrated Windows security fails? The user gets a 401 error. ASP.NET has a built-in feature to capture and redirect most errors in the web.config file, but unfortunately 401 and 403 errors are the exception and must be handled in other ways. Once again, the description of the problem should make it clear that IIS is the solution. While you can trigger authentication by sending a 401 status code to avoid configuring IIS, I don't know of any way to avoid getting involved with IIS to actually handle a 401 error.
Using the IIS Manager, right-click the WinLogin.aspx file, click Properties, and then go to the Custom Errors tab to Edit the various 401 errors and assign a custom redirection. Unfortunately, this redirection must be a static file—it will not process an ASP.NET page. My solution is to redirect to a static Redirect401.htm file, with the full physical path, which contains javascript, or a meta-tag, to redirect to the real ASP.NET logon form, named WebLogin.aspx. Note that you lose the original ReturnUrl in these redirections, since the IIS error redirection required a static html file with nothing dynamic, so you will have to handle this later.
ms972958.mixedsecurity_fig02(en-us,MSDN.10).gif
Figure 2. Redirect 401 errors to a custom error page

Forms Custom Logon

The next step is to create the usual Forms authentication logon screen, named WebLogin.aspx. The UI should at least contain textboxes for the user to enter the username and password, as well as a submit button to log on, but you may want to add other logon features as well. You'll also need to add your own authentication code in the logon button's event handler, since I've kept it very simple by just testing if the username and password are identical. You may also want to add support for roles, either your own custom roles or Windows groups.
At this point you now have an authenticated user, along with their username, to work with. You could just call FormsAuthentication.RedirectFromLoginPage to once again plug into the Forms authentication, but that doesn't quite work since we lost the original ReturnUrl. So you have to separately set the authentication cookie, using FormsAuthentication.SetAuthCookie, and then manually redirect to the original ReturnUrl, which will be discussed very soon. This at first sounds like it should work, but you end up just looping again to WinLogin.aspx.
So why don't you get to your WebLogin.aspx page? You don't have the proper permissions yet. You started by setting up Forms authentication and set authorization to deny anonymous users, which is smart enough to allow anonymous users to access the loginUrl, but that's WinLogin.aspx. So you need to also explicitly set authorization for WebLogin.aspx to allow anonymous users, which is easily done using a location section in the web.config file, with path WebLogin.aspx. And that's it—you have now have successfully combined Forms and Windows security in ASP.NET.

Redirect Original URL

So now the only remaining problem is to track the original URL, so the redirect can work. The Forms authentication module automatically adds a ReturnUrl querystring to WinLogin.aspx, but Integrated Windows security prevents this page from ever running for non-Windows users. Then IIS forces you to redirect those users to a static html file, so the querystring is lost. You need to instead track the original URL with something more persistent, like cookies or session, although we first have to figure out how to capture that original URL to save it.
Forms authentication works through an HttpModule that intercepts and processes all requests, which then redirects un-authenticated users before the original requested page is ever loaded. This means that you need to also plug in to the request at an earlier time, like in global.asax, where there is already an AuthenticateRequest event that you can handle to capture the URL. So just check if the request is not authenticated, and then set the request's path to be the value of the ReturnUrl cookie that you will redirect back to in your WebLogin.aspx page.
That sounds right, but now you end up just looping back to WebLogin.aspx, not the original URL. Debugging at this point easily finds the problem: You are overwriting the ReturnUrl cookie when WebLogin.aspx is requested, so you just need to not change the cookie for this one case. There is also one other situation that can occur: The user can go directly to WebLogin.aspx, which will now not have a ReturnUrl cookie, so you simply need to check and handle this also. Now you finally have combined Forms and Windows security, along with the original redirect.
ms972958.mixedsecurity_fig03(en-us,MSDN.10).gif
Figure 3. Mixing Forms and Windows security in ASP.NET

Conclusion

Let's look at what we've done, and why it appeared to be so difficult. The problem was to combine Forms and Windows security. There is a tendency for developers to just jump into the technical details, which say you cannot combine authentications. But users don't really care how many authentication types you have, so why do we care? We need to instead look at what the users really want, which in this case is simply to capture their Windows username, if possible, and otherwise redirect them to a logon screen.