PHP HTTP Digest

The most common way to create a secure login system to a Web application is to use SSL to encrypt the HTTP connection. SSL is great for protecting data in transit and making sure it gets to who you actually wanted it too, but sometimes you just want to protect your password from being passed in plain text.

SSL is great, but it is a bit of an overhead for just protecting a cookie value. The thing with SSL is that it sits between TCP and HTTP meaning that all traffic sent over a HTTPS connection is encrypted, which could be a lot of data for just securing a 32 character password string. I don't want to spend my CPU cycles encrypting everything that goes over the wire, if I'm using cookies to hold user information between requests then to get that cookie sent encrypted I need to return all my data encrypted also.

So what alternatives are there? Well we could use Javascript to hash our password along with a salt we pass back and forth, but then the client would have to support Javascript and we'd have to write a hashing algorithm in Javascript since there's no native hash algorithm.

There already is a solution, HTTP Digest. HTTP Digest is the HTTP way of making sure a client knows a valid username and password for a resource without sending the password over the wire.

Digestion

HTTP Digest is an authentication scheme that never passes the password over the wire, instead the client creates a hash in such a way that the server can verify it knows the password. It doesn't solve all the problems, it is still possible for someone to hijack our session on route, but it does enough to ensure that our password cannot be found out. It's kind of complex, but it basically works like this:

  1. Server sends an auth challenge with a value unique to that transaction known as the nonce value. The nonce can be anything, but is generally generated from a timestamp in such a way as to go out of date.
  2. The client generates a digest by hashing the username, HTTP realm and password together, this is known as the "a1" digest. It then creates a second digest of the method and URL of the required HTTP request known as the "a2" digest. Finally it creates a response digest by hashing together the a1 and a2 digests with the nonce value sent by the server. It sends this as the response to the servers challenge along with the nonce value and username in plain text.
  3. The server, knowing the "a1" digest for the given user and presuming that the nonce value isn't stale (out of date), builds a response of its own and compares it to the clients response. If they match then the client must know the password and the server sends the URLs representation.

Note that this is a over-simplification of HTTP Digest, but you get the general idea.

So if HTTP Digest is so good, why is it not used everywhere? Well for a number of reasons:

Complexity
HTTP Digest is complex compaired to HTTP Basic, and since SSL does everything Digest does plus more, so SSL has taken off as the ultimate solution leaving Digest in its wake.
Browser support
Older browser manufacturers didn't have the time or energy to support an obscure piece of the HTTP specification that no one saw any reason for using. These days all modern browsers support Digest, not all perfectly, but well enough for us to get by.
Browser dialogs
Who designed the HTTP Auth dialog box in Internet Explorer? Other browsers don't do much better (except Safari whose at least looks like a regular dialog box) and since we live in a World full of designers with no respect for the Web and how it works, HTTP Auth has been sidelined for HTML forms and Netscapes wonderful invention, the cookie.

Now that the Web has grown up and we're starting to use it like it was designed to be used, it's time for HTTP Digest to finally find it's place in the Web ecosystem and as such I've put together a PHP class to implement HTTP Digest since PHP provides no native support for it.

The PHP class

