IIS7: Using Basic Authentication may cause premature user lockouts.

PROBLEM:

If you have been noticing recently that end users of your IIS web site are getting locked out more often than expected, rest assured you are not imagining things.

The particular problem I am describing here applies *ONLY* if you are using basic authentication. If you are trying to find the root cause of authentication issues with anything other than "Basic Authentication" on IIS 7.0 please ignore this blog. It doesn't apply to you.

CAUSE:

  I have been supporting IIS for nearly a decade now. And with each new version that I've seen, the support for non-English languages has been getting better and better. IIS 7 is no different. The way it does this is by formatting strings as Unicode characters in as many places as practical instead of ANSI. One of the "features" the BasicAuthenticationModule is that it supports *both* UTF-8 and ANSI encoded credentials.  UTF-8 (i.e. "Unicode") allows for a much larger range of characters due to the way each "character" is 2-bytes versus 1-byte. The possible values for a 2-byte character range from "0x0000" - "0xFFFF" which is 0-65,535. A 1-byte character is "0x00" - "0xFF" which is 0-255.

  Here's where the problem is: The BasicAuthenticationModule uses a simple Windows API called "LogonUserEx" to validate the credentials supplied by a user. According to MSDN this API accepts a string type of "LPTSTR" for the domainname, username, and password parameters. Based on what I am reading (I am not C++ developer by any means) a LPTSTR can potentially be either Unicode or ANSI. Because the BasicAuthenticationModule doesn't usually know what the encoding type is when a browser makes a request it actually tries both encoding types when calling LogonUserEx. For the most part, Windows has been using Unicode characters for quite some time now, so first we pass a UTF-8 encoded string to LogonUserEx. If that fails we give it another try using ANSI encoding to make sure the failure wasn't because of how the browser encoded the credentials. If that second try fails, we consider the credentials to be invalid. Unfortunately what this means is that 2 failed logon attempts via LogonUserEx have just occurred. If you have the lockout threshold set to something like "3", then those 2 invalid attempts by an end user actually just caused "4" failures and they are now locked out.

SOLUTION:

  Install the hotfix available from Microsoft's support site.
  http://support.microsoft.com/kb/981280

WORKAROUND:

  Fortunately the workaround is pretty straight-forward as long as your application works fine under an application pool using the "Integrated" managed pipeline mode. In a nutshell, just add a custom HttpModule to the IIS "pipeline" that calls LogonUser before the BasicAuthenticationModule does. In the HttpModule we can try only one type of encoding and also call LogonUser only once. If that single attempt fails then have the HttpModule immediately return a 401.1 instead of allowing the request to continue on to the BasicAuthenticationModule. Below are some steps that you can take to get this setup. I understand that most of the people reading this are probably not developers so I am providing some “sample” code below that you can use.

1. Copy/paste the following code into notepad.

////////////////////////////////////////////////////////////////////////////
// BEGIN CODE

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using System.Web;

namespace SampleCode
{
    public class BasicLockoutWorkaround : IHttpModule
    {
       #region Members
        private bool _disposed;
        #endregion

