HTTP Basic and Digest authentication with PHP

HTTP authentication is quite popular for web applications. It is pretty
easy to implement and works for a range of http applications; not to mention
your browser.

Basic Auth

The two main authentication schemes are 'basic' and 'digest'. Basic is pretty
easy to implement and appears to be the most common:

  1. <?php
  2.  
  3. $username = null;
  4. $password = null;
  5.  
  6. // mod_php
  7. if (isset($_SERVER['PHP_AUTH_USER'])) {
  8.     $username = $_SERVER['PHP_AUTH_USER'];
  9.     $password = $_SERVER['PHP_AUTH_PW'];
  10.    
  11. // most other servers
  12. } elseif (isset($_SERVER['HTTP_AUTHENTICATION'])) {
  13.    
  14.         if (strpos(strtolower($_SERVER['HTTP_AUTHENTICATION']),'basic')===0)
  15.           list($username,$password) = explode(':',base64_decode(substr($_SERVER['HTTP_AUTHORIZATION'], 6)));
  16.          
  17. }
  18.  
  19. if (is_null($username)) {
  20.    
  21.     header('WWW-Authenticate: Basic realm="My Realm"');
  22.     header('HTTP/1.0 401 Unauthorized');
  23.     echo 'Text to send if user hits Cancel button';
  24.    
  25.     die();
  26.    
  27. } else {
  28.     echo "<p>Hello {$username}.</p>";
  29.     echo "<p>You entered {$password} as your password.</p>";
  30. }
  31.  
  32. ?>

Well it's a bit difficult I suppose, but you might have noticed the username
and password are sent over the wire using base64 encoding. Not really secure, unless
you have SSL in place.

Digest

Digest is designed to be more secure. The password is never sent over the wire
in plain text, but rather as a hash. The implications of the usage of a hash is that
it can never be decrypted. We can only validate the hash by applying the same hash function
to the password we have. If the hashes match, the password was correct.

Lets first see how Digest auth should work:

Client requests url

  1. GET / HTTP/1.1

Server requires authentication

  1. HTTP/1.1 401 Unauthorized
  2. WWW-Authenticate: Digest realm="The batcave",
  3.   qop="auth",
  4.   nonce="4993927ba6279",
  5.   opaque="d8ea7aa61a1693024c4cc3a516f49b3c"

Client authenticates

  1. GET / HTTP/1.1
  2. Authorization: Digest username="admin",
  3.   realm="The batcave",
  4.   nonce=49938e61ccaa4,
  5.   uri="/",
  6.   response="98ccab4542f284c00a79b5957baaff23",
  7.   opaque="d8ea7aa61a1693024c4cc3a516f49b3c",
  8.   qop=auth, nc=00000001,
  9.   cnonce="8d1b34edb475994b"

Information coming from the server:

realmA string which will be used within the UI and as part of the hash.
qopCan be auth and auth-int and has influence on how the hash is created. We use auth.
nonceA unique code, which will be used within the hash and needs to be sent back by the client.
opaqueThis can be treated as a session id. If this changes the browser will deauthenticate the user.

Information from the client:

usernameThe supplied username
realmSame as server response.
nonceSame as server response.
uriThe authentication uri
responseThe validation hash.
opaqueSame as server response.
qopSame as server response.
ncNonce-count. This a hexadecimal serial number for the request. The client should increase this number by one for every request.
cnonceA unique id generated by the client

So how do we know if the password was correct? We van validate using the following formula (pseudo code).

  1. A1 = md5(username:realm:password)
  2. A2 = md5(request-method:uri) // request method = GET, POST, etc.
  3. Hash = md5(A1:nonce:nc:cnonce:qop:A2)
  4.  
  5. if (Hash == response)
  6.   //success!
  7. else
  8.   //failure!

Or, using PHP:

  1. <?php
  2.  
  3. $realm = 'The batcave';
  4.  
  5. // Just a random id
  6. $nonce = uniqid();
  7.  
  8. // Get the digest from the http header
  9. $digest = getDigest();
  10.  
  11. // If there was no digest, show login
  12. if (is_null($digest)) requireLogin($realm,$nonce);
  13.  
  14. $digestParts = digestParse($digest);
  15.  
  16. $validUser = 'admin';
  17. $validPass = '1234';
  18.  
  19. // Based on all the info we gathered we can figure out what the response should be
  20. $A1 = md5("{$validUser}:{$realm}:{$validPass}");
  21. $A2 = md5("{$_SERVER['REQUEST_METHOD']}:{$digestParts['uri']}");
  22.  
  23. $validResponse = md5("{$A1}:{$digestParts['nonce']}:{$digestParts['nc']}:{$digestParts['cnonce']}:{$digestParts['qop']}:{$A2}");
  24.  
  25. if ($digestParts['response']!=$validResponse) requireLogin($realm,$nonce);
  26.  
  27. // We're in!
  28. echo 'Well done sir, you made it all the way through the login!';
  29.  
  30. // This function returns the digest string
  31. function getDigest() {
  32.  
  33.     // mod_php
  34.     if (isset($_SERVER['PHP_AUTH_DIGEST'])) {
  35.         $digest = $_SERVER['PHP_AUTH_DIGEST'];
  36.     // most other servers
  37.     } elseif (isset($_SERVER['HTTP_AUTHENTICATION'])) {
  38.      
  39.             if (strpos(strtolower($_SERVER['HTTP_AUTHENTICATION']),'digest')===0)
  40.               $digest = substr($_SERVER['HTTP_AUTHORIZATION'], 7);
  41.     }
  42.  
  43.     return $digest;
  44.  
  45. }
  46.  
  47. // This function forces a login prompt
  48. function requireLogin($realm,$nonce) {
  49.     header('WWW-Authenticate: Digest realm="' . $realm . '",qop="auth",nonce="' . $nonce . '",opaque="' . md5($realm) . '"');
  50.     header('HTTP/1.0 401 Unauthorized');
  51.     echo 'Text to send if user hits Cancel button';
  52.     die();
  53. }
  54.  
  55. // This function extracts the separate values from the digest string
  56. function digestParse($digest) {
  57.     // protect against missing data
  58.     $needed_parts = array('nonce'=>1, 'nc'=>1, 'cnonce'=>1, 'qop'=>1, 'username'=>1, 'uri'=>1, 'response'=>1);
  59.     $data = array();
  60.  
  61.     preg_match_all('@(\w+)=(?:(?:")([^"]+)"|([^\s,$]+))@', $digest, $matches, PREG_SET_ORDER);
  62.  
  63.     foreach ($matches as $m) {
  64.         $data[$m[1]] = $m[2] ? $m[2] : $m[3];
  65.         unset($needed_parts[$m[1]]);
  66.     }
  67.  
  68.     return $needed_parts ? false : $data;
  69. }
  70.  
  71. ?>

As you can see we need to have a plain-text version of the password in order to
validate the user. It's not a good idea to store the plain-text password, therefore
it's strongly recommended to store the result of $A1 instead.

Security improvements

  • It's smart to validate the contents of opaque, nonce and realm. If you have
    the data stored on the server, why not check it.
  • The nc should be an ever increasing number. You could store the number and
    track to make sure it doesn't make any big jumps. It's not wanted to be extremely
    strict about the sequence, because you might miss a number, and requests could come in
    be out of order.
  • 'qop' is quality of protection. This serves as an integrity code for the request.
    A hacker could steal all your HTTP Digest headers and simply change the body to make it do
    something else. If 'qop' is set to 'auth', only the requested uri will be taken
    into consideration. If 'qop' is 'auth-int' the body of the request will also be used in the hash. (A2 = md5(request-method:uri:md5(request-body))).

References:


11 Responses to HTTP Basic and Digest authentication with PHP

  1. 903 Richard Heyes 2009-02-12 10:26 am

    The PEAR package Auth_SASL I wrote may be of some interest, implementing a variety of SASL authentication methods: http://pear.php.net/package/Auth_SASL

  2. 904 sak 2009-02-12 1:33 pm

    the problem with HTTP auth is how do you logout ?

  3. 905 Scott MacVicar 2009-02-12 1:36 pm

    Your digest authentication is open to a replay attack, you can do a few things to solve this.

    1. Keep track of any previous nonce values and make sure they're not re-used.
    2. Attach a timestamp to the nonce and if a timestamp it outwith a defined time frame, reject it.
    3. Add in the IP address as part of the nonce and check it on the next request.

    I recommend a combination of at least two though.

  4. 906 Evert 2009-02-12 3:50 pm

    Richard, I'll definitely take a look. Especially in relation to some of the points Scott just mentioned :)

    Sak, the trick is to just trigger another HTTP 401. If you do this, the user will be forced to reauthenticate.

    Scott,
    1 & 2 make sense, both definitely a bit tedious to implement. Curious to how other people do this..

  5. 907 Dan 2009-02-26 1:06 am

    I am curious too about how to logout.
    Any ideas on this? I don't want to just log in as one user.
    Is it possible to set a bad nonce on log out request?

  6. 908 Evert 2009-02-26 3:52 pm

    As mentioned before, all you really need to do is trigger a 401.

    As a starting point you could use:

    http://code.google.com/p/sabredav/source/browse/trunk/lib/Sabre/HTTP/

    And call ->requireLogin();

  7. 909 Max 2009-03-30 12:48 pm

    About logout.
    I can throw 401 but trouble happens when user press "cancel" on authentication form and then browser's "back". In this case browser does not purge authentication cache and user still logged in.

  8. 910 Evandro Oliveira 2009-07-03 1:53 pm

    Hi Evert. Nice help! First time here, and obviously found ur article via google...
    Wanna suggest a little correction:
    "(...)
    $A1 = md5("{$digestParts['username']}:{$realm}(...)"

    $digestParts['username'] should be replaced by $validUser, don't???

    else any username'll be validated by the correct pwd. Changed it here and sucessfully works.

    a simple workaround to do logout function is merge http-auth with php session... just start a session at line 1 and create an if to check a logout function to close the section....

    sorry 4 bad english... and thank you again...

  9. 911 Evert 2009-07-03 2:56 pm

    Evandro, that's a very good point. I'm fixing this :)

  10. 912 Evert 2009-07-03 3:19 pm

    Note that in real-life, you would actually check the username coming in from the request, then fetch the password information.

    So the only reason why this would be somewhat of a security issue, is if you really only just have 1 hardcoded username + pass.

  11. 4560 What happened to HTTP authentication? - Het Bijstere Spoor 2010-07-12 2:29 pm

    ...ably know that HTTP also has native authentication, in the form of Basic and Digest authentication (read my older article if you want to know how). Every browser supports it, and as a bonus pretty much every other HTTP cl...

Leave a Reply



About

My name is Evert, and I've been writing semi-regularly on this blog since 2006.

I'm currently available for contract work.

more info.

Subscribe

Dropbox

Dropbox is a simple cross-platform online backup and sync application. The first 2GB of space is free, and both you and me get an extra 250MB extra space if you sign up through this link.