Dan Matthews
Aug 27, 2014
  34534
(5 votes)

Mixing Forms and Windows Authentication

Recently I worked on a project where the client had both internal (Active Directory) and external (database-stored) users and wanted to authenticate both against the website. In itself, that’s very straightforward in EPiServer – we can just use the multiplexing authentication and role providers. However, there was a twist. This client wanted to authenticate external users using a clean, external-friendly form, and they wanted internal users to authenticate automatically by passing their AD credentials directly to the site via the Intranet Sites zone. This is where it gets interesting. To explain the issue and how we solved it, we need to take a step back and understand how authentication works. For the sake of clarity, we’ll consider just three aspects of security. There are more in play here, but they aren’t core to what are looking at:

  • Authentication Method
  • Authentication Provider
  • Role Provider

If the end user requests a resource to which they do not have access, then IIS will trigger the configured authentication method to capture credentials from the user. This could be forms authentication (the website captures the information in a plain-text HTML form, should be on HTTPS), basic authentication (browser captures credentials and sends them in plain text, not secure), Windows authentication (browser captured credentials and – via one of several mechanisms – passes them via simple enryption/hashing to the website, semi-secure) or one of various other methods. The site will then take those credentials and pass them to the authentication provider for authentication. In the case of the multiplexing authentication provider, that may in turn call multiple other providers in order to try and authenticate the user until the user can either be authenticated or all attempts fail. If the user can be authenticated, the site will set the HttpContext.Current.User property which is an IPrincipal object. This IPrincipal can be a built-in principal type or a custom one, but whatever type it is, it will have an Identity property. This stores information for the authentication method, and will be a an IIdentity object. If you are using Forms Authentication, this will be a FormsIdentity object which contains various information about the forms ticket. If you are using Windows authentication, it will be a WindowsIdentity with various IDs etc. related to Windows Authentication. Note that this does NOT equate to whether the user is a Windows (or AD) user or not! You can use the Windows Authentication Method to authenticate both internal and external users – it’s simply the mechanism by which credentials are gathered.

At this point we have an authenticated user along with the mechanism used to collect those credentials. The roles that are available to the user can now be identified by calling the role provider associated with that user. This can be done directly on the Principal using a method, or indirectly via the role manager. We now know who someone is, how we authenticated them, and what they can do. In a simple scenario, this is all we need.

One of the features of Windows is that you can add sites to the ‘Intranet Sites’ zone and enable an option to try and automatically authenticate any sites in that zone using your windows credentials. For an end user, that means that they can visit the site and the same credentials they log on to their PC with will be passed to the site to try and authenticate – typically Active Directory credentials. This only works when the site is configured for the Windows authentication method, as otherwise the site won’t send the right challenge to the browser which can capture the credentials.

The problem with this project we were working on is that we wanted a combination of two different authentication methods. For external users, we wanted to use forms authentication. For internal users, we wanted to use the Windows authentication method so that we could try and log them on automatically. Unfortunately, IIS only allows us to configure a single authentication method. At least – you can enable both forms and windows authentication for the website in IIS, ignoring the error message, but you can still only configure one in your Web.Config. Either you choose forms, and specify the logon form URL, or you choose Windows (and optionally set the type of credential exchange that Windows will use). In our project, if we choose forms then we can’t log on Intranet users automatically, and if we choose Windows then external users will get a browser-triggered popup rather than the clean form we created on the site. At this point, we therefore need to work around the limitations that ASP.NET imposes on us. To see how we do that, we need to understand the difference between forms and Windows authentication.

In forms authentication, when the website receives a request to which the anonymous or authenticated user does not have access then it will get the form configured in the Web.Config and do a HTTP 302 redirect to that form – passing the original URL in as a QueryString parameter so that it can be redirected to once the user is successfully logged in. When the user is logged in, an authentication ticket is written to a cookie which is then sent back on each browser request in the cookie collection. Usually it will be a session cookie unless you want the user to be ‘remembered’, in which case you can write a permanent cookie. In that way, once logged on the user will stay logged in for that session or – with a permanent cookie - until the cookie expires or they log out and the cookie is deleted.

