I have created a simple (test) managed module in C# (.net v3.5) which performs http basic authentication. I based it on this article: http://www.leastprivilege.com/HTTPBasicAuthenticationAgainstNonWindowsAccountsInIISASPNETPart2TheHTTPModule.aspx but kept it simpler than that at this test stage. I have provided the code at the end of this post.
When a request arrives, the module checks the Authorization header and if it is absent or invalid it sets Response.StatusCode to 401 and calls Response.AddHeader("WWW-Authenticate", "Basic realm=\"test\"") then Response.End(). This causes the browser to popup a login dialogue. If that is filled-in, the credentials are sent back in the authorization header on the resubmitted request and my module then validates them and lets the user in (etc).
However, if I enable custom errors (for example:
<system.webServer>
<httpErrors>
<remove statusCode="401" subStatusCode="-1" />
<error statusCode="401" prefixLanguageFilePath="" path="/mytest401.htm" responseMode="ExecuteURL" />
</httpErrors>
</system.webServer>
) then the behaviour changes. My module still intercepts the request and sets the status code and header as before, but it seems that the custom error module kicks in and sends back the mytest401.htm page with a response status of 200 OK (not 401) and without my header - so there is no challenge/response (login box) shown by the browser and the user effectively can't ever log in - they just get the custom 401 error page all the time. I don't want this behaviour!
You might argue that there is no point customising the 401 error page because the user will never see it - my module will send back its response and the browser will do its popup without showing the 401 error response. However, if the user hits ESC in response to the browser login popup or (in IE) gets the credentials wrong 3 times, then the browser will show the 401 page as sent back from IIS7 - I want this to be friendlier than the standard one so I want to customise it - this is what custom error pages are for!
In my experimentation I also discovered that if I try to get around this behaviour by setting Response.TrySkipIisCustomErrors=true in my module then I do get the 401 challenge I expected, but when escaping out of the dialogue it is clear that no content has been sent back and the user sees a blank error page (neither the standard 401 nor my custom 401 page is sent back). I guess this is expected because it has skipped generating it.
So I'm wondering how to combine the use of custom error pages with my custom authentication module. Can I get the custom error page content to be sent back and yet still retain my header and status code? Ideally this would be done with some 'ordering' - perhaps if my module executed after the custom error module then it could change the 200 into a 401 and add the header. This might be complicated though because the module works in two parts. In the AuthenticateRequest event handler it does the checking of the Authorization header and the validation of the credentials, setting the 401 status if required, but the header is added in the EndRequest event handler if the status is 401. Now this works and both events fire and respond as expected but the custom error module comes in afterwards and changes everything. If I managed to get my module's EndRequest event handler to fire after the custom error module has done its work I suspect the status would already have been set back to 200 so I would not be able to test for 401 before I add my header etc. I'm sure I could find a way around that but at the moment I can't reorder the events anyway.
Here's the code for the module, with some experimentation commented out...
public class PhilsTestHttpModule : IHttpModule
{
public void Init(HttpApplication context)
{
context.AuthenticateRequest += TestAuthenticate;
context.EndRequest += TestEndRequest;
}
public void Dispose()
{
}
public void TestAuthenticate(object sender, EventArgs e)
{
bool AllowIn = false;
string username = String.Empty;
string password = String.Empty;
// is header there?
HttpContext context = HttpContext.Current;
string authHeader = context.Request.Headers["Authorization"];
if (!String.IsNullOrEmpty(authHeader) && (authHeader.StartsWith("Basic")))
{
// strip out the "basic"
string encodedUserPass = authHeader.Substring(6).Trim();
// decode
Encoding encoding = Encoding.GetEncoding("iso-8859-1");
string userPass = encoding.GetString(Convert.FromBase64String(encodedUserPass));
// split out username, password.
int separator = userPass.IndexOf(':');
username = userPass.Substring(0, separator);
password = userPass.Substring(separator + 1);
// verify (test)
if ((username == "Hubert") && (password == "scampi"))
AllowIn = true;
}
if (!AllowIn)
{
context.Response.StatusCode = 401;
//context.Response.TrySkipIisCustomErrors = true;
context.Response.End();
}
else
{
// populates AUTH_USER server variable.
context.User = new GenericPrincipal(new GenericIdentity(username),null);
}
}
public void TestEndRequest(object sender, EventArgs e)
{
if (HttpContext.Current.Response.StatusCode == 401)
{
HttpContext.Current.Response.AddHeader("WWW-Authenticate", "Basic realm=\"test\"");
//HttpContext.Current.Response.TrySkipIisCustomErrors = true;
HttpContext.Current.Response.End();
}
}
}