1
2
3
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
41
42
43
44
45
47 """Returns a get_ha1 function which obtains a plaintext password from a
48 dictionary of the form: {username : password}.
49
50 If you want a simple dictionary-based authentication scheme, with plaintext
51 passwords, use get_ha1_dict_plain(my_userpass_dict) as the value for the
52 get_ha1 argument to digest_auth().
53 """
54 def get_ha1(realm, username):
55 password = user_password_dict.get(username)
56 if password:
57 return md5_hex('%s:%s:%s' % (username, realm, password))
58 return None
59
60 return get_ha1
61
62
64 """Returns a get_ha1 function which obtains a HA1 password hash from a
65 dictionary of the form: {username : HA1}.
66
67 If you want a dictionary-based authentication scheme, but with
68 pre-computed HA1 hashes instead of plain-text passwords, use
69 get_ha1_dict(my_userha1_dict) as the value for the get_ha1
70 argument to digest_auth().
71 """
72 def get_ha1(realm, username):
73 return user_ha1_dict.get(username)
74
75 return get_ha1
76
77
79 """Returns a get_ha1 function which obtains a HA1 password hash from a
80 flat file with lines of the same format as that produced by the Apache
81 htdigest utility. For example, for realm 'wonderland', username 'alice',
82 and password '4x5istwelve', the htdigest line would be::
83
84 alice:wonderland:3238cdfe91a8b2ed8e39646921a02d4c
85
86 If you want to use an Apache htdigest file as the credentials store,
87 then use get_ha1_file_htdigest(my_htdigest_file) as the value for the
88 get_ha1 argument to digest_auth(). It is recommended that the filename
89 argument be an absolute path, to avoid problems.
90 """
91 def get_ha1(realm, username):
92 result = None
93 f = open(filename, 'r')
94 for line in f:
95 u, r, ha1 = line.rstrip().split(':')
96 if u == username and r == realm:
97 result = ha1
98 break
99 f.close()
100 return result
101
102 return get_ha1
103
104
106 """Synthesize a nonce value which resists spoofing and can be checked
107 for staleness. Returns a string suitable as the value for 'nonce' in
108 the www-authenticate header.
109
110 s
111 A string related to the resource, such as the hostname of the server.
112
113 key
114 A secret string known only to the server.
115
116 timestamp
117 An integer seconds-since-the-epoch timestamp
118
119 """
120 if timestamp is None:
121 timestamp = int(time.time())
122 h = md5_hex('%s:%s:%s' % (timestamp, s, key))
123 nonce = '%s:%s' % (timestamp, h)
124 return nonce
125
126
128 """The hash function H"""
129 return md5_hex(s)
130
131
133
134 """Class to parse a Digest Authorization header and perform re-calculation
135 of the digest.
136 """
137
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
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')
161 self.algorithm = paramsd.get('algorithm', 'MD5').upper()
162 self.cnonce = paramsd.get('cnonce')
163 self.opaque = paramsd.get('opaque')
164 self.qop = paramsd.get('qop')
165 self.nc = paramsd.get('nc')
166
167
168 if self.algorithm not in valid_algorithms:
169 raise ValueError(
170 self.errmsg("Unsupported value for algorithm: '%s'" %
171 self.algorithm))
172
173 has_reqd = (
174 self.username and
175 self.realm and
176 self.nonce and
177 self.uri and
178 self.response
179 )
180 if not has_reqd:
181 raise ValueError(
182 self.errmsg("Not all required parameters are present."))
183
184 if self.qop:
185 if self.qop not in valid_qops:
186 raise ValueError(
187 self.errmsg("Unsupported value for qop: '%s'" % self.qop))
188 if not (self.cnonce and self.nc):
189 raise ValueError(
190 self.errmsg("If qop is sent then "
191 "cnonce and nc MUST be present"))
192 else:
193 if self.cnonce or self.nc:
194 raise ValueError(
195 self.errmsg("If qop is not sent, "
196 "neither cnonce nor nc can be present"))
197
199 return 'authorization : %s' % self.auth_header
200
202 """Validate the nonce.
203 Returns True if nonce was generated by synthesize_nonce() and the
204 timestamp is not spoofed, else returns False.
205
206 s
207 A string related to the resource, such as the hostname of
208 the server.
209
210 key
211 A secret string known only to the server.
212
213 Both s and key must be the same values which were used to synthesize
214 the nonce we are trying to validate.
215 """
216 try:
217 timestamp, hashpart = self.nonce.split(':', 1)
218 s_timestamp, s_hashpart = synthesize_nonce(
219 s, key, timestamp).split(':', 1)
220 is_valid = s_hashpart == hashpart
221 if self.debug:
222 TRACE('validate_nonce: %s' % is_valid)
223 return is_valid
224 except ValueError:
225 pass
226 return False
227
229 """Returns True if a validated nonce is stale. The nonce contains a
230 timestamp in plaintext and also a secure hash of the timestamp.
231 You should first validate the nonce to ensure the plaintext
232 timestamp is not spoofed.
233 """
234 try:
235 timestamp, hashpart = self.nonce.split(':', 1)
236 if int(timestamp) + max_age_seconds > int(time.time()):
237 return False
238 except ValueError:
239 pass
240 if self.debug:
241 TRACE("nonce is stale")
242 return True
243
244 - def HA2(self, entity_body=''):
245 """Returns the H(A2) string. See :rfc:`2617` section 3.2.2.3."""
246
247
248
249
250
251
252
253 if self.qop is None or self.qop == "auth":
254 a2 = '%s:%s' % (self.http_method, self.uri)
255 elif self.qop == "auth-int":
256 a2 = "%s:%s:%s" % (self.http_method, self.uri, H(entity_body))
257 else:
258
259
260 raise ValueError(self.errmsg("Unrecognized value for qop!"))
261 return H(a2)
262
264 """Calculates the Request-Digest. See :rfc:`2617` section 3.2.2.1.
265
266 ha1
267 The HA1 string obtained from the credentials store.
268
269 entity_body
270 If 'qop' is set to 'auth-int', then A2 includes a hash
271 of the "entity body". The entity body is the part of the
272 message which follows the HTTP headers. See :rfc:`2617` section
273 4.3. This refers to the entity the user agent sent in the
274 request which has the Authorization header. Typically GET
275 requests don't have an entity, and POST requests do.
276
277 """
278 ha2 = self.HA2(entity_body)
279
280 if self.qop:
281 req = "%s:%s:%s:%s:%s" % (
282 self.nonce, self.nc, self.cnonce, self.qop, ha2)
283 else:
284 req = "%s:%s" % (self.nonce, ha2)
285
286
287
288
289
290
291
292
293
294
295
296
297 if self.algorithm == 'MD5-sess':
298 ha1 = H('%s:%s:%s' % (ha1, self.nonce, self.cnonce))
299
300 digest = H('%s:%s' % (ha1, req))
301 return digest
302
303
306 """Constructs a WWW-Authenticate header for Digest authentication."""
307 if qop not in valid_qops:
308 raise ValueError("Unsupported value for qop: '%s'" % qop)
309 if algorithm not in valid_algorithms:
310 raise ValueError("Unsupported value for algorithm: '%s'" % algorithm)
311
312 if nonce is None:
313 nonce = synthesize_nonce(realm, key)
314 s = 'Digest realm="%s", nonce="%s", algorithm="%s", qop="%s"' % (
315 realm, nonce, algorithm, qop)
316 if stale:
317 s += ', stale="true"'
318 return s
319
320
322 """A CherryPy tool which hooks at before_handler to perform
323 HTTP Digest Access Authentication, as specified in :rfc:`2617`.
324
325 If the request has an 'authorization' header with a 'Digest' scheme,
326 this tool authenticates the credentials supplied in that header.
327 If the request has no 'authorization' header, or if it does but the
328 scheme is not "Digest", or if authentication fails, the tool sends
329 a 401 response with a 'WWW-Authenticate' Digest header.
330
331 realm
332 A string containing the authentication realm.
333
334 get_ha1
335 A callable which looks up a username in a credentials store
336 and returns the HA1 string, which is defined in the RFC to be
337 MD5(username : realm : password). The function's signature is:
338 ``get_ha1(realm, username)``
339 where username is obtained from the request's 'authorization' header.
340 If username is not found in the credentials store, get_ha1() returns
341 None.
342
343 key
344 A secret string known only to the server, used in the synthesis
345 of nonces.
346
347 """
348 request = cherrypy.serving.request
349
350 auth_header = request.headers.get('authorization')
351 nonce_is_stale = False
352 if auth_header is not None:
353 try:
354 auth = HttpDigestAuthorization(
355 auth_header, request.method, debug=debug)
356 except ValueError:
357 raise cherrypy.HTTPError(
358 400, "The Authorization header could not be parsed.")
359
360 if debug:
361 TRACE(str(auth))
362
363 if auth.validate_nonce(realm, key):
364 ha1 = get_ha1(realm, auth.username)
365 if ha1 is not None:
366
367
368
369 digest = auth.request_digest(ha1, entity_body=request.body)
370 if digest == auth.response:
371 if debug:
372 TRACE("digest matches auth.response")
373
374
375
376 nonce_is_stale = auth.is_nonce_stale(max_age_seconds=600)
377 if not nonce_is_stale:
378 request.login = auth.username
379 if debug:
380 TRACE("authentication of %s successful" %
381 auth.username)
382 return
383
384
385 header = www_authenticate(realm, key, stale=nonce_is_stale)
386 if debug:
387 TRACE(header)
388 cherrypy.serving.response.headers['WWW-Authenticate'] = header
389 raise cherrypy.HTTPError(
390 401, "You are not authorized to access that resource")
391