Download Install Tutorial Docs FAQ Tools WikiLicense Team IRC Planet Involvement Shop Book

root/trunk/cherrypy/lib/auth_digest.py

Revision 2645 (checked in by fumanchu, 5 months ago)

Docstring improvements

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

Hosted by WebFaction

Log in as guest/cpguest to create tickets