With Windows authentication, when a website receives a request to which the anonymous or authenticated user does not have access then it will send a 401 challenge back to the browser. Note that this is not a 403 forbidden, but rather a challenge to see if the user can authenticate. At this point, when the browser picks up a 401, then if the site is in the Intranet Sites zone and the automatic logon option is enabled, then the browser will silently try to negotiate a logon with the website using the currently logged on user’s credentials. If that doesn’t work (or the site is not in the Intranet zone and/or the automatic logon is not enabled), a logon popup is displayed. Note that when using the Windows authentication method, both the browser and the server need to keep track together of the user logon for the duration of the browser session. Every resource request needs to share this negotiated logon.

Because these two methods send back totally different HTTP statuses, 302 or 401, they are fundamentally incompatible. Even more than that, unless the Windows authentication method is configured in the Web.Config, then any 401 challenge/response based user logon will not be negotiated for the ongoing session. You could get them to log on for the first request, but every subsequent request will have ‘forgotten’ the login.

So how do we get this to work?

We need to make sure that we have both forms and Windows authentication methods enabled in IIS, and the Web.Config needs to be configured for forms authentication. After that, the first trick is that we switch between 302 and 401 responses based on some criteria on the incoming request. You can use anything to do this, for example you could pick up a specific referrer, requests coming from a specific IP subnet or requests with a specific QueryString. There are various places that you can switch this, but probably the easiest is in your Global.asax file in the Application_EndRequest method. An example of how this could look is below.