<?php class HTTPDigest { /** The Digest opaque value (any string will do, never sent in plain text over the wire). * @var str */ var $opaque = 'opaque'; /** The authentication realm name. * @var str */ var $realm = 'Realm'; /** The base URL of the application, auth data will be used for all resources under this URL. * @var str */ var $baseURL = '/'; /** Are passwords stored as an a1 hash (username:realm:password) rather than plain text. * @var str */ var $passwordsHashed = TRUE; /** The private key. * @var str */ var $privateKey = 'privatekey'; /** The life of the nonce value in seconds * @var int */ var $nonceLife = 300; /** Send HTTP Auth header */ function send() { header('WWW-Authenticate: Digest '. 'realm="'.$this->realm.'", '. 'domain="'.$this->baseURL.'", '. 'qop=auth, '. 'algorithm=MD5, '. 'nonce="'.$this->getNonce().'", '. 'opaque="'.$this->getOpaque().'"' ); header('HTTP/1.0 401 Unauthorized'); } /** Authenticate the user and return username on success. * @param str[] users Array of username/password pairs * @return str */ function authenticate($users) { if (isset($_SERVER['Authorization'])) { $authorization = $_SERVER['Authorization']; } elseif (function_exists('apache_request_headers')) { $headers = apache_request_headers(); if (isset($headers['Authorization'])) { $authorization = $headers['Authorization']; } } else { trigger_error('HTTP Digest headers not being passed to PHP by the server, unable to authenticate user'); exit; } if (isset($authorization)) { if (substr($authorization, 0, 5) == 'Basic') { trigger_error('You are trying to use HTTP Basic authentication but I am expecting HTTP Digest'); exit; } if ( preg_match('/username="([^"]+)"/', $authorization, $username) && preg_match('/nonce="([^"]+)"/', $authorization, $nonce) && preg_match('/response="([^"]+)"/', $authorization, $response) && preg_match('/opaque="([^"]+)"/', $authorization, $opaque) && preg_match('/uri="([^"]+)"/', $authorization, $uri) ) { $username = $username[1]; $requestURI = $_SERVER['REQUEST_URI']; if (strpos($requestURI, '?') !== FALSE) { // hack for IE which does not pass querystring in URI element of Digest string or in response hash $requestURI = substr($requestURI, 0, strlen($uri[1])); } if ( isset($users[$username]) && $opaque[1] == $this->getOpaque() && $uri[1] == $requestURI && $nonce[1] == $this->getNonce() ) { $passphrase = $users[$username]; if ($this->passwordsHashed) { $a1 = $passphrase; } else { $a1 = md5($username.':'.$this->getRealm().':'.$passphrase); } $a2 = md5($_SERVER['REQUEST_METHOD'].':'.$requestURI); if ( preg_match('/qop="?([^,\s"]+)/', $authorization, $qop) && preg_match('/nc=([^,\s"]+)/', $authorization, $nc) && preg_match('/cnonce="([^"]+)"/', $authorization, $cnonce) ) { $expectedResponse = md5($a1.':'.$nonce[1].':'.$nc[1].':'.$cnonce[1].':'.$qop[1].':'.$a2); } else { $expectedResponse = md5($a1.':'.$nonce[1].':'.$a2); } if ($response[1] == $expectedResponse) { return $username; } } } } return NULL; } /** Get nonce value for HTTP Digest. * @return str */ function getNonce() { $time = ceil(time() / $this->nonceLife) * $this->nonceLife; return md5(date('Y-m-d H:i', $time).':'.$_SERVER['REMOTE_ADDR'].':'.$this->privateKey); } /** Get opaque value for HTTP Digest. * @return str */ function getOpaque() { return md5($this->opaque); } /** Get realm for HTTP Digest taking PHP safe mode into account. * @return str */ function getRealm() { if (ini_get('safe_mode')) { return $this->realm.'-'.getmyuid(); } else { return $this->realm; } } }

Usage

To use this class is easy, just instanciate the class and if the authenticate() method returns a username then the authentication was successful. If not, use the send() method to send the HTTP Digest challenge header.

<?php $users = array( // username => hashed password 'username' => md5('username:'.$HTTPDigest->getRealm().':password') ); $HTTPDigest =& new HTTPDigest(); if ($authed = $HTTPDigest->authenticate($users)) { echo sprintf('Logged in as "%s"', $authed); } else { $HTTPDigest->send(); echo 'Not logged in'; } ?>

Details & problems

There are a few things to note about this implementation:

Nonce life
The nonce value is calculated from the time, the clients IP address and a private key. This means that it protects against reply attacks, but not man in the middle attacks. It also means that the nonce value is valid only for a certain length (the nonceLength member) of time before it becomes stale and a re-authentication is required with a new nonce value.
PHP safe mode
If your PHP installation is in safe mode, then the HTTP realm is appended with the uid of the user running the script. The class takes this into account transparently, but if you are storing your passwords hashed then you must use the correct realm when hashing them.
HTTP Authorisation header
Apache does not pass the authorisation header to PHP, so we use the apache_request_headers() function to grab all the request headers from Apache. On some servers, this function does not manage to get the authorisation header and as such this code does not work, I'm not sure why this is the case on some web servers. If it is not working and you think it should, check the contents of apache_request_headers() after sending a digest response to make sure the authorisation is being recieved by PHP.

Download & license

The HTTP Digest PHP class can be downloaded here.

The code is freely available under the BSD License.