While doing
some study on security and authentication mechanisms in ASP.NET,
I was scrounging the Web for some good examples of HttpModules for doing
BASIC authentication. I came across a remarkable piece of work by Greg
Reinacker, who had taken the time to dissect RFC
2617 and succeeded in wrapping up Digest Authentication into an
HTTP Module, complete with its own little XML document holding users,
passwords and roles. You can find Greg's
original
piece here.
I fiddled with this and although I did get his Webservice sample (which had
a copy of the compiled HttpModule already in its /bin folder) to work,
I kept getting errors when I tried to compile a new module from the source
code he had published. Finally after going over his instructions carefully
and making sure that I still hadn't done something wrong, I realized
that the published source may not have been updated even though the fixed
compiled DLL had been. So I just kept working on it and tracing through
his code and finally got everything working as advertised. Kudos to Reinacker
for putting out a tremendous piece of work and even more so for being
willing to share it --others are simply selling this kind of stuff. Digest
is really superior to other forms of authentication because it looks
"professional", the browser re-sends the authenticated credentials
automatically, and nothing has to go over the wire in the clear. It's
not your username
and password, its a Digest, which is a hash of a number of different
items.
I've seen several examples of using HttpModules for authentication. One person's
presentation expected you to have the username and password on the querystring!
I don't know what they were thinking...
While I was on this track I decided it would be useful
to go ahead and wire up this puppy to use SQL Server instead of an XML
file. And, for those of you who still feel "C-Sharp Challenged",
I even went and wrote up a second version of it in VB.NET (with the aid
of Lutz Roeder's
Reflector).
Http Modules are .NET classes that implement System.Web.IHttpModule.
By plugging your HttpModule into the Http pipeline, you can gain access
to the various Request events, have your module invoked by the Runtime,
and be able to play with or manipulate the Request. This interface exposes
only two methods, Init(), which registers the Module's
event handlers, and Dispose(), which allows us to perform
any necessary cleanup before we go bye-bye and the Garbage Man comes
to pick us up. You can register
an HTTPModule for any of the following events raised by the HttpApplication
instance:
AcquireRequestState:
Raised when runtime is about to acquire the current HTTP Request.
AuthenticateRequest: Raised when ASP.NET is ready to authenticate
the user issuing the request.
AuthorizeRequest: Same with authorization for resources.
BeginRequest: Raised when the Runtime receives a new HTTP Request.
Disposed: Raised when ASP.NET completes processing of an HTTP
Request.
EndRequest:
Raised immediately before the sending of content to the client.
Error:
Raised when there is an unhandled exception during HTTP processing.
PreRequestHandlerExecute:
Raised just before execution of an HTTP Handler.
PostRequestHandlerExecute: Raised just after HTTPHandler
finishes executing.
PreSendRequestContent: Raised just before ASP.NET sends
Response content to the client.
PreSendRequestHeaders: Raised just before headers are
send to the browser.
ReleaseRequestState:
Raised after ASP.NET completes executing all Request Handlers.
ResolveRequestCache: Raised to determine if the Request can
be fulfilled from the Output Cache.
UpdateRequestCache: Raised when ASP.NET has finished processing
and the output contents are ready to be added to the Cache.
In addition to these events, there are the "stock"
Application and Session events which you can hook from within Global.asax.
Obviously, it can be seen that there is a rich HTTP Pipeline event model
here that we bloodthirsty developers can hook our programming teeth into. This
particular HttpModule hooks both AuthenticateRequest and EndRequest,
as you will soon see.
Module Registration
HttpModules are registered into the Http Pipeline in
machine.config or in web.config, like so:
<httpModules>
<add name="DigestAuthenticationModule"
type="System.Web.Security.DigestAuthenticationModule,DigestAuthMod" />
</httpModules>
</system.web>
Now, let's take a look at some code. I've left almost
all of Greg's work intact, except for the Reflector decompiler's mangling
of variables and changing the namespace to the one that would be expected
for this
type
of Authentication
Module,
and
of
course, my new code for handling Authentication
and the return of roles via SQL Server, which is a little more robust scheme:
Imports Microsoft.VisualBasic
Imports System
Imports System.Collections
Imports System.Collections.Specialized
Imports System.Configuration
Imports System.Data
Imports System.Data.SqlClient
Imports System.Security.Cryptography
Imports System.Security.Principal
Imports System.Text
Imports System.Web
Namespace System.Web.Security
Public Class DigestAuthenticationModule
Implements IHttpModule
Public Overridable Sub Dispose() Implements IHttpModule.Dispose
End Sub
Public Overridable Sub Init(ByVal application As HttpApplication) Implements IHttpModule.Init
AddHandler application.AuthenticateRequest, AddressOf Me.OnAuthenticateRequest
AddHandler application.EndRequest, AddressOf Me.OnEndRequest
End Sub
Public Sub OnAuthenticateRequest(ByVal source As Object, ByVal eventArgs As EventArgs)
Dim httpApplication As httpApplication = CType(source, httpApplication)
Dim str1 As String = httpApplication.Request.Headers("Authorization")
If Not str1 Is Nothing AndAlso str1.Length <> 0 Then
str1 = str1.Trim()
If str1.IndexOf("Digest", 0) = 0 Then
Dim i As Integer
str1 = str1.Substring(7)
Dim listDictionary As listDictionary = New listDictionary()
Dim strs1 As String() = str1.Split(New Char() {","c})
Dim str2 As String = String.Empty
For i = 0 To CInt(strs1.Length) - 1
Dim strs2 As String() = strs1(i).Split(New Char() {"="c}, 2)
str2 = strs2(0).Trim(New Char() {" "c, """"c})
Dim str3 As String = strs2(1).Trim(New Char() {" "c, """"c})
listDictionary.Add(str2, str3)
Next i
Dim str4 As String = CType(listDictionary("username"), String)
Dim str5 As String = ""
Dim strs3 As String()
If Not GetPasswordAndRoles(httpApplication, str4, str5, strs3) Then
DenyAccess(httpApplication)
Else
Dim str6 As String
Dim str7 As String = ConfigurationSettings.AppSettings("System.Web.Security.DigestAuthenticationModule_Realm")
Dim str8 As String = String.Format("{0}:{1}:{2}", _ CType(listDictionary("username"), String), str7, str5)
Dim str9 As String = GetMD5HashBinHex(str8)
Dim str10 As String = String.Format("{0}:{1}", httpApplication.Request.HttpMethod, _ CType(listDictionary("uri"), String))
Dim str11 As String = GetMD5HashBinHex(str10)
If Not listDictionary("qop") Is Nothing Then
str6 = String.Format("{0}:{1}:{2}:{3}:{4}:{5}", New Object() {str9, CType(listDictionary("nonce"), String), CType(listDictionary("nc"), String), CType(listDictionary("cnonce"), String), CType(listDictionary("qop"), String), str11})
Else
str6 = String.Format("{0}:{1}:{2}", str9, _ CType(listDictionary("nonce"), String), str11)
End If
Dim str12 As String = GetMD5HashBinHex(str6)
Dim flag As Boolean = IsValidNonce(CType(listDictionary("nonce"), String)) = False
httpApplication.Context.Items("staleNonce") = flag
If CType(listDictionary("response"), String) = str12 AndAlso Not flag Then
httpApplication.Context.User = New GenericPrincipal(New GenericIdentity(str4, "YourType"), strs3)
Else
DenyAccess(httpApplication)
End If
End If
End If
End If
End Sub
Public Sub OnEndRequest(ByVal source As Object, ByVal eventArgs As EventArgs)
Dim httpApplication As httpApplication = CType(source, httpApplication)
If httpApplication.Response.StatusCode = 401 Then
Dim str1 As String = ConfigurationSettings.AppSettings("System.Web.Security.DigestAuthenticationModule_Realm")
Dim str2 As String = GetCurrentNonce()
Dim flag As Boolean = False
Dim local As Object = httpApplication.Context.Items("staleNonce")
If Not local Is Nothing Then
flag = CBool(local)
End If
Dim stringBuilder As stringBuilder = New stringBuilder("Digest")
stringBuilder.Append(" realm=""")
stringBuilder.Append(str1)
stringBuilder.Append("""")
stringBuilder.Append(", nonce=""")
stringBuilder.Append(str2)
stringBuilder.Append("""")
stringBuilder.Append(", opaque=""0000000000000000""")
stringBuilder.Append(", stale=")
stringBuilder.Append(IIf(flag = True, "true", "false"))
stringBuilder.Append(", algorithm=MD5")
stringBuilder.Append(", qop=""auth""")
httpApplication.Response.AppendHeader("WWW-Authenticate", stringBuilder.ToString())
httpApplication.Response.StatusCode = 401
End If
End Sub
Private Sub DenyAccess(ByVal app As HttpApplication)
app.Response.StatusCode = 401
app.Response.StatusDescription = "Access Denied"
app.Response.Write("401 Access Denied")
app.CompleteRequest()
End Sub
Private Function GetMD5HashBinHex(ByVal val As String) As String
Dim i As Integer
Dim encoding As encoding = New ASCIIEncoding()
Dim bs As Byte() = New _ MD5CryptoServiceProvider().ComputeHash(encoding.GetBytes(val))
Dim str1 As String = ""
For i = 0 To 15
str1 = String.Concat(str1, String.Format("{0:x02}", bs(i)))
Next i
Return str1
End Function
Protected Overridable Function GetCurrentNonce() As String
Dim nonceTime As DateTime = DateTime.Now.AddMinutes(1)
Dim expireStr As String = nonceTime.ToString("G")
Dim enc As Encoding = New ASCIIEncoding()
Dim expireBytes As Byte() = enc.GetBytes(expireStr)
Dim nonce As String = Convert.ToBase64String(expireBytes)
' nonce can't end in '=', so trim them from the end
Dim theChar(0) As Char
theChar(0) = Convert.ToChar("=")
nonce = nonce.TrimEnd(theChar)
Return nonce
End Function
Protected Overridable Function IsValidNonce(ByVal nonce As String) As Boolean
Dim dateTime As dateTime
Dim i As Integer = nonce.Length Mod 4
If i > 0 Then
i = 4 - i
End If
Dim str As String = nonce.PadRight(nonce.Length + i, "="c)
Dim flag1 As Boolean = False
Try
Dim bs As Byte() = Convert.FromBase64String(str)
dateTime = dateTime.Parse(New ASCIIEncoding().GetString(bs))
Catch e As FormatException
Return False
End Try
flag1 = dateTime.Now <= dateTime
Return flag1
End Function
Protected Overridable Function GetPasswordAndRoles(ByVal app As HttpApplication, _ ByVal username As String, _ ByRef password As String, ByRef roles As String()) As Boolean
password = Nothing
Dim sqlConnection As sqlConnection = New sqlConnection(ConfigurationSettings.AppSettings("dbConn").ToString())
sqlConnection.Open()
Dim sqlCommand As sqlCommand = New sqlCommand()
sqlCommand.Connection = sqlConnection
sqlCommand.CommandType = CommandType.StoredProcedure
sqlCommand.CommandText = "AuthenticateUser"
sqlCommand.Parameters.Add(New SqlParameter("@username", username))
Dim sqlDataReader As sqlDataReader = _ sqlCommand.ExecuteReader(CommandBehavior.CloseConnection)
sqlDataReader.Read()
password = sqlDataReader.GetString(0)
Dim str2 As String = sqlDataReader.GetString(1)
sqlDataReader.Close()
Dim flag1 As Boolean = False
roles = str2.Split(New Char() {"|"c})
If CInt(roles.Length) > 0 Then
flag1 = True
Else
flag1 = False
End If
Return flag1 End Function
End Class
End Namespace
|
Not how on the OnEndRequest method, the Module makes
sure to send the WWW-Authenticate header, which should bring the browser
into action:
Upon being properly authenticated, we store the roles
(permissions) for the newly authenticated user in the Identity Instance.
Since the browser has now cached and will automatically resend its valid
credentials with each request, even if we click on a hyperlink to another
page, we
are still authenticated, and we can capture the permissions for a user
with simple, built-in classes and methods:
Private Sub Page_Load(ByVal sender As System.Object,
ByVal e As System.EventArgs) Handles MyBase.Load
Response.Write("Howdy " & HttpContext.Current.User.Identity.Name)
Response.Write(" Admin: " & HttpContext.Current.User.IsInRole("Administrator").ToString())
End Sub
Supporting Programmatic Authentication with Digest:
Additionally, let's talk about
how to authentical with Digest programmatically: We'll
create a new page, sampleCredentials.aspx, whose sole purpose is to use
the WebRequest class to make a GET request
to a page that is protected by our Digest authentication scheme. First, we need to allow this page
to run (assuming that for testing purposes, it is located in the same
web application as the rest of the Digest Authentication pages). We
do this by empolying the location path elements in our web.config,
like so:
</system.web>
<location path="SampleCredentials.aspx">
<system.web><authorization>
<allow users="?" />
</authorization>
</system.web>
</location>
Note that I've placed this nodelist
just below the regular closing </system.web> tag. What
this says is that for the specific page only, allow anonymous access
(don't challenge with Digest). This is to allow the SampleCredentials.aspx
page to do the folowing:
Private Sub Page_Load(ByVal sender As System.Object,
ByVal e As System.EventArgs) Handles MyBase.Load
Dim query As String = "http://localhost/digestauthtestsvc/default.aspx"
Dim request As WebRequest = HttpWebRequest.Create(query)
Dim cc As CredentialCache = New CredentialCache()
request.Credentials = New NetworkCredential("test2", "test2")
Dim response As HttpWebResponse = request.GetResponse()
Dim r As StreamReader = New StreamReader(response.GetResponseStream(), Encoding.ASCII)
Page.Response.Write(r.ReadToEnd())
Response.Close()
r.Close()
End Sub
As can be seen above, it is trivial to programmatically
add credentials (including for Digest Authentication) to a WebRequest
instance. Indeed, the above will in fact return the Default.aspx page
to the browser, indicating that it has been successfully authenticated
as "test2". If we then click on the "Page 2" hperlink in the
default.aspx page that is presented, we get a new login Interface dialog.
Why? Simple - the digest authentication that allowed our browser to receive
the contents of default.aspx (a Digest - protected page) was performed
solely by the code in the WebRequest instance. The browser itself was never
presented with a Digest logon challenge in this case; consequently,
when it arrives on a new page that requires Digest authentication, it is
appropriately
required
to authenticate, having never before been given this opportunity.
Should you decide to download and and try either of the
below linked solultions, please bear in mind that certain preparations need to
be made:
1) With the local instance of
SQL Server running (and we must assume you still have the stock Northwind
database), Open Query Analyzer and load the NorthwindMods.sql script.
This will add the columns username varchar(15), password varchar(15)
and roles varchar(250) to the Northwind Employees table. It will also
create the AuthenticateUser Stored procedure, and finally will populate
the Employees table with two records having user / passwords of test
/test and test2/test2 respectively. Also, make sure that your Sql Server
connection information squares with the dbConn AppSettings element
in the web.config provided.
2) Finally, In IIS, you must make sure the virtual directory is an
Application, and go into the Directory Security tab and ensure that
ONLY - I repeat, ONLY Anonymous acess is on. Everything else, BASIC,
Digest, etc. must be OFF. Enjoy! The downloads below are in
VS.NET 2002 format to help everybody who hasn't quite made it to 2003
yet.
3) NOTE: I'm still seeing some glitches with this on
Windows Server 2003 and I haven't yet figured it out. I suspect it is
related to the way IIS 6.0 comes so totally "Locked down" out
of the box. However, I have gotten it to work fine using the Network
Service account as the default Application Pool Identity. It should work
fine on Windows 2000 and Windows XP also.
| Peter Bromberg is a C# MVP, MCP, and .NET consultant who has worked in the banking and financial industry for 20 years. He has architected and developed web - based corporate distributed application solutions since 1995, and focuses exclusively on the .NET Platform. |
|