protected void Application_EndRequest(object sender, EventArgs e) { // we only want 302 redirects if they are for login purposes if (this.Response.StatusCode == 302 && this.Response.RedirectLocation.Contains("/login")) { // look for a setting on the QueryString to trigger a challenge if (!string.IsNullOrEmpty(Request.QueryString["internal"])) { this.Response.StatusCode = 401; // note that the following line is .NET 4.5 or later only // otherwise you have to suppress the return URL etc manually! this.Response.SuppressFormsAuthenticationRedirect = true; } } }

So far so good, and if you try and hit your website with the specified QueryString you will get a 401 challenge returned – you will either be auto-logged on or prompted depending on your configuration described earlier. Otherwise, forms login will work just as before. However, you’ll notice that if you use the QueryString method to trigger a 401, then other secured resources such as images may not load. The reason for this is that the 401 challenge worked for the initial request, but because your site is not configured for Windows authentication, it is not retaining the logon credentials through the session. Effectively, you’re not logged in for all the other resource requests. You can see this because if you try and access another page without your QueryString, then you won’t be logged in. We therefore need to do our second trick.

One of the nice things about forms authentication is that we can log someone on programmatically and write an authentication cookie. The trick we therefore make is that when the response comes back from our initial 401 challenge, we can pick it up and write a forms authentication cookie that matches the username and details logged on using Windows authentication. As far as the site is concerned, the user has then been logged on using forms authentication and because the cookie comes back on each request, the user is logged on for all resources. Again, we can do this in the Global.asax file. This time we use the Application_AuthenticateRequest method, and it could look something like this:

protected void Application_AuthenticateRequest(object sender, EventArgs e) { if (Request.IsAuthenticated && HttpContext.Current.User.Identity is WindowsIdentity) { // note that we will be stripping the domain from the username as forms authentication doesn't capture this anyway // create a temp cookie for this request only (not set in response) var tempCookie = FormsAuthentication.GetAuthCookie(Regex.Replace(HttpContext.Current.User.Identity.Name, ".*\\\\(.*)", "$1", RegexOptions.None), false); // set the user based on this temporary cookie - just for this request // we grab the roles from the identity we are replacing so that none are lost HttpContext.Current.User = new GenericPrincipal(new FormsIdentity(FormsAuthentication.Decrypt(tempCookie.Value)), (HttpContext.Current.User.Identity as WindowsIdentity).Groups.Select(group => group.Value).ToArray()); // now set the forms cookie FormsAuthentication.SetAuthCookie(HttpContext.Current.User.Identity.Name, false); } }

Now when an internal user authenticates using Windows authentication either automatically or via popup, they will end up being a forms-authenticated user on the site, just like the external users that came through the forms authentication logon form.

This is not necessarily the cleanest way to handle this – there are some funky ways to do this I’ve seen with HTTP modules and subsites with different Web.Config files, but I think this is probably one of the easiest ways to implement this and it’s fairly versatile. I hope it helps someone out who finds themselves with this interesting edge case!

Aug 27, 2014

Comments

Aug 11, 2015 06:48 AM

I just finished reading your post and --while I have not yet had a chance to test drive your code-- it is truly inspired. 

David Smith
David Smith Nov 11, 2016 05:09 PM

Thanks so much for this post.  It's exactly what I needed, and works great.

I thought I'd share a couple enhancements to your Application_AuthenticateRequest:

        protected void Application_EndRequest(object sender, EventArgs e)
        {
            // we only want 302 redirects if they are for login purposes
            if (this.Response.StatusCode == 302 &&
                this.Response.RedirectLocation.StartsWith(ConfigurationManager.AppSettings["FormsAuthenticationLoginPage"], StringComparison.CurrentCultureIgnoreCase))
            {
                // look for a setting on the QueryString to trigger a challenge
                if (Request.UserHostAddress.StartsWith(ConfigurationManager.AppSettings["SubnetForWindowsAuthentication"]) &&
                    Request.Browser.Win32)
                {
                    // Add script to response to redirect to forms login page in case windows authentication fails
                    this.Response.ClearContent();
                    this.Response.Write("");

                    // Required to allow javascript redirection through to browser
                    this.Response.TrySkipIisCustomErrors = true;
                    this.Response.Status = "401 Unauthorized";
                    this.Response.StatusCode = 401;
                    // note that the following line is .NET 4.5 or later only
                    // otherwise you have to suppress the return URL etc manually!
                    this.Response.SuppressFormsAuthenticationRedirect = true;
                }
            }
        }

Here are the changes:

- I'm only issuing a 401 challenge if the user is on the subnet of the local Windows domain (Request.UserHostAddress), AND the user is browsing on Windows (Request.Browser.Win32).  This way they'll get the forms login page if they're running a Mac or smartphone or other device that woudn't support Windows authentication, even when they're on the local network.

- I'm modifying the response to include script that redirects to the forms login page, so that if a device is not successful with Windows authentication (say, a Windows client that isn't joined to the domain), the error page that the browser displays will fallback to forms authentication when the user cancels the Windows authentication dialog.  This requires that you skip custom errors (TrySkipIisCustomErrors = true), otherwise IIS will replace the custom response.

yasser zaid
yasser zaid Apr 12, 2017 01:47 PM

Dear how can i get current user AD Username

Lyne Belanger
Lyne Belanger Jan 19, 2021 04:36 PM

Thanks a lot for this post.

David, what kind of script would you add to Response.Write? JavaScript?

Please login to comment.
Latest blogs
Copy Optimizely SaaS CMS Settings to ENV Format Via Bookmarklet

Do you work with multiple Optimizely SaaS CMS instances? Use a bookmarklet to automatically copy them to your clipboard, ready to paste into your e...

Daniel Isaacs | Dec 22, 2024 | Syndicated blog

Increase timeout for long running SQL queries using SQL addon

Learn how to increase the timeout for long running SQL queries using the SQL addon.

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Overriding the help text for the Name property in Optimizely CMS

I recently received a question about how to override the Help text for the built-in Name property in Optimizely CMS, so I decided to document my...

Tomas Hensrud Gulla | Dec 20, 2024 | Syndicated blog

Resize Images on the Fly with Optimizely DXP's New CDN Feature

With the latest release, you can now resize images on demand using the Content Delivery Network (CDN). This means no more storing multiple versions...

Satata Satez | Dec 19, 2024