Asp.Net: Authentication based on HTTP Digest protocol. Module Enhancement.
Author: Anatoly Lubarsky
Published: 22/12/2002
on aspnetmania.com
In the article "Authentication based on HTTP Basic protocol. HttpModule." I described the Basic authentication algorithm. With its help Basic Authentication role-based system was built without special confuguring of IIS server and using database to store users' credentials.
Authentication based Basic has one major downside - username and password are passed as clear text across the network, encoding base64 is not counted as secure system.
Digest Authentication
In this article, I will describe the Digest Authentication algorithm that remedies some problems, that Basic Authentication has. For example, this scheme does not pass password as clear text across the network. The official scheme name is - "Digest Access Authentication".
Let's enhance our mechanism from the previous article.
Digest has the following advantages:
- passwords are not passed as clear text across the network
- the ability to provide defense from replay attacks (monitoring http nc value)
- the ability to provide defense (monitoring nonce)
- in the certain length of time
- against a certain client
- against a certain request
One site can use several authentication systems simultaneously (i.e. Basic and Digest).
It's time to describe the Digest Authentication algorithm:
Step #1. The first request from the User Agent to the Http Server - Http header Authorization is empty - it means that the server has to return a header, requiring a user to authenticate. For example:
Step #2. Server response:
HTTP/1.1 401 Unauthorized WWW-Authenticate: Digest realm="testrealm@host.com", qop="auth", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41", algorithm=MD5, stale=false
Let's investigate WWW-Authenticate Http response header (as you can see, it is more complicated, than the one, used in Basic mechanism):
realm | A string to be displayed to users to inform them which username and password to use. For example "registered_users@gotham.news.com". |
nonce | A server-specified data string which should be uniquely generated each time a 401 response is made. Specifically, since the string is passed in the header lines as a quoted string, the double-quote character is not allowed. A nonce might, for example, be constructed as the base 64 encoding of time-stamp H(time-stamp ":" ETag ":" private-key) |
opaque | A string of data, specified by the server, which should be returned by the client unchanged in the Authorization header of subsequent requests with URIs in the same protection space. |
stale | true/false A flag, indicating that if true - the request was correct, username-password also correct, nonce was incorrect; if false or any other value or if not exists - the username-password are incorrect |
algorithm | optional, MD5 = default |
qop | If present, it is a quoted string of one or more tokens indicating the "quality of protection" values supported by the server. The value "auth" indicates authentication; the value "auth-int" indicates authentication with integrity protection. |
Step #3. The user gets a modal window, inviting him or her to input the username and the password (pay attention that the window differs from the one used in the Basic mechanism). After that Authentication request is performed by the user:
GET ... ... HTTP/1.1 Authorization: Digest username="Mufasa", realm="testrealm@host.com", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", uri="/dir/index.html", qop=auth, nc=00000001, cnonce="0a4f113b", response="6629fae49393a05397450978507c4ef1", opaque="5ccc069c403ebaf9f0171e9517f40e41"
Now let's investigate Authorization Http request header:
username | a user name |
realm | see WWW-Authenticate |
qop | see WWW-Authenticate (need to be equal to one of the list, specified in qop of WWW-Authenticate) |
algorithm | see WWW-Authenticate (need to be the same) |
opaque | see WWW-Authenticate (need to be the same) |
uri | a string, indicating request (for example a page) |
response | 32-character string - the password is checked with the help of this particular string. |
nonce | |
nc | nonce counter - specifies the number of times this specific nonce has been used. |
cnonce | A unique string, sent to the server by the browser. |
Step #4. The sequence of operations, to maintain user's request
- Check if Authorization request header exists
- Check if it is of Digest type
- Cut a word "Digest"
- Take the username (pay attention - there is no password in the header - at least we cannot see it)
- Check the user against the database, if exists - get his password from the database
- Check the request, using role based authentication against pages, as we did using Basic authentication mechanism.
If something is wrong - go to Step #6. If everything is ok - move forward (we still have a lot of work to do :))
Step #5. Check the password
- Create A1 string like
A1 = unq(username) ":" unq(realm) ":" passwd - Hash A1
HA1 = MD5(A1) - Create A2 string like
A2 = Http Method ":" digest-uri - Hash A2
HA2 = MD5(A2) - Create GENRESPONSE string like
GENRESPONSE = HA1 ":" nonce ":" nc ":" cnonce ":" qop ":" HA2 - Hash GENRESPONSE
HGENRESPONSE = MD5(GENRESPONSE)
if HGENRESPONSE is equal to the response of the Authorization request header and the nonce is correct - everything is ok, if not - go to step 6
Step #6. Set the server code of state to 401, show a modal window as in Step #2, in other words - build the Digest WWW-Authenticate response header.
HTTP Module AuthDigest
Ok, being armed with the theoretical background, listed above, let's write our own HttpModule, performing Digest Authentication.
// inherits from HttpModule public class AuthDigest : IHttpModule { public AuthDigest() { } public void Dispose() { } public void Init(HttpApplication application) { application.AuthenticateRequest += new EventHandler(this.OnAuthenticateRequest); application.EndRequest += new EventHandler(this.OnEndRequest); } // As usual, we use 2 methods: // OnAuthenticateRequest // OnEndRequest /* ##################################################### # # OnAuthenticateRequest # # ##################################################### */ public void OnAuthenticateRequest(object source, EventArgs eventArgs) { HttpApplication app = (HttpApplication) source; // get Authorization header; check if not empty string authorization = app.Request.Headers["Authorization"]; if ((authorization == null) || (authorization.Length == 0)) { AccessDenied(app); return; } // is it digest scheme ? authorization = authorization.Trim(); if (authorization.IndexOf("Digest", 0) != 0) { AccessDenied(app); return; } // get Header parts // write them to the ListDictionary object ListDictionary dictAuthHeaderContents = new ListDictionary(); dictAuthHeaderContents = getHeaderParts(authorization); // check the user against the Database (by roles) // if everything is ok - get the password // this approach is the main difference // from the Basic scheme // get user groups - // necessary for the GenericPrincipal instance string username = (string)dictAuthHeaderContents["username"]; string password = ""; string[] groups; if (!AuthenticateAgentDigest(app, username, out password, out groups)) { AccessDenied(app); return; } // see Step #5 of the Digest algorithm // check against Digest Scheme string realm = ConfigurationSettings.AppSettings ["HTTPDigest.Components.AuthDigest_Realm"]; // a) // A1 = unq(username-value) ":" unq(realm-value) ":" passwd string A1 = String.Format("{0}:{1}:{2}", (string)dictAuthHeaderContents["username"], realm, password); // b) // HA1 = MD5(A1) string HA1 = CvtHex(A1); // c) // A2 = HTTP Method ":" digest-uri-value string A2 = String.Format("{0}:{1}", app.Request.HttpMethod, (string)dictAuthHeaderContents["uri"]); // d) // HA2 = MD5(A2) string HA2 = CvtHex(A2); // e) // GENRESPONSE = // HA1 ":" nonce ":" nc ":" cnonce ":" qop ":" HA2 string GENRESPONSE; if (dictAuthHeaderContents["qop"] != null) { GENRESPONSE = String.Format("{0}:{1}:{2}:{3}:{4}:{5}", HA1, (string)dictAuthHeaderContents["nonce"], (string)dictAuthHeaderContents["nc"], (string)dictAuthHeaderContents["cnonce"], (string)dictAuthHeaderContents["qop"], HA2); } else { GENRESPONSE = String.Format("{0}:{1}:{2}", HA1, (string)dictAuthHeaderContents["nonce"], HA2); } string HGENRESPONSE = CvtHex(GENRESPONSE); // Check the nonce bool isNonceStale = !IsValidNonce((string)dictAuthHeaderContents["nonce"]); app.Context.Items["staleNonce"] = isNonceStale; // Check HGENRESPONSE // against the response of the Authorization header // Check the nonce // if everything is ok - // create GenericPrincipal instance, which contains // users groups string TestResponse = dictAuthHeaderContents["response"].ToString(); if ((TestResponse == HGENRESPONSE) && (!isNonceStale)) { app.Context.User = new GenericPrincipal( new GenericIdentity(username, "HTTPDigest.Components.AuthDigest"), groups); } else { AccessDenied(app); return; } } /* ###################################################### # # OnEndRequest # # set server response # WWW-Authenticate header (digest scheme) # build header string according to scheme # lift up modal window # ###################################################### */ public void OnEndRequest(object source, EventArgs eventArgs) { HttpApplication app = (HttpApplication) source; if (app.Response.StatusCode == 401) { // from config. string lRealm = ConfigurationSettings.AppSettings ["HTTPDigest.Components.AuthDigest_Realm"]; string lOpaque = ConfigurationSettings.AppSettings ["HTTPDigest.Components.AuthDigest_Opaque"]; string lAlgorithm = ConfigurationSettings.AppSettings ["HTTPDigest.Components.AuthDigest_Algorithm"]; string lQop = ConfigurationSettings.AppSettings ["HTTPDigest.Components.AuthDigest_Qop"]; // generate string lNonce = GenerateNonce(); bool isNonceStale = false; object staleObj = app.Context.Items["staleNonce"]; if (staleObj != null) isNonceStale = (bool)staleObj; // Show Digest modal window // build WWW-Authenticate server response header StringBuilder authHeader = new StringBuilder("Digest"); authHeader.Append(" realm=\""); authHeader.Append(lRealm); authHeader.Append("\""); authHeader.Append(", nonce=\""); authHeader.Append(lNonce); authHeader.Append("\""); authHeader.Append(", opaque=\""); authHeader.Append(lOpaque); authHeader.Append("\""); authHeader.Append(", stale="); authHeader.Append(isNonceStale ? "true" : "false"); authHeader.Append(", algorithm=\""); authHeader.Append(lAlgorithm); authHeader.Append("\""); authHeader.Append(", qop=\""); authHeader.Append(lQop); authHeader.Append("\""); app.Response.AppendHeader("WWW-Authenticate", authHeader.ToString()); // Set response state to 401 app.Response.StatusCode = 401; } } /* ######################################################## # # GenerateNonce # # generate unique server nonce # ######################################################## */ protected virtual string GenerateNonce() { // Create a unique nonce - // the simpliest version // Now + 3 minutes, encoded base64 // The nonce validity check // will be performed also against the time // More strong example of nonce - // use additionally ETag and unique key, which is // known by the server DateTime nonceTime = DateTime.Now + TimeSpan.FromMinutes(3); string expireStr = nonceTime.ToString("G"); Encoding enc = new ASCIIEncoding(); byte[] expireBytes = enc.GetBytes(expireStr); string nonce = Convert.ToBase64String(expireBytes); // base64 adds "=" // sign, which is forbidden by the server // cut it nonce = nonce.TrimEnd(new Char[] {'='}); return nonce; } /* ######################################################## # # IsValidNonce # # string nonce : in # ######################################################## */ protected virtual bool IsValidNonce(string nonce) { // Check nonce validity // decode from base64 and check // This implementation uses a simple version - // thats why the check is simple also - // check against the time DateTime expireTime; int numPadChars = nonce.Length % 4; if (numPadChars > 0) numPadChars = 4 - numPadChars; string newNonce = nonce.PadRight(nonce.Length + numPadChars, '='); try { byte[] decodedBytes = Convert.FromBase64String(newNonce); string preNonce = new ASCIIEncoding().GetString(decodedBytes); expireTime = DateTime.Parse(preNonce); } catch (FormatException) { return false; } return (expireTime >= DateTime.Now); } /* ######################################################## # # CvtHex - hashes strings # # string sToConvert : in # string SConverted : out # ######################################################## */ private string CvtHex(string sToConvert) { // Hashing Encoding enc = new ASCIIEncoding(); MD5 md5 = new MD5CryptoServiceProvider(); byte[] bToConvert = md5.ComputeHash(enc.GetBytes(sToConvert)); string sConverted = ""; for (int i = 0 ; i < 16 ; i++) sConverted += String.Format("{0:x02}", bToConvert[i]); return sConverted; } /* ######################################################## # # getHeaderParts(string authorization) # # convert Authorization header # from string to Dictionary # string authorization : in # ListDictionary : out # ######################################################## */ private ListDictionary getHeaderParts(string authorization) { // A method, that converts // HTTP header string with all its contents // to the ListDictionary object ListDictionary dict = new ListDictionary(); string[] parts = authorization.Substring(7).Split(new char[] {','}); foreach (string part in parts) { string[] subParts = part.Split(new char[] {'='}, 2); string key = subParts[0].Trim(new char[] {' ', '\"'}); string val = subParts[1].Trim(new char[] {' ', '\"'}); dict.Add(key, val); } return dict; } /* ######################################################## # # AccessDenied # 401 - Access Denied # # app in; # HttpApplication # ######################################################## */ private void AccessDenied(HttpApplication app) { // Access denied // Write to the browser app.Response.StatusCode = 401; app.Response.StatusDescription = "Access Denied"; app.Response.Write("401 Access Denied"); app.CompleteRequest(); } // The next method checks the credentials // against the database // based on roles // if everything is ok, // returns true and the users list of groups // that is necessary to create the instance of // GenericPrincipal // + also returns his password - // this is the difference from the Basic scheme /* ######################################################## # # AuthenticateAgentDigest # # Authenticates Agent, returns true/false # app in; HttpApplication # User in; username # Password out; password to hash then and check # groups out; agent groups to create GenericPrincipal # ######################################################## */ protected virtual bool AuthenticateAgentDigest(HttpApplication app, string username, out string password, out string[] groups) { password = ""; groups = null; int lagentID = 0; string lpageURL = ""; // instance of a class, that works with databse // source code attached SqlDataProvider dataProvider = new SqlDataProvider(); // check if the specified user exists // if yes - get his password // get agent if exists lagentID = dataProvider.getAgentByUsername(username, out password); if (lagentID == 0) return false; // check if the specified user has groups // get agent groups ArrayList arrAgentsGroups = new ArrayList(); arrAgentsGroups = dataProvider.getGroupsByAgentID(lagentID); if (arrAgentsGroups.Count == 0) return false; // check if requested page has groups // get pages groups lpageURL = app.Request.Path; ArrayList arrPagesGroups = new ArrayList(); arrPagesGroups = dataProvider.getGroupsByPageURL(lpageURL); if (arrPagesGroups.Count == 0) return false; // check if at least one user group // is in Page Groups List // if yes, return true // check if at least one agent group // is in Page Groups List string[] pagegroups = (string[])arrPagesGroups.ToArray(typeof(string)); groups = (string[]) arrAgentsGroups.ToArray(typeof(string)); foreach (string groupagentID in groups) { foreach (string grouppageID in pagegroups) { if (groupagentID == grouppageID) return true; } } return false; } }
That's it. HttpModule is ready. Let's create a configuration repository of it. In web.config under system.web let's write:
< HttpModules > < add type="HttpDigest.Components.AuthDigest, HttpDigest" name="DigestAuthenticationModule" / >
Cancelling built-in authentication
< Authentication mode="None" / > < Authorization > < deny users="?" / >
And save the essential data used by the Digest Authentication scheme (realm string, algorithm, qop string, server opaque)
< AppSettings > < add value="testrealm@host.com" key="HttpDigest.Components.AuthDigest_Realm" / > < add value="5ccc069c403ebaf9f0171e9517f40e41" key="HttpDigest.Components.AuthDigest_Opaque" / > < add value="MD5" key="HttpDigest.Components.AuthDigest_Algorithm" / > < add value="auth" key="HttpDigest.Components.AuthDigest_Qop" / >
Thats all.
The Digest Access Authentication scheme is not intended to be a complete answer to the need for security on the World Wide Web. Digest Authentication offers no confidentiality protection beyond protecting the actual password. However, it is significantly stronger than (e.g.) CRAM-MD5, which has been proposed for use with LDAP, POP and IMAP. It is intended to provide an authentication system, simple and flexible as Basic, and at the same time to remedy the weaknesses of the Basic authentication mechanism.
Class, that works with the database and database scripts are inside the source code, is attached.
Materials and credits: rfc2617, Marty Hall, Greg Reinacker, aspnetmania.com
Saturday, November 29, 2003 5:49 AM