~ubuntu-branches/debian/wheezy/calibre/wheezy

« back to all changes in this revision

Viewing changes to src/cherrypy/lib/auth_digest.py

  • Committer: Package Import Robot
  • Author(s): Martin Pitt
  • Date: 2012-01-07 11:22:54 UTC
  • mfrom: (29.4.10 precise)
  • Revision ID: package-import@ubuntu.com-20120107112254-n1syr437o46ds802
Tags: 0.8.34+dfsg-1
* New upstream version. (Closes: #654751)
* debian/rules: Do not install calibre copy of chardet; instead, add
  build/binary python-chardet dependency.
* Add disable_plugins.py: Disable plugin dialog. It uses a totally
  non-authenticated and non-trusted way of installing arbitrary code.
  (Closes: #640026)
* debian/rules: Install with POSIX locale, to avoid installing translated
  manpages into the standard locations. (Closes: #646674)

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# This file is part of CherryPy <http://www.cherrypy.org/>
 
2
# -*- coding: utf-8 -*-
 
3
# vim:ts=4:sw=4:expandtab:fileencoding=utf-8
 
4
 
 
5
__doc__ = """An implementation of the server-side of HTTP Digest Access
 
6
Authentication, which is described in :rfc:`2617`.
 
7
 
 
8
Example usage, using the built-in get_ha1_dict_plain function which uses a dict
 
9
of plaintext passwords as the credentials store::
 
10
 
 
11
    userpassdict = {'alice' : '4x5istwelve'}
 
12
    get_ha1 = cherrypy.lib.auth_digest.get_ha1_dict_plain(userpassdict)
 
13
    digest_auth = {'tools.auth_digest.on': True,
 
14
                   'tools.auth_digest.realm': 'wonderland',
 
15
                   'tools.auth_digest.get_ha1': get_ha1,
 
16
                   'tools.auth_digest.key': 'a565c27146791cfb',
 
17
    }
 
18
    app_config = { '/' : digest_auth }
 
19
"""
 
20
 
 
21
__author__ = 'visteya'
 
22
__date__ = 'April 2009'
 
23
 
 
24
 
 
25
import time
 
26
from cherrypy._cpcompat import parse_http_list, parse_keqv_list
 
27
 
 
28
import cherrypy
 
29
from cherrypy._cpcompat import md5, ntob
 
30
md5_hex = lambda s: md5(ntob(s)).hexdigest()
 
31
 
 
32
qop_auth = 'auth'
 
33
qop_auth_int = 'auth-int'
 
34
valid_qops = (qop_auth, qop_auth_int)
 
35
 
 
36
valid_algorithms = ('MD5', 'MD5-sess')
 
37
 
 
38
 
 
39
def TRACE(msg):
 
40
    cherrypy.log(msg, context='TOOLS.AUTH_DIGEST')
 
41
 
 
42
# Three helper functions for users of the tool, providing three variants
 
43
# of get_ha1() functions for three different kinds of credential stores.
 
44
def get_ha1_dict_plain(user_password_dict):
 
45
    """Returns a get_ha1 function which obtains a plaintext password from a
 
46
    dictionary of the form: {username : password}.
 
47
 
 
48
    If you want a simple dictionary-based authentication scheme, with plaintext
 
49
    passwords, use get_ha1_dict_plain(my_userpass_dict) as the value for the
 
50
    get_ha1 argument to digest_auth().
 
51
    """
 
52
    def get_ha1(realm, username):
 
53
        password = user_password_dict.get(username)
 
54
        if password:
 
55
            return md5_hex('%s:%s:%s' % (username, realm, password))
 
56
        return None
 
57
 
 
58
    return get_ha1
 
59
 
 
60
def get_ha1_dict(user_ha1_dict):
 
61
    """Returns a get_ha1 function which obtains a HA1 password hash from a
 
62
    dictionary of the form: {username : HA1}.
 
63
 
 
64
    If you want a dictionary-based authentication scheme, but with
 
65
    pre-computed HA1 hashes instead of plain-text passwords, use
 
66
    get_ha1_dict(my_userha1_dict) as the value for the get_ha1
 
67
    argument to digest_auth().
 
68
    """
 
69
    def get_ha1(realm, username):
 
70
        return user_ha1_dict.get(user)
 
71
 
 
72
    return get_ha1
 
73
 
 
74
def get_ha1_file_htdigest(filename):
 
75
    """Returns a get_ha1 function which obtains a HA1 password hash from a
 
76
    flat file with lines of the same format as that produced by the Apache
 
77
    htdigest utility. For example, for realm 'wonderland', username 'alice',
 
78
    and password '4x5istwelve', the htdigest line would be::
 
79
 
 
80
        alice:wonderland:3238cdfe91a8b2ed8e39646921a02d4c
 
81
 
 
82
    If you want to use an Apache htdigest file as the credentials store,
 
83
    then use get_ha1_file_htdigest(my_htdigest_file) as the value for the
 
84
    get_ha1 argument to digest_auth().  It is recommended that the filename
 
85
    argument be an absolute path, to avoid problems.
 
86
    """
 
87
    def get_ha1(realm, username):
 
88
        result = None
 
89
        f = open(filename, 'r')
 
90
        for line in f:
 
91
            u, r, ha1 = line.rstrip().split(':')
 
92
            if u == username and r == realm:
 
93
                result = ha1
 
94
                break
 
95
        f.close()
 
96
        return result
 
97
 
 
98
    return get_ha1
 
99
 
 
100
 
 
101
def synthesize_nonce(s, key, timestamp=None):
 
102
    """Synthesize a nonce value which resists spoofing and can be checked for staleness.
 
103
    Returns a string suitable as the value for 'nonce' in the www-authenticate header.
 
104
 
 
105
    s
 
106
        A string related to the resource, such as the hostname of the server.
 
107
 
 
108
    key
 
109
        A secret string known only to the server.
 
110
    
 
111
    timestamp
 
112
        An integer seconds-since-the-epoch timestamp
 
113
    
 
114
    """
 
115
    if timestamp is None:
 
116
        timestamp = int(time.time())
 
117
    h = md5_hex('%s:%s:%s' % (timestamp, s, key))
 
118
    nonce = '%s:%s' % (timestamp, h)
 
119
    return nonce
 
120
 
 
121
 
 
122
def H(s):
 
123
    """The hash function H"""
 
124
    return md5_hex(s)
 
125
 
 
126
 
 
127
class HttpDigestAuthorization (object):
 
128
    """Class to parse a Digest Authorization header and perform re-calculation
 
129
    of the digest.
 
130
    """
 
131
 
 
132
    def errmsg(self, s):
 
133
        return 'Digest Authorization header: %s' % s
 
134
 
 
135
    def __init__(self, auth_header, http_method, debug=False):
 
136
        self.http_method = http_method
 
137
        self.debug = debug
 
138
        scheme, params  = auth_header.split(" ", 1)
 
139
        self.scheme = scheme.lower()
 
140
        if self.scheme != 'digest':
 
141
            raise ValueError('Authorization scheme is not "Digest"')
 
142
 
 
143
        self.auth_header = auth_header
 
144
 
 
145
        # make a dict of the params
 
146
        items = parse_http_list(params)
 
147
        paramsd = parse_keqv_list(items)
 
148
 
 
149
        self.realm = paramsd.get('realm')
 
150
        self.username = paramsd.get('username')
 
151
        self.nonce = paramsd.get('nonce')
 
152
        self.uri = paramsd.get('uri')
 
153
        self.method = paramsd.get('method')
 
154
        self.response = paramsd.get('response') # the response digest
 
155
        self.algorithm = paramsd.get('algorithm', 'MD5')
 
156
        self.cnonce = paramsd.get('cnonce')
 
157
        self.opaque = paramsd.get('opaque')
 
158
        self.qop = paramsd.get('qop') # qop
 
159
        self.nc = paramsd.get('nc') # nonce count
 
160
 
 
161
        # perform some correctness checks
 
162
        if self.algorithm not in valid_algorithms:
 
163
            raise ValueError(self.errmsg("Unsupported value for algorithm: '%s'" % self.algorithm))
 
164
 
 
165
        has_reqd = self.username and \
 
166
                   self.realm and \
 
167
                   self.nonce and \
 
168
                   self.uri and \
 
169
                   self.response
 
170
        if not has_reqd:
 
171
            raise ValueError(self.errmsg("Not all required parameters are present."))
 
172
 
 
173
        if self.qop:
 
174
            if self.qop not in valid_qops:
 
175
                raise ValueError(self.errmsg("Unsupported value for qop: '%s'" % self.qop))
 
176
            if not (self.cnonce and self.nc):
 
177
                raise ValueError(self.errmsg("If qop is sent then cnonce and nc MUST be present"))
 
178
        else:
 
179
            if self.cnonce or self.nc:
 
180
                raise ValueError(self.errmsg("If qop is not sent, neither cnonce nor nc can be present"))
 
181
 
 
182
 
 
183
    def __str__(self):
 
184
        return 'authorization : %s' % self.auth_header
 
185
 
 
186
    def validate_nonce(self, s, key):
 
187
        """Validate the nonce.
 
188
        Returns True if nonce was generated by synthesize_nonce() and the timestamp
 
189
        is not spoofed, else returns False.
 
190
 
 
191
        s
 
192
            A string related to the resource, such as the hostname of the server.
 
193
            
 
194
        key
 
195
            A secret string known only to the server.
 
196
        
 
197
        Both s and key must be the same values which were used to synthesize the nonce
 
198
        we are trying to validate.
 
199
        """
 
200
        try:
 
201
            timestamp, hashpart = self.nonce.split(':', 1)
 
202
            s_timestamp, s_hashpart = synthesize_nonce(s, key, timestamp).split(':', 1)
 
203
            is_valid = s_hashpart == hashpart
 
204
            if self.debug:
 
205
                TRACE('validate_nonce: %s' % is_valid)
 
206
            return is_valid
 
207
        except ValueError: # split() error
 
208
            pass
 
209
        return False
 
210
 
 
211
 
 
212
    def is_nonce_stale(self, max_age_seconds=600):
 
213
        """Returns True if a validated nonce is stale. The nonce contains a
 
214
        timestamp in plaintext and also a secure hash of the timestamp. You should
 
215
        first validate the nonce to ensure the plaintext timestamp is not spoofed.
 
216
        """
 
217
        try:
 
218
            timestamp, hashpart = self.nonce.split(':', 1)
 
219
            if int(timestamp) + max_age_seconds > int(time.time()):
 
220
                return False
 
221
        except ValueError: # int() error
 
222
            pass
 
223
        if self.debug:
 
224
            TRACE("nonce is stale")
 
225
        return True
 
226
 
 
227
 
 
228
    def HA2(self, entity_body=''):
 
229
        """Returns the H(A2) string. See :rfc:`2617` section 3.2.2.3."""
 
230
        # RFC 2617 3.2.2.3
 
231
        # If the "qop" directive's value is "auth" or is unspecified, then A2 is:
 
232
        #    A2 = method ":" digest-uri-value
 
233
        #
 
234
        # If the "qop" value is "auth-int", then A2 is:
 
235
        #    A2 = method ":" digest-uri-value ":" H(entity-body)
 
236
        if self.qop is None or self.qop == "auth":
 
237
            a2 = '%s:%s' % (self.http_method, self.uri)
 
238
        elif self.qop == "auth-int":
 
239
            a2 = "%s:%s:%s" % (self.http_method, self.uri, H(entity_body))
 
240
        else:
 
241
            # in theory, this should never happen, since I validate qop in __init__()
 
242
            raise ValueError(self.errmsg("Unrecognized value for qop!"))
 
243
        return H(a2)
 
244
 
 
245
 
 
246
    def request_digest(self, ha1, entity_body=''):
 
247
        """Calculates the Request-Digest. See :rfc:`2617` section 3.2.2.1.
 
248
 
 
249
        ha1
 
250
            The HA1 string obtained from the credentials store.
 
251
 
 
252
        entity_body
 
253
            If 'qop' is set to 'auth-int', then A2 includes a hash
 
254
            of the "entity body".  The entity body is the part of the
 
255
            message which follows the HTTP headers. See :rfc:`2617` section
 
256
            4.3.  This refers to the entity the user agent sent in the request which
 
257
            has the Authorization header. Typically GET requests don't have an entity,
 
258
            and POST requests do.
 
259
        
 
260
        """
 
261
        ha2 = self.HA2(entity_body)
 
262
        # Request-Digest -- RFC 2617 3.2.2.1
 
263
        if self.qop:
 
264
            req = "%s:%s:%s:%s:%s" % (self.nonce, self.nc, self.cnonce, self.qop, ha2)
 
265
        else:
 
266
            req = "%s:%s" % (self.nonce, ha2)
 
267
 
 
268
        # RFC 2617 3.2.2.2
 
269
        #
 
270
        # If the "algorithm" directive's value is "MD5" or is unspecified, then A1 is:
 
271
        # A1 = unq(username-value) ":" unq(realm-value) ":" passwd
 
272
        #
 
273
        # If the "algorithm" directive's value is "MD5-sess", then A1 is
 
274
        # calculated only once - on the first request by the client following
 
275
        # receipt of a WWW-Authenticate challenge from the server.
 
276
        # A1 = H( unq(username-value) ":" unq(realm-value) ":" passwd )
 
277
        #         ":" unq(nonce-value) ":" unq(cnonce-value)
 
278
        if self.algorithm == 'MD5-sess':
 
279
            ha1 = H('%s:%s:%s' % (ha1, self.nonce, self.cnonce))
 
280
 
 
281
        digest = H('%s:%s' % (ha1, req))
 
282
        return digest
 
283
 
 
284
 
 
285
 
 
286
def www_authenticate(realm, key, algorithm='MD5', nonce=None, qop=qop_auth, stale=False):
 
287
    """Constructs a WWW-Authenticate header for Digest authentication."""
 
288
    if qop not in valid_qops:
 
289
        raise ValueError("Unsupported value for qop: '%s'" % qop)
 
290
    if algorithm not in valid_algorithms:
 
291
        raise ValueError("Unsupported value for algorithm: '%s'" % algorithm)
 
292
 
 
293
    if nonce is None:
 
294
        nonce = synthesize_nonce(realm, key)
 
295
    s = 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % (
 
296
                realm, nonce, algorithm, qop)
 
297
    if stale:
 
298
        s += ', stale="true"'
 
299
    return s
 
300
 
 
301
 
 
302
def digest_auth(realm, get_ha1, key, debug=False):
 
303
    """A CherryPy tool which hooks at before_handler to perform
 
304
    HTTP Digest Access Authentication, as specified in :rfc:`2617`.
 
305
    
 
306
    If the request has an 'authorization' header with a 'Digest' scheme, this
 
307
    tool authenticates the credentials supplied in that header.  If
 
308
    the request has no 'authorization' header, or if it does but the scheme is
 
309
    not "Digest", or if authentication fails, the tool sends a 401 response with
 
310
    a 'WWW-Authenticate' Digest header.
 
311
    
 
312
    realm
 
313
        A string containing the authentication realm.
 
314
    
 
315
    get_ha1
 
316
        A callable which looks up a username in a credentials store
 
317
        and returns the HA1 string, which is defined in the RFC to be
 
318
        MD5(username : realm : password).  The function's signature is:
 
319
        ``get_ha1(realm, username)``
 
320
        where username is obtained from the request's 'authorization' header.
 
321
        If username is not found in the credentials store, get_ha1() returns
 
322
        None.
 
323
    
 
324
    key
 
325
        A secret string known only to the server, used in the synthesis of nonces.
 
326
    
 
327
    """
 
328
    request = cherrypy.serving.request
 
329
    
 
330
    auth_header = request.headers.get('authorization')
 
331
    nonce_is_stale = False
 
332
    if auth_header is not None:
 
333
        try:
 
334
            auth = HttpDigestAuthorization(auth_header, request.method, debug=debug)
 
335
        except ValueError:
 
336
            raise cherrypy.HTTPError(400, "The Authorization header could not be parsed.")
 
337
        
 
338
        if debug:
 
339
            TRACE(str(auth))
 
340
        
 
341
        if auth.validate_nonce(realm, key):
 
342
            ha1 = get_ha1(realm, auth.username)
 
343
            if ha1 is not None:
 
344
                # note that for request.body to be available we need to hook in at
 
345
                # before_handler, not on_start_resource like 3.1.x digest_auth does.
 
346
                digest = auth.request_digest(ha1, entity_body=request.body)
 
347
                if digest == auth.response: # authenticated
 
348
                    if debug:
 
349
                        TRACE("digest matches auth.response")
 
350
                    # Now check if nonce is stale.
 
351
                    # The choice of ten minutes' lifetime for nonce is somewhat arbitrary
 
352
                    nonce_is_stale = auth.is_nonce_stale(max_age_seconds=600)
 
353
                    if not nonce_is_stale:
 
354
                        request.login = auth.username
 
355
                        if debug:
 
356
                            TRACE("authentication of %s successful" % auth.username)
 
357
                        return
 
358
    
 
359
    # Respond with 401 status and a WWW-Authenticate header
 
360
    header = www_authenticate(realm, key, stale=nonce_is_stale)
 
361
    if debug:
 
362
        TRACE(header)
 
363
    cherrypy.serving.response.headers['WWW-Authenticate'] = header
 
364
    raise cherrypy.HTTPError(401, "You are not authorized to access that resource")
 
365