# Copyright (c) 2007, Kundan Singh. All rights reserved. See LICENSING for details.

This file implements RFC2617 (HTTP auth)

'''
The HTTP basic and digest access authentication as per RFC 2617.
'''

from random import randint
from hashlib import md5
from base64 import b64encode
import time


From RFC2617 p.3
   HTTP provides a simple challenge-response authentication mechanism
   that MAY be used by a server to challenge a client request and by a
   client to provide authentication information. It uses an extensible,
   case-insensitive token to identify the authentication scheme,
   followed by a comma-separated list of attribute-value pairs which
   carry the parameters necessary for achieving authentication via that
   scheme.

      auth-scheme    = token
      auth-param     = token "=" ( token | quoted-string )
_quote   = lambda s: '"' + s + '"' if not s or s[0] != '"' != s[-1] else s
_unquote = lambda s: s[1:-1] if s and s[0] == '"' == s[-1] else s

def createAuthenticate(authMethod='Digest', **kwargs):
    '''Build the WWW-Authenticate header's value.
    >>> print createAuthenticate('Basic', realm='iptel.org')
    Basic realm="iptel.org"
    >>> print createAuthenticate('Digest', realm='iptel.org', domain='sip:iptel.org', nonce='somenonce')
    Digest realm="iptel.org", domain="sip:iptel.org", qop="auth", nonce="somenonce", opaque="", stale=FALSE, algorithm=MD5
    '''
    if authMethod.lower() == 'basic':
        return 'Basic realm=%s'%(_quote(kwargs.get('realm', '')))
    elif authMethod.lower() == 'digest':
        predef = ('realm', 'domain', 'qop', 'nonce', 'opaque', 'stale', 'algorithm')
        unquoted = ('stale', 'algorithm')
        now = time.time(); nonce = kwargs.get('nonce', b64encode('%d %s'%(now, md5('%d:%d'%(now, id(createAuthenticate))))))
        default = dict(realm='', domain='', opaque='', stale='FALSE', algorithm='MD5', qop='auth', nonce=nonce)
        kv = map(lambda x: (x, kwargs.get(x, default[x])), predef) + filter(lambda x: x[0] not in predef, kwargs.items()) # put predef attributes in order before non predef attributes
        return 'Digest ' + ', '.join(map(lambda y: '%s=%s'%(y[0], _quote(y[1]) if y[0] not in unquoted else y[1]), kv))
    else: raise ValueError, 'invalid authMethod%s'%(authMethod)
    

From RFC2617 p.3
   The 401 (Unauthorized) response message is used by an origin server
   to challenge the authorization of a user agent. This response MUST
   include a WWW-Authenticate header field containing at least one
   challenge applicable to the requested resource. The 407 (Proxy
   Authentication Required) response message is used by a proxy to
   challenge the authorization of a client and MUST include a Proxy-
   Authenticate header field containing at least one challenge
   applicable to the proxy for the requested resource.

      challenge   = auth-scheme 1*SP 1#auth-param
From RFC2617 p.4
   A user agent that wishes to authenticate itself with an origin
   server--usually, but not necessarily, after receiving a 401
   (Unauthorized)--MAY do so by including an Authorization header field
   with the request. A client that wishes to authenticate itself with a
   proxy--usually, but not necessarily, after receiving a 407 (Proxy
   Authentication Required)--MAY do so by including a Proxy-
   Authorization header field with the request.  Both the Authorization
   field value and the Proxy-Authorization field value consist of
   credentials containing the authentication information of the client
   for the realm of the resource being requested. The user agent MUST
   choose to use one of the challenges with the strongest auth-scheme it
   understands and request credentials from the user based upon that
   challenge.

   credentials = auth-scheme #auth-param
def createAuthorization(challenge, username, password, uri=None, method=None, entityBody=None, context=None):
    '''Build the Authorization header for this challenge. The challenge represents the
    WWW-Authenticate header's value and the function returns the Authorization
    header's value. The context (dict) is used to save cnonce and nonceCount
    if available. The uri represents the request URI str, and method the request
    method. The result contains the properties in alphabetical order of property name.
    
    >>> context = {'cnonce':'0a4f113b', 'nc': 0}
    >>> print createAuthorization('Digest realm="testrealm@host.com", qop="auth", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41"', 'Mufasa', 'Circle Of Life', '/dir/index.html', 'GET', None, context)
    Digest cnonce="0a4f113b",nc=00000001,nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",opaque="5ccc069c403ebaf9f0171e9517f40e41",qop=auth,realm="testrealm@host.com",response="6629fae49393a05397450978507c4ef1",uri="/dir/index.html",username="Mufasa"
    >>> print createAuthorization('Basic realm="WallyWorld"', 'Aladdin', 'open sesame')
    Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
    '''
    authMethod, sep, rest = challenge.strip().partition(' ')
    ch, cr = dict(), dict() # challenge and credentials
    cr['password']   = password
    cr['username']   = username
    