        #region Imports
        [DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern bool LogonUser(
            string Username,
            string Domain,
            string Password,
            LOGON32_LOGON LogonType,
            LOGON32_PROVIDER LogonProvider,
            ref IntPtr Token
            );

        [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
        private static extern bool CloseHandle(
            IntPtr handle
            );
        #endregion

        #region Enumerations
        public enum LOGON32_PROVIDER : uint
        {
            DEFAULT = 0,
            WINNT35 = 1,
            WINNT40 = 2,
            WINNT50 = 3
        }
        public enum LOGON32_LOGON : uint
        {
            /// <summary>This logon type is intended for users who will be interactively using the computer, such as a user being
            /// logged on by a terminal server, remote shell, or similar process. This logon type has the additional expense of
            /// caching logon information for disconnected operations; therefore, it is inappropriate for some client/server applications,
            /// such as a mail server.</summary>

            INTERACTIVE = 2,
            /// <summary>This logon type is intended for high performance servers to authenticate plaintext passwords. The LogonUser
            /// function does not cache credentials for this logon type.</summary>

            NETWORK = 3,
            /// <summary>This logon type is intended for batch servers, where processes may be executing on behalf of a user
            /// without their direct intervention. This type is also for higher performance servers that process many plaintext
            /// authentication attempts at a time, such as mail or Web servers. The LogonUser function does not cache credentials
            /// for this logon type.</summary>

            BATCH = 4,
            /// <summary>Indicates a service-type logon. The account provided must have the service privilege enabled.</summary>
            SERVICE = 5,
            /// <summary>This logon type is for GINA DLLs that log on users who will be interactively using the computer. This logon
            /// type can generate a unique audit record that shows when the workstation was unlocked.</summary>

            UNLOCK = 7,
            /// <summary>This logon type preserves the name and password in the authentication package, which allows the server to make
            /// connections to other network servers while impersonating the client. A server can accept plaintext credentials from a client,
            /// call LogonUser, verify that the user can access the system across the network, and still communicate with other servers.</summary>

            NETWORK_CLEARTEXT = 8,
            /// <summary>This logon type allows the caller to clone its current token and specify new credentials for outbound connections.
            /// The new logon session has the same local identifier but uses different credentials for other network connections. This logon type
            /// is supported only by the LOGON32_PROVIDER_WINNT50 logon provider.</summary>

            NEW_CREDENTIALS = 9
        }
        #endregion

        #region Methods
       public void Init(HttpApplication application)
        {
            application.BeginRequest += new EventHandler(this.OnBeginRequest);
        }
        private void OnBeginRequest(object sender, EventArgs e)
        {
            HttpContext context = HttpContext.Current;

            if (context != null)
            {
                string authorization = context.Request.ServerVariables["HTTP_AUTHORIZATION"];
                if (authorization != null)
                {
                    if (authorization.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
                    {
                        int index = authorization.IndexOf(" ");
                        if (index != -1)
                        {
                            try
                            {
                                authorization = authorization.Substring(index + 1);

                                UTF8Encoding encoder = new UTF8Encoding();
                                Decoder decoder = encoder.GetDecoder();

                                byte[] bytes = Convert.FromBase64String(authorization);
                                int count = decoder.GetCharCount(bytes, 0, bytes.Length);
                                char[] characters = new char[count];
                                decoder.GetChars(bytes, 0, bytes.Length, characters, 0);
                                authorization = new string(characters);
                                index = authorization.IndexOf(":");
                                if (index != -1)
                                {
                                    IntPtr token = IntPtr.Zero;
                                    string domainName = null;
                                    string userName = authorization.Substring(0, index);
                                    string password = authorization.Substring(index + 1);

                                    index = userName.IndexOf("\\");
                                    if (index != -1)
                                    {
                                        domainName = userName.Substring(0, index);
                                        userName = userName.Substring(index + 1);
                                    }
                                    bool success = false;

                                    try
                                    {
                                        success = LogonUser(
                                            userName,
                                            domainName,
                                            password,
                                            LOGON32_LOGON.NETWORK_CLEARTEXT,
                                            LOGON32_PROVIDER.DEFAULT,
                                            ref token
                                            );
                                    }
                                    finally
                                    {
                                        if (!success)
                                        {
                                            Win32Exception exception = new Win32Exception();
                                            context.Response.AppendToLog("401Reason=0x" + exception.NativeErrorCode.ToString("X"));

                                            context.Response.Clear();
                                            context.Response.StatusCode = 401;
                                            context.Response.SubStatusCode = 1;
                                            context.Response.StatusDescription = exception.Message;
                                            context.Response.End();
                                        }

                                        if (token != IntPtr.Zero)
                                            CloseHandle(token);
                                    }
                                }
                            }
                            catch (Exception ex)
                            {
                                context.Trace.Write("OnBeginRequest", "Error decoding Authorization header: " + ex.Message);
                            }
                        }
                    }
                }
            }
        }
        public void Dispose()
        {
            if (!this._disposed)
            {
                lock (this)
                {
                    if (!this._disposed)
                    {
                        this._disposed = true;

                        HttpContext context = HttpContext.Current;

                        if (context != null)
                        {
                            HttpApplication application = context.ApplicationInstance;
                            application.BeginRequest -= new EventHandler(this.OnBeginRequest);
                        }
                    }
                }
            }
        }
        #endregion
    }
}
// END CODE
////////////////////////////////////////////////////////////////////////////

2. Save the file as "c:\Windows\Microsoft.net\Framework\v2.0.50727\BasicLockoutWorkaround.cs"

3. Open a CMD prompt and change to the above directory.

4. Type the following (minus the quotes) and press enter:

"Csc.exe /noconfig /nowarn:1701,1702 /errorreport:prompt /warn:4 /define:TRACE /reference:C:\Windows\Microsoft.NET\Framework\v2.0.50727\System.dll /reference:C:\Windows\Microsoft.NET\Framework\v2.0.50727\System.Web.dll /debug:pdbonly /filealign:512 /optimize+ /out:BasicLockoutWorkaround.dll /target:library BasicLockoutWorkaround.cs"

5. Create a BIN folder in the root of your IIS application (either the web site or the folder where your content resides).

6. Copy the resulting "BasicLockoutWorkaround.dll" and "BasicLockoutWorkaround.pdb" files into the BIN folder.

7. Open the IIS 7.0 manager

8. Select the same web site or application as mentioned in step 5.

9. Double-click the “Modules” icon.

10. Click "Add Managed Module..." in the upper right.

11. Enter "BasicAuth Lockout Workaround" for the name.

12. Click the drop-down and choose the "SampleCode.BasicLockoutWorkaround" item.

13. Click "OK"

14. In the upper right, click "View Ordered List..."

15. Select the "BasicAuth Lockout Workaround" item and move it above the "BasicAuthenticationModule" item using the arrows in the upper right.

16. DONE (Close the IIS manager, do a test etc)!

Note: If you have Visual Studio available, instead of following these exact steps, it would be better to create a new managed assembly project, and sign it with a key. Then instead of putting the assembly in the BIN folder, put it in the GAC. This is especially important when you re-use the same assembly in multiple applications. For each application, IIS will pre-load all DLL’s from the BIN folder. If you have 30 applications, for example, even if they all point to the same location, it will load everything in BIN 30 times. In other words, you’ll have BasiclockoutWorkaround.dll loaded 30 different times into the vitual memory of your IIS process. This leads to memory fragmentation and can cause OutOfMemoryException problems. For more information please see http://blogs.msdn.com/tom/archive/2008/02/18/high-memory-part-5-fragmentation.aspx

No Comments