digest.py revision 4a4f2fe02baf385f6c24fc98c6e17bf6ac5e0724
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"""
6Digest HTTP/1.1 Authentication
7
8This module implements ``Digest`` authentication as described by
9RFC 2617 [1]_ .
10
11Basically, you just put this module before your application, and it
12takes care of requesting and handling authentication requests.  This
13module 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))
22serving on...
23
24This code has not been audited by a security expert, please use with
25caution (or better yet, report security holes). At this time, this
26implementation does not provide for further challenges, nor does it
27support Authentication-Info header.  It also uses md5, and an option
28to use sha would be a good thing.
29
30.. [1] http://www.faqs.org/rfcs/rfc2617.html
31"""
32from paste.httpexceptions import HTTPUnauthorized
33from paste.httpheaders import *
34try:
35    from hashlib import md5
36except ImportError:
37    from md5 import md5
38import time, random
39from six.moves.urllib.parse import quote as url_quote
40import six
41
42def _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
62def _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
70def 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
77class 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
167class 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
230middleware = AuthDigestHandler
231
232__all__ = ['digest_password', 'AuthDigestHandler' ]
233
234def 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
252if "__main__" == __name__:
253    import doctest
254    doctest.testmod(optionflags=doctest.ELLIPSIS)
255