1
2
3 __all__ = []
4
5 import random
6 try:
7 from hashlib import md5
8 except ImportError:
9 from md5 import md5
10 import binascii
11 import base64
12 import time
13
14 sysrand = random.SystemRandom()
15
17 """
18 Generates a string of random base64 characters.
19 @param bytes: is the number of random 8bit values to be used
20
21 >>> gen_rand_str() != gen_rand_str()
22 True
23 """
24 randnum = sysrand.getrandbits(bytes*8)
25 randstr = ("%%0%dX" % (2*bytes)) % randnum
26 randstr = binascii.unhexlify(randstr)
27 randstr = base64.encodestring(randstr).strip()
28 return randstr
29
31 """internal
32 @raises ValueError:
33
34 >>> parse_digest_response('foo=bar')
35 {'foo': 'bar'}
36 >>> parse_digest_response('foo="bar"')
37 {'foo': 'bar'}
38 >>> sorted(parse_digest_response('foo="bar=qux",spam=egg').items())
39 [('foo', 'bar=qux'), ('spam', 'egg')]
40 >>> try:
41 ... parse_digest_response('spam')
42 ... except ValueError:
43 ... print("ValueError")
44 ValueError
45 >>> try:
46 ... parse_digest_response('spam="egg"error')
47 ... except ValueError:
48 ... print("ValueError")
49 ValueError
50 """
51 if ret is None:
52 ret = {}
53 data = data.strip()
54 key, rest = data.split('=', 1)
55 if rest.startswith('"'):
56 rest = rest[1:]
57 value, rest = rest.split('"', 1)
58 if not rest:
59 ret[key] = value
60 return ret
61 if rest[0] != ',':
62 raise ValueError("invalid digest response")
63 rest = rest[1:]
64 else:
65 if ',' not in rest:
66 ret[key] = rest
67 return ret
68 value, rest = rest.split(',' , 1)
69 ret[key] = value
70 return parse_digest_response(rest, ret)
71
73 """
74 Internal Exception class that is thrown inside L{AuthDigestMiddleware}, but
75 not visible to other code.
76 """
77
78 __all__.append("AuthTokenGenerator")
80 """Generates authentification tokens for L{AuthDigestMiddleware}. The
81 interface consists of beeing callable with a username and having a
82 realm attribute being a string."""
84 """
85 @type realm: str
86 @param realm: is a string according to RFC2617.
87 @type getpass: str -> str
88 @param getpass: this function is called with a username and password is
89 expected as result. C{None} may be used as an invalid password.
90 """
91 self.realm = realm
92 self.getpass = getpass
93 - def __call__(self, username, algo="md5"):
94 """Generates an authentification token from a username.
95 @type username: str
96 @rtype: str or None
97 """
98 assert algo.lower() in ["md5", "md5-sess"]
99 password = self.getpass(username)
100 if password is None:
101 return None
102 a1 = "%s:%s:%s" % (username, self.realm, password)
103 return md5(a1).hexdigest()
104
105 __all__.append("NonceStoreBase")
107 """Nonce storage interface."""
111 """
112 This method is to be overriden and should return new nonces.
113 @type ident: str
114 @param ident: is an identifier to be associated with this nonce
115 @rtype: str
116 """
117 raise NotImplementedError
118 - def checknonce(self, nonce, count=1, ident=None):
119 """
120 This method is to be overridden and should do a check for whether the
121 given nonce is valid as being used count times.
122 @type nonce: str
123 @type count: int
124 @param count: indicates how often the nonce has been used (including
125 this check)
126 @type ident: str
127 @param ident: it is also checked that the nonce was associated to this
128 identifier when given
129 @rtype: bool
130 """
131 raise NotImplementedError
132
141
142 __all__.append("StatelessNonceStore")
144 """
145 This is a stateless nonce storage that cannot check the usage count for
146 a nonce and thus cannot protect against replay attacks. It however can make
147 it difficult by posing a timeout on nonces and making it difficult to forge
148 nonces.
149
150 This nonce store is usable with L{scgi.forkpool}.
151
152 >>> s = StatelessNonceStore()
153 >>> n = s.newnonce()
154 >>> s.checknonce("spam")
155 False
156 >>> s.checknonce(n)
157 True
158 >>> s.checknonce(n)
159 True
160 >>> s.checknonce(n.rsplit(':', 1)[0] + "bad hash")
161 False
162 """
163 - def __init__(self, maxage=300, secret=None):
164 """
165 @type maxage: int
166 @param maxage: is the number of seconds a nonce may be valid. Choosing a
167 large value may result in more memory usage whereas a smaller
168 value results in more requests. Defaults to 5 minutes.
169 @type secret: str
170 @param secret: if not given, a secret is generated and is therefore
171 shared after forks. Knowing this secret permits creating nonces.
172 """
173 NonceStoreBase.__init__(self)
174 self.maxage = maxage
175 if secret:
176 self.server_secret = secret
177 else:
178 self.server_secret = gen_rand_str()
179
181 """
182 Generates a new nonce string.
183 @type ident: None or str
184 @rtype: str
185 """
186 nonce_time = format_time(time.time())
187 nonce_value = gen_rand_str()
188 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
189 if ident is not None:
190 token = "%s:%s" % (token, ident)
191 token = md5(token).hexdigest()
192 return "%s:%s:%s" % (nonce_time, nonce_value, token)
193
194 - def checknonce(self, nonce, count=1, ident=None):
195 """
196 Do a check for whether the provided string is a nonce and increase usage
197 count on returning True.
198 @type nonce: str
199 @type count: int
200 @type ident: None or str
201 @rtype: bool
202 """
203 if count != 1:
204 return False
205 try:
206 nonce_time, nonce_value, nonce_hash = nonce.split(':')
207 except ValueError:
208 return False
209 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
210 if ident is not None:
211 token = "%s:%s" % (token, ident)
212 token = md5(token).hexdigest()
213 if token != nonce_hash:
214 return False
215
216 if nonce_time < format_time(time.time() - self.maxage):
217 return False
218 return True
219
220 __all__.append("MemoryNonceStore")
222 """
223 Simple in-memory mechanism to store nonces.
224
225 >>> s = MemoryNonceStore(maxuses=1)
226 >>> n = s.newnonce()
227 >>> s.checknonce("spam")
228 False
229 >>> s.checknonce(n)
230 True
231 >>> s.checknonce(n)
232 False
233 """
234 - def __init__(self, maxage=300, maxuses=5):
235 """
236 @type maxage: int
237 @param maxage: is the number of seconds a nonce may be valid. Choosing a
238 large value may result in more memory usage whereas a smaller
239 value results in more requests. Defaults to 5 minutes.
240 @type maxuses: int
241 @param maxuses: is the number of times a nonce may be used (with
242 different nc values). A value of 1 makes nonces usable exactly
243 once resulting in more requests. Defaults to 5.
244 """
245 NonceStoreBase.__init__(self)
246 self.maxage = maxage
247 self.maxuses = maxuses
248 self.nonces = []
249
250 self.server_secret = gen_rand_str()
251
253 """internal methods cleaning list of valid nonces"""
254 old = format_time(time.time() - self.maxage)
255 while self.nonces and self.nonces[0][0] < old:
256 self.nonces.pop(0)
257
259 """
260 Generates a new nonce string.
261 @type ident: None or str
262 @rtype: str
263 """
264 self._cleanup()
265 nonce_time = format_time(time.time())
266 nonce_value = gen_rand_str()
267 self.nonces.append((nonce_time, nonce_value, 1))
268 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
269 if ident is not None:
270 token = "%s:%s" % (token, ident)
271 token = md5(token).hexdigest()
272 return "%s:%s:%s" % (nonce_time, nonce_value, token)
273
274 - def checknonce(self, nonce, count=1, ident=None):
275 """
276 Do a check for whether the provided string is a nonce and increase usage
277 count on returning True.
278 @type nonce: str
279 @type count: int
280 @type ident: None or str
281 @rtype: bool
282 """
283 try:
284 nonce_time, nonce_value, nonce_hash = nonce.split(':')
285 except ValueError:
286 return False
287 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
288 if ident is not None:
289 token = "%s:%s" % (token, ident)
290 token = md5(token).hexdigest()
291 if token != nonce_hash:
292 return False
293
294 self._cleanup()
295
296
297 lower, upper = 0, len(self.nonces) - 1
298 while lower < upper:
299 mid = (lower + upper) // 2
300 if nonce_time <= self.nonces[mid][0]:
301 upper = mid
302 else:
303 lower = mid + 1
304
305 if len(self.nonces) <= lower:
306 return False
307 (nt, nv, uses) = self.nonces[lower]
308 if nt != nonce_time or nv != nonce_value:
309 return False
310 if count != uses:
311 del self.nonces[lower]
312 return False
313 if uses >= self.maxuses:
314 del self.nonces[lower]
315 else:
316 self.nonces[lower] = (nt, nv, uses+1)
317 return True
318
319 __all__.append("LazyDBAPI2Opener")
321 """
322 Connects to database on first request. Otherwise it behaves like a dbapi2
323 connection. This may be usefull in combination with scgi.forkpool, because
324 this way each worker child opens a new database connection when the first
325 request is to be answered.
326 """
327 - def __init__(self, function, *args, **kwargs):
328 """
329 The database will be connected on the first method call. This is done
330 by calling the given function with the remaining parameters.
331 @param function: is the function that connects to the database
332 """
333 self._function = function
334 self._args = args
335 self._kwargs = kwargs
336 self._dbhandle = None
338 """Returns an open database connection. Open if necessary."""
339 if self._dbhandle is None:
340 self._dbhandle = self._function(*self._args, **self._kwargs)
341 self._function = self._args = self._kwargs = None
342 return self._dbhandle
355
356 __all__.append("DBAPI2NonceStore")
358 """
359 A dbapi2-backed nonce store implementation suitable for usage with forking
360 wsgi servers such as scgi.forkpool.
361 >>> import sqlite3
362 >>> db = sqlite3.connect(":memory:")
363 >>> db.cursor().execute("CREATE TABLE nonces (key, value);") and None
364 >>> db.commit() and None
365 >>> s = DBAPI2NonceStore(db, maxuses=1)
366 >>> n = s.newnonce()
367 >>> s.checknonce("spam")
368 False
369 >>> s.checknonce(n)
370 True
371 >>> s.checknonce(n)
372 False
373 """
374 - def __init__(self, dbhandle, maxage=300, maxuses=5, table="nonces"):
375 """
376 @param dbhandle: is a dbapi2 connection
377 @type maxage: int
378 @param maxage: is the number of seconds a nonce may be valid. Choosing a
379 large value may result in more memory usage whereas a smaller
380 value results in more requests. Defaults to 5 minutes.
381 @type maxuses: int
382 @param maxuses: is the number of times a nonce may be used (with
383 different nc values). A value of 1 makes nonces usable exactly
384 once resulting in more requests. Defaults to 5.
385 """
386 NonceStoreBase.__init__(self)
387 self.dbhandle = dbhandle
388 self.maxage = maxage
389 self.maxuses = maxuses
390 self.table = table
391 self.server_secret = gen_rand_str()
392
394 """internal methods cleaning list of valid nonces"""
395 old = format_time(time.time() - self.maxage)
396 cur.execute("DELETE FROM %s WHERE key < '%s:';" % (self.table, old))
397
399 """
400 Generates a new nonce string.
401 @rtype: str
402 """
403 nonce_time = format_time(time.time())
404 nonce_value = gen_rand_str()
405 dbkey = "%s:%s" % (nonce_time, nonce_value)
406 cur = self.dbhandle.cursor()
407 self._cleanup(cur)
408 cur.execute("INSERT INTO %s VALUES ('%s', '1');" % (self.table, dbkey))
409 self.dbhandle.commit()
410 token = "%s:%s" % (dbkey, self.server_secret)
411 if ident is not None:
412 token = "%s:%s" % (token, ident)
413 token = md5(token).hexdigest()
414 return "%s:%s:%s" % (nonce_time, nonce_value, token)
415
416 - def checknonce(self, nonce, count=1, ident=None):
417 """
418 Do a check for whether the provided string is a nonce and increase usage
419 count on returning True.
420 @type nonce: str
421 @type count: int
422 @rtype: bool
423 """
424 try:
425 nonce_time, nonce_value, nonce_hash = nonce.split(':')
426 except ValueError:
427 return False
428 if not nonce_time.isalnum() or not nonce_value.replace("+", ""). \
429 replace("/", "").replace("=", "").isalnum():
430 return False
431 token = "%s:%s:%s" % (nonce_time, nonce_value, self.server_secret)
432 if ident is not None:
433 token = "%s:%s" % (token, ident)
434 token = md5(token).hexdigest()
435 if token != nonce_hash:
436 return False
437
438 if nonce_time < format_time(time.time() - self.maxage):
439 return False
440
441 cur = self.dbhandle.cursor()
442
443
444 dbkey = "%s:%s" % (nonce_time, nonce_value)
445 cur.execute("SELECT value FROM %s WHERE key = '%s';" %
446 (self.table, dbkey))
447 uses = cur.fetchone()
448 if uses is None:
449 self.dbhandle.commit()
450 return False
451 uses = int(uses[0])
452 if count != uses:
453 cur.execute("DELETE FROM %s WHERE key = '%s';" %
454 (self.table, dbkey))
455 self.dbhandle.commit()
456 return False
457 if uses >= self.maxuses:
458 cur.execute("DELETE FROM %s WHERE key = '%s';" %
459 (self.table, dbkey))
460 else:
461 cur.execute("UPDATE %s SET value = '%d' WHERE key = '%s';" %
462 (self.table, uses + 1, dbkey))
463 self.dbhandle.commit()
464 return True
465
466 __all__.append("AuthDigestMiddleware")
468 """Middleware partly implementing RFC2617. (md5-sess was omited)"""
469 algorithms = {"md5": lambda data: md5(data).hexdigest()}
470 - def __init__(self, app, gentoken, maxage=300, maxuses=5, store=None):
471 """
472 @param app: is the wsgi application to be served with authentification.
473 @type gentoken: str -> (str or None)
474 @param gentoken: has to have the same functionality and interface as the
475 L{AuthTokenGenerator} class.
476 @type maxage: int
477 @param maxage: deprecated, see L{MemoryNonceStore} or
478 L{StatelessNonceStore}
479 @type maxuses: int
480 @param maxuses: deprecated, see L{MemoryNonceStore}
481 @type store: L{NonceStoreBase}
482 @param store: a nonce storage implementation object. Usage of this
483 parameter will override maxage and maxuses.
484 """
485 self.app = app
486 self.gentoken = gentoken
487 if store is None:
488 self.noncestore = MemoryNonceStore(maxage, maxuses)
489 else:
490 assert hasattr(store, "newnonce")
491 assert hasattr(store, "checknonce")
492 self.noncestore = store
493
494 - def __call__(self, environ, start_response):
495 """wsgi interface"""
496
497 try:
498 auth = environ["HTTP_AUTHORIZATION"]
499 method, rest = auth.split(' ', 1)
500
501 if method.lower() != "digest":
502 raise AuthenticationRequired
503 credentials = parse_digest_response(rest)
504
505
506 credentials["algorithm"] = credentials.get("algorithm",
507 "md5").lower()
508 if not credentials["algorithm"] in self.algorithms:
509 raise AuthenticationRequired
510
511
512
513
514
515 uri = credentials["uri"]
516 if "QUERY_STRING" in environ and environ["QUERY_STRING"]:
517 if not uri.endswith(environ["QUERY_STRING"]):
518 raise AuthenticationRequired
519 uri = uri[:-len(environ["QUERY_STRING"])]
520 if "SCRIPT_NAME" in environ:
521 if not uri.startswith(environ["SCRIPT_NAME"]):
522 raise AuthenticationRequired
523 uri = uri[len(environ["SCRIPT_NAME"]):]
524 if "PATH_INFO" in environ:
525 if not uri.startswith(environ["PATH_INFO"]):
526 raise AuthenticationRequired
527 uri = uri[len(environ["PATH_INFO"]):]
528 if uri not in ('', '?'):
529 raise AuthenticationRequired
530 del uri
531
532 if ("username" not in credentials or
533 "nonce" not in credentials or
534 "response" not in credentials or
535 "qop" in credentials and (
536 credentials["qop"] != "auth" or
537 "nc" not in credentials or
538 credentials["nc"].lower().strip("0123456789abcdef") or
539 "cnonce" not in credentials)):
540 raise AuthenticationRequired
541
542 noncecount = 1
543 if credentials.get("qop") is not None:
544
545 noncecount = int(credentials["nc"], 16)
546
547 if not self.noncestore.checknonce(credentials["nonce"], noncecount):
548 return self.authorization_required(environ, start_response,
549 stale=True)
550
551
552 response = self.auth_response(credentials,
553 environ["REQUEST_METHOD"])
554
555 if response != credentials["response"]:
556 raise AuthenticationRequired
557
558 except (KeyError, ValueError, AuthenticationRequired):
559 return self.authorization_required(environ, start_response)
560 else:
561 environ["REMOTE_USER"] = credentials["username"]
562 def modified_start_response(status, headers, exc_info=None):
563 digest = dict(nextnonce=self.noncestore.newnonce())
564 if "qop" in credentials:
565 digest["qop"] = "auth"
566 digest["cnonce"] = credentials["cnonce"]
567 digest["rspauth"] = self.auth_response(credentials, "")
568 challenge = ", ".join(map('%s="%s"'.__mod__, digest.items()))
569 headers.append(("Authentication-Info", challenge))
570 return start_response(status, headers, exc_info)
571 return self.app(environ, modified_start_response)
572
574 """internal method generating authentication tokens
575 @raise KeyError:
576 @raise ValueError:
577 """
578 username = credentials["username"]
579 algo = credentials["algorithm"]
580 uri = credentials["uri"]
581 nonce = credentials["nonce"]
582 a1h = self.gentoken(username, algo)
583 if a1h is None:
584 raise ValueError
585 a2h = self.algorithms[algo]("%s:%s" % (reqmethod, uri))
586 qop = credentials.get("qop", None)
587 if qop is None:
588 dig = ":".join((a1h, nonce, a2h))
589 else:
590 if qop != "auth":
591 raise ValueError
592
593 dig = ":".join((a1h, nonce, credentials["nc"],
594 credentials["cnonce"], qop, a2h))
595 return self.algorithms[algo](dig)
596
598 """internal method implementing wsgi interface, serving 401 page"""
599 digest = dict(nonce=self.noncestore.newnonce(),
600 realm=self.gentoken.realm,
601 algorithm="md5",
602 qop="auth")
603 if stale:
604 digest["stale"] = "TRUE"
605 challenge = ", ".join(map('%s="%s"'.__mod__, digest.items()))
606 status = "401 Not authorized"
607 headers = [("Content-type", "text/html"),
608 ("WWW-Authenticate", "Digest %s" % challenge)]
609 data = "<html><head><title>401 Not authorized</title></head><body><h1>"
610 data += "401 Not authorized</h1></body></html>"
611 headers.append(("Content-length", str(len(data))))
612 start_response(status, headers)
613 if environ["REQUEST_METHOD"] == "HEAD":
614 return []
615 return [data]
616