Home | History | Annotate | Download | only in auth
      1 # (c) 2005 Clark C. Evans
      2 # This module is part of the Python Paste Project and is released under
      3 # the MIT License: http://www.opensource.org/licenses/mit-license.php
      4 # This code was written with funding by http://prometheusresearch.com
      5 """
      6 Digest HTTP/1.1 Authentication
      7 
      8 This module implements ``Digest`` authentication as described by
      9 RFC 2617 [1]_ .
     10 
     11 Basically, you just put this module before your application, and it
     12 takes care of requesting and handling authentication requests.  This
     13 module has been tested with several common browsers "out-in-the-wild".
     14 
     15 >>> from paste.wsgilib import dump_environ
     16 >>> from paste.httpserver import serve
     17 >>> # from paste.auth.digest import digest_password, AuthDigestHandler
     18 >>> realm = 'Test Realm'
     19 >>> def authfunc(environ, realm, username):
     20 ...     return digest_password(realm, username, username)
     21 >>> serve(AuthDigestHandler(dump_environ, realm, authfunc))
     22 serving on...
     23 
     24 This code has not been audited by a security expert, please use with
     25 caution (or better yet, report security holes). At this time, this
     26 implementation does not provide for further challenges, nor does it
     27 support Authentication-Info header.  It also uses md5, and an option
     28 to use sha would be a good thing.
     29 
     30 .. [1] http://www.faqs.org/rfcs/rfc2617.html
     31 """
     32 from paste.httpexceptions import HTTPUnauthorized
     33 from paste.httpheaders import *
     34 try:
     35     from hashlib import md5
     36 except ImportError:
     37     from md5 import md5
     38 import time, random
     39 from six.moves.urllib.parse import quote as url_quote
     40 import six
     41 
     42 def _split_auth_string(auth_string):
     43     """ split a digest auth string into individual key=value strings """
     44     prev = None
     45     for item in auth_string.split(","):
     46         try:
     47             if prev.count('"') == 1:
     48                 prev = "%s,%s" % (prev, item)
     49                 continue
     50         except AttributeError:
     51             if prev == None:
     52                 prev = item
     53                 continue
     54             else:
     55                 raise StopIteration
     56         yield prev.strip()
     57         prev = item
     58 
     59     yield prev.strip()
     60     raise StopIteration
     61 
     62 def _auth_to_kv_pairs(auth_string):
     63     """ split a digest auth string into key, value pairs """
     64     for item in _split_auth_string(auth_string):
     65         (k, v) = item.split("=", 1)
     66         if v.startswith('"') and len(v) > 1 and v.endswith('"'):
     67             v = v[1:-1]
     68         yield (k, v)
     69 
     70 def digest_password(realm, username, password):
     71     """ construct the appropriate hashcode needed for HTTP digest """
     72     content = "%s:%s:%s" % (username, realm, password)
     73     if six.PY3:
     74         content = content.encode('utf8')
     75     return md5(content).hexdigest()
     76 
     77 class AuthDigestAuthenticator(object):
     78     """ implementation of RFC 2617 - HTTP Digest Authentication """
     79     def __init__(self, realm, authfunc):
     80         self.nonce    = {} # list to prevent replay attacks
     81         self.authfunc = authfunc
     82         self.realm    = realm
     83 
     84     def build_authentication(self, stale = ''):
     85         """ builds the authentication error """
     86         content = "%s:%s" % (time.time(), random.random())
     87         if six.PY3:
     88             content = content.encode('utf-8')
     89         nonce  = md5(content).hexdigest()
     90 
     91         content = "%s:%s" % (time.time(), random.random())
     92         if six.PY3:
     93             content = content.encode('utf-8')
     94         opaque = md5(content).hexdigest()
     95 
     96         self.nonce[nonce] = None
     97         parts = {'realm': self.realm, 'qop': 'auth',
     98                  'nonce': nonce, 'opaque': opaque }
     99         if stale:
    100             parts['stale'] = 'true'
    101         head = ", ".join(['%s="%s"' % (k, v) for (k, v) in parts.items()])
    102         head = [("WWW-Authenticate", 'Digest %s' % head)]
    103         return HTTPUnauthorized(headers=head)
    104 
    105     def compute(self, ha1, username, response, method,
    106                       path, nonce, nc, cnonce, qop):
    107         """ computes the authentication, raises error if unsuccessful """
    108         if not ha1:
    109             return self.build_authentication()
    110         content = '%s:%s' % (method, path)
    111         if six.PY3:
    112             content = content.encode('utf8')
    113         ha2 = md5(content).hexdigest()
    114         if qop:
    115             chk = "%s:%s:%s:%s:%s:%s" % (ha1, nonce, nc, cnonce, qop, ha2)
    116         else:
    117             chk = "%s:%s:%s" % (ha1, nonce, ha2)
    118         if six.PY3:
    119             chk = chk.encode('utf8')
    120         if response != md5(chk).hexdigest():
    121             if nonce in self.nonce:
    122                 del self.nonce[nonce]
    123             return self.build_authentication()
    124         pnc = self.nonce.get(nonce,'00000000')
    125         if pnc is not None and nc <= pnc:
    126             if nonce in self.nonce:
    127                 del self.nonce[nonce]
    128             return self.build_authentication(stale = True)
    129         self.nonce[nonce] = nc
    130         return username
    131 
    132     def authenticate(self, environ):
    133         """ This function takes a WSGI environment and authenticates
    134             the request returning authenticated user or error.
    135         """
    136         method = REQUEST_METHOD(environ)
    137         fullpath = url_quote(SCRIPT_NAME(environ)) + url_quote(PATH_INFO(environ))
    138         authorization = AUTHORIZATION(environ)
    139         if not authorization:
    140             return self.build_authentication()
    141         (authmeth, auth) = authorization.split(" ", 1)
    142         if 'digest' != authmeth.lower():
    143             return self.build_authentication()
    144         amap = dict(_auth_to_kv_pairs(auth))
    145         try:
    146             username = amap['username']
    147             authpath = amap['uri']
    148             nonce    = amap['nonce']
    149             realm    = amap['realm']
    150             response = amap['response']
    151             assert authpath.split("?", 1)[0] in fullpath
    152             assert realm == self.realm
    153             qop      = amap.get('qop', '')
    154             cnonce   = amap.get('cnonce', '')
    155             nc       = amap.get('nc', '00000000')
    156             if qop:
    157                 assert 'auth' == qop
    158                 assert nonce and nc
    159         except:
    160             return self.build_authentication()
    161         ha1 = self.authfunc(environ, realm, username)
    162         return self.compute(ha1, username, response, method, authpath,
    163                             nonce, nc, cnonce, qop)
    164 
    165     __call__ = authenticate
    166 
    167 class AuthDigestHandler(object):
    168     """
    169     middleware for HTTP Digest authentication (RFC 2617)
    170 
    171     This component follows the procedure below:
    172 
    173         0. If the REMOTE_USER environment variable is already populated;
    174            then this middleware is a no-op, and the request is passed
    175            along to the application.
    176 
    177         1. If the HTTP_AUTHORIZATION header was not provided or specifies
    178            an algorithem other than ``digest``, then a HTTPUnauthorized
    179            response is generated with the challenge.
    180 
    181         2. If the response is malformed or or if the user's credientials
    182            do not pass muster, another HTTPUnauthorized is raised.
    183 
    184         3. If all goes well, and the user's credintials pass; then
    185            REMOTE_USER environment variable is filled in and the
    186            AUTH_TYPE is listed as 'digest'.
    187 
    188     Parameters:
    189 
    190         ``application``
    191 
    192             The application object is called only upon successful
    193             authentication, and can assume ``environ['REMOTE_USER']``
    194             is set.  If the ``REMOTE_USER`` is already set, this
    195             middleware is simply pass-through.
    196 
    197         ``realm``
    198 
    199             This is a identifier for the authority that is requesting
    200             authorization.  It is shown to the user and should be unique
    201             within the domain it is being used.
    202 
    203         ``authfunc``
    204 
    205             This is a callback function which performs the actual
    206             authentication; the signature of this callback is:
    207 
    208               authfunc(environ, realm, username) -> hashcode
    209 
    210             This module provides a 'digest_password' helper function
    211             which can help construct the hashcode; it is recommended
    212             that the hashcode is stored in a database, not the user's
    213             actual password (since you only need the hashcode).
    214     """
    215     def __init__(self, application, realm, authfunc):
    216         self.authenticate = AuthDigestAuthenticator(realm, authfunc)
    217         self.application = application
    218 
    219     def __call__(self, environ, start_response):
    220         username = REMOTE_USER(environ)
    221         if not username:
    222             result = self.authenticate(environ)
    223             if isinstance(result, str):
    224                 AUTH_TYPE.update(environ,'digest')
    225                 REMOTE_USER.update(environ, result)
    226             else:
    227                 return result.wsgi_application(environ, start_response)
    228         return self.application(environ, start_response)
    229 
    230 middleware = AuthDigestHandler
    231 
    232 __all__ = ['digest_password', 'AuthDigestHandler' ]
    233 
    234 def make_digest(app, global_conf, realm, authfunc, **kw):
    235     """
    236     Grant access via digest authentication
    237 
    238     Config looks like this::
    239 
    240       [filter:grant]
    241       use = egg:Paste#auth_digest
    242       realm=myrealm
    243       authfunc=somepackage.somemodule:somefunction
    244 
    245     """
    246     from paste.util.import_string import eval_import
    247     import types
    248     authfunc = eval_import(authfunc)
    249     assert isinstance(authfunc, types.FunctionType), "authfunc must resolve to a function"
    250     return AuthDigestHandler(app, realm, authfunc)
    251 
    252 if "__main__" == __name__:
    253     import doctest
    254     doctest.testmod(optionflags=doctest.ELLIPSIS)
    255