From RFC2617 p.5
   The "basic" authentication scheme is based on the model that the
   client must authenticate itself with a user-ID and a password for
   each realm.  The realm value should be considered an opaque string
   which can only be compared for equality with other realms on that
   server. The server will service the request only if it can validate
   the user-ID and password for the protection space of the Request-URI.
   There are no optional authentication parameters.

   For Basic, the framework above is utilized as follows:

      challenge   = "Basic" realm
      credentials = "Basic" basic-credentials

   Upon receipt of an unauthorized request for a URI within the
   protection space, the origin server MAY respond with a challenge like
   the following:

      WWW-Authenticate: Basic realm="WallyWorld"

   where "WallyWorld" is the string assigned by the server to identify
   the protection space of the Request-URI. A proxy may respond with the
   same challenge using the Proxy-Authenticate header field.
    if authMethod.lower() == 'basic':
        return authMethod + ' ' + basic(cr)

From RFC2617 p.6
   Like Basic Access Authentication, the Digest scheme is based on a
   simple challenge-response paradigm. The Digest scheme challenges
   using a nonce value. A valid response contains a checksum (by
   default, the MD5 checksum) of the username, the password, the given
   nonce value, the HTTP method, and the requested URI. In this way, the
   password is never sent in the clear. Just as with the Basic scheme,
   the username and password must be prearranged in some fashion not
   addressed by this document.
    elif authMethod.lower() == 'digest':
        for n,v in map(lambda x: x.strip().split('='), rest.split(',') if rest else []):
            ch[n.lower().strip()] = _unquote(v.strip())
        # TODO: doesn't work if embedded ',' in value, e.g., qop="auth,auth-int"

From RFC2617 p.8
   If a server receives a request for an access-protected object, and an
   acceptable Authorization header is not sent, the server responds with
   a "401 Unauthorized" status code, and a WWW-Authenticate header as
   per the framework defined above, which for the digest scheme is
   utilized as follows:

      challenge        =  "Digest" digest-challenge

      digest-challenge  = 1#( realm | [ domain ] | nonce |
                          [ opaque ] |[ stale ] | [ algorithm ] |
                          [ qop-options ] | [auth-param] )


      domain            = "domain" "=" <"> URI ( 1*SP URI ) <">
      URI               = absoluteURI | abs_path
      nonce             = "nonce" "=" nonce-value
      nonce-value       = quoted-string
      opaque            = "opaque" "=" quoted-string
      stale             = "stale" "=" ( "true" | "false" )
      algorithm         = "algorithm" "=" ( "MD5" | "MD5-sess" |
                           token )
      qop-options       = "qop" "=" <"> 1#qop-value <">
      qop-value         = "auth" | "auth-int" | token
        for y in filter(lambda x: x in ch, ['username', 'realm', 'nonce', 'opaque', 'algorithm']):
            cr[y] = ch[y]
        cr['uri']        = uri
        cr['httpMethod'] = method
        if 'qop' in ch:
            if context and 'cnonce' in context:
                cnonce, nc = context['cnonce'], context['nc'] + 1
            else:
                cnonce, nc = H(str(randint(0, 2**31))), 1
            if context:
                context['cnonce'], context['nc'] = cnonce, nc
            cr['qop'], cr['cnonce'], cr['nc'] = 'auth', cnonce, '%08x'% nc
    

From RFC2617 p.11
   The client is expected to retry the request, passing an Authorization
   header line, which is defined according to the framework above,
   utilized as follows.

       credentials      = "Digest" digest-response
       digest-response  = 1#( username | realm | nonce | digest-uri
                       | response | [ algorithm ] | [cnonce] |
                       [opaque] | [message-qop] |
                           [nonce-count]  | [auth-param] )

       username         = "username" "=" username-value
       username-value   = quoted-string
       digest-uri       = "uri" "=" digest-uri-value
       digest-uri-value = request-uri   ; As specified by HTTP/1.1
       message-qop      = "qop" "=" qop-value
       cnonce           = "cnonce" "=" cnonce-value
       cnonce-value     = nonce-value
       nonce-count      = "nc" "=" nc-value
       nc-value         = 8LHEX
       response         = "response" "=" request-digest
        cr['response'] = digest(cr)
        items = sorted(filter(lambda x: x not in ['name', 'authMethod', 'value', 'httpMethod', 'entityBody', 'password'], cr))
        return authMethod + ' ' + ','.join(map(lambda y: '%s=%s'%(y, (cr[y] if y == 'qop' or y == 'nc' else _quote(cr[y]))), items))
    else:
        raise ValueError, 'Invalid auth method -- ' + authMethod



From RFC2617 p.10
     In this document the string obtained by applying the digest
     algorithm to the data "data" with secret "secret" will be denoted
     by KD(secret, data), and the string obtained by applying the
     checksum algorithm to the data "data" will be denoted H(data). The
     notation unq(X) means the value of the quoted-string X without the
     surrounding quotes.

     For the "MD5" and "MD5-sess" algorithms

         H(data) = MD5(data)

     and

         KD(secret, data) = H(concat(secret, ":", data))
H = lambda d: md5(d).hexdigest()
KD = lambda s, d: H(s + ':' + d)


From RFC2617 p.18
   The first time the client requests the document, no Authorization
   header is sent, so the server responds with:

         HTTP/1.1 401 Unauthorized
         WWW-Authenticate: Digest
                 realm="testrealm@host.com",
                 qop="auth,auth-int",
                 nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
                 opaque="5ccc069c403ebaf9f0171e9517f40e41"

   The client may prompt the user for the username and password, after
   which it will respond with a new request, including the following
   Authorization header:


         Authorization: Digest username="Mufasa",
                 realm="testrealm@host.com",
                 nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093",
                 uri="/dir/index.html",
                 qop=auth,
                 nc=00000001,
                 cnonce="0a4f113b",
                 response="6629fae49393a05397450978507c4ef1",
                 opaque="5ccc069c403ebaf9f0171e9517f40e41"
def digest(cr):
    '''Create a digest response for the credentials.
    
    >>> input = {'httpMethod':'GET', 'username':'Mufasa', 'password': 'Circle Of Life', 'realm':'testrealm@host.com', 'algorithm':'md5', 'nonce':'dcd98b7102dd2f0e8b11d0f600bfb0c093', 'uri':'/dir/index.html', 'qop':'auth', 'nc': '00000001', 'cnonce':'0a4f113b', 'opaque':'5ccc069c403ebaf9f0171e9517f40e41'}
    >>> print digest(input)
    "6629fae49393a05397450978507c4ef1"
    '''
    algorithm, username, realm, password, nonce, cnonce, nc, qop, httpMethod, uri, entityBody \
      = map(lambda x: cr[x] if x in cr else None, ['algorithm', 'username', 'realm', 'password', 'nonce', 'cnonce', 'nc', 'qop', 'httpMethod', 'uri', 'entityBody'])
      

From RFC2617 p.13
   If the "algorithm" directive's value is "MD5" or is unspecified, then
   A1 is:

      A1       = unq(username-value) ":" unq(realm-value) ":" passwd

   where

      passwd   = < user's password >

   If the "algorithm" directive's value is "MD5-sess", then A1 is
   calculated only once - on the first request by the client following
   receipt of a WWW-Authenticate challenge from the server.  It uses the
   server nonce from that challenge, and the first client nonce value to
   construct A1 as follows:

      A1       = H( unq(username-value) ":" unq(realm-value)
                     ":" passwd )
                     ":" unq(nonce-value) ":" unq(cnonce-value)

   This creates a 'session key' for the authentication of subsequent
    if algorithm and algorithm.lower() == 'md5-sess':
        A1 = H(username + ':' + realm + ':' + password) + ':' + nonce + ':' + cnonce
    else:
        A1 = username + ':' + realm + ':' + password

From RFC2617 p.14
   If the "qop" directive's value is "auth" or is unspecified, then A2
   is:

      A2       = Method ":" digest-uri-value

   If the "qop" value is "auth-int", then A2 is:

      A2       = Method ":" digest-uri-value ":" H(entity-body)
    if not qop or qop == 'auth':
        A2 = httpMethod + ':' + str(uri)
    else:
        A2 = httpMethod + ':' + str(uri) + ':' + H(str(entityBody))


From RFC2617 p.13
   If the "qop" value is "auth" or "auth-int":

      request-digest  = <"> < KD ( H(A1),     unq(nonce-value)
                                          ":" nc-value
                                          ":" unq(cnonce-value)
                                          ":" unq(qop-value)
                                          ":" H(A2)
                                  ) <">

   If the "qop" directive is not present (this construction is for
   compatibility with RFC 2069):

      request-digest  =
                 <"> < KD ( H(A1), unq(nonce-value) ":" H(A2) ) >
   <">
    if qop and (qop == 'auth' or qop == 'auth-int'):
        a = nonce + ':' + str(nc) + ':' + cnonce + ':' + qop + ':' + A2
        return _quote(KD(H(A1), nonce + ':' + str(nc) + ':' + cnonce + ':' + qop + ':' + H(A2)))
    else:
        return _quote(KD(H(A1), nonce + ':' + H(A2)))



From RFC2617 p.6
   If the user agent wishes to send the userid "Aladdin" and password
   "open sesame", it would use the following header field:

      Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==
def basic(cr):
    '''Create a basic response for the credentials.
    
    >>> print basic({'username':'Aladdin', 'password':'open sesame'})
    QWxhZGRpbjpvcGVuIHNlc2FtZQ==
    '''

From RFC2617 p.5
   To receive authorization, the client sends the userid and password,
   separated by a single colon (":") character, within a base64 [7]
   encoded string in the credentials.

      basic-credentials = base64-user-pass
      base64-user-pass  = <base64 [4] encoding of user-pass,
                       except not limited to 76 char/line>
      user-pass   = userid ":" password
      userid      = *<TEXT excluding ":">
      password    = *TEXT

   Userids might be case sensitive.
    return b64encode(cr['username'] + ':' + cr['password'])


if __name__ == '__main__':
    import doctest
    doctest.testmod()