[1517] CalendarServer/branches/users/cdaboo/digest-1510
Revision: 1517 http://trac.macosforge.org/projects/calendarserver/changeset/1517 Author: cdaboo@apple.com Date: 2007-05-08 09:59:23 -0700 (Tue, 08 May 2007) Log Message: ----------- Move digest db patch out of Twisted and into CalendarServer code. Modified Paths: -------------- CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/digest.py Removed Paths: ------------- CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.auth.digest.patch CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.test.test_httpauth.patch Deleted: CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.auth.digest.patch =================================================================== --- CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.auth.digest.patch 2007-05-08 08:26:19 UTC (rev 1516) +++ CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.auth.digest.patch 2007-05-08 16:59:23 UTC (rev 1517) @@ -1,340 +0,0 @@ -Index: twisted/web2/auth/digest.py -=================================================================== ---- twisted/web2/auth/digest.py (revision 19773) -+++ twisted/web2/auth/digest.py (working copy) -@@ -173,14 +173,110 @@ - return expected == response - - -+class IDigestCredentialsDatabase(Interface): -+ """ -+ An interface to a digest credentials database that is used to hold per-client digest credentials so that fast -+ re-authentication can be done with replay attacks etc prevented. -+ """ -+ -+ def has_key(self, key): -+ """ -+ See whether the matching key exists. -+ -+ @param key: the key to check. -+ @type key: C{str}. -+ -+ @return: C{True} if the key exists, C{False} otherwise. -+ """ -+ pass -+ -+ def set(self, key, value): -+ """ -+ Store per-client credential information the first time a nonce is generated and used. -+ -+ @param key: the key for the data to store. -+ @type key: C{str} -+ @param value: the data to store. -+ @type value: any. -+ """ -+ pass -+ -+ def get(self, key): -+ """ -+ Validate client supplied credentials by comparing with the cached values. If valid, store the new -+ cnonce value in the database so that it can be used on the next validate. -+ -+ @param key: the key to check. -+ @type key: C{str}. -+ -+ @return: the value for the corresponding key, or C{None} if the key is not found. -+ """ -+ pass -+ -+ def delete(self, key): -+ """ -+ Remove the record associated with the supplied key. -+ -+ @param key: the key to remove. -+ @type key: C{str} -+ """ -+ pass -+ -+ def keys(self): -+ """ -+ Return all the keys currently available. -+ -+ @return: a C{list} of C{str} for each key currently in the database. -+ """ -+ pass -+ -+class DigestCredentialsMap: -+ -+ implements(IDigestCredentialsDatabase) -+ -+ def __init__(self, *args): -+ self.db = {} -+ -+ def has_key(self, key): -+ """ -+ See IDigestCredentialsDatabase. -+ """ -+ return self.db.has_key(key) -+ -+ def set(self, key, value): -+ """ -+ See IDigestCredentialsDatabase. -+ """ -+ self.db[key] = value -+ -+ def get(self, key): -+ """ -+ See IDigestCredentialsDatabase. -+ """ -+ if self.db.has_key(key): -+ return self.db[key] -+ else: -+ return None -+ -+ def delete(self, key): -+ """ -+ See IDigestCredentialsDatabase. -+ """ -+ if self.db.has_key(key): -+ del self.db[key] -+ -+ def keys(self): -+ """ -+ See IDigestCredentialsDatabase. -+ """ -+ return self.db.keys() -+ - class DigestCredentialFactory(object): - """ - Support for RFC2617 HTTP Digest Authentication - - @cvar CHALLENGE_LIFETIME_SECS: The number of seconds for which an - opaque should be valid. -- -- @ivar privateKey: A random string used for generating the secure opaque. - """ - - implements(ICredentialFactory) -@@ -189,7 +285,7 @@ - - scheme = "digest" - -- def __init__(self, algorithm, realm): -+ def __init__(self, algorithm, realm, mapper=DigestCredentialsMap, mapperargs=[]): - """ - @type algorithm: C{str} - @param algorithm: case insensitive string that specifies -@@ -199,87 +295,19 @@ - @type realm: C{str} - @param realm: case sensitive string that specifies the realm - portion of the challenge -+ -+ @type mapper: C{IDigestCredentialsDatabase} -+ @param realm: the class of mapper to use with this factory - """ - self.algorithm = algorithm - self.realm = realm -+ self.db = mapper(*mapperargs) - -- c = tuple([random.randrange(sys.maxint) for _ in range(3)]) -- -- self.privateKey = '%d%d%d' % c -- - def generateNonce(self): - c = tuple([random.randrange(sys.maxint) for _ in range(3)]) - c = '%d%d%d' % c - return c - -- def _getTime(self): -- """ -- Parameterize the time based seed used in generateOpaque -- so we can deterministically unittest it's behavior. -- """ -- return time.time() -- -- def generateOpaque(self, nonce, clientip): -- """ -- Generate an opaque to be returned to the client. -- This should be a unique string that can be returned to us and verified. -- """ -- -- # Now, what we do is encode the nonce, client ip and a timestamp -- # in the opaque value with a suitable digest -- key = "%s,%s,%s" % (nonce, clientip, str(int(self._getTime()))) -- digest = md5.new(key + self.privateKey).hexdigest() -- ekey = key.encode('base64') -- return "%s-%s" % (digest, ekey.strip('\n')) -- -- def verifyOpaque(self, opaque, nonce, clientip): -- """ -- Given the opaque and nonce from the request, as well as the clientip -- that made the request, verify that the opaque was generated by us. -- And that it's not too old." -- -- @param opaque: The opaque value from the Digest response -- @param nonce: The nonce value from the Digest response -- @param clientip: The remote IP address of the client making the request -- -- @return: Return True if the opaque was successfully verified -- -- @raise L{twisted.cred.error.LoginFailed} -- """ -- -- # First split the digest from the key -- opaqueParts = opaque.split('-') -- if len(opaqueParts) != 2: -- raise error.LoginFailed('Invalid response, invalid opaque value') -- -- # Verify the key -- key = opaqueParts[1].decode('base64') -- keyParts = key.split(',') -- -- if len(keyParts) != 3: -- raise error.LoginFailed('Invalid response, invalid opaque value') -- -- if keyParts[0] != nonce: -- raise error.LoginFailed( -- 'Invalid response, incompatible opaque/nonce values') -- -- if keyParts[1] != clientip: -- raise error.LoginFailed( -- 'Invalid response, incompatible opaque/client values') -- -- if (int(self._getTime()) - int(keyParts[2]) > -- DigestCredentialFactory.CHALLENGE_LIFETIME_SECS): -- -- raise error.LoginFailed( -- 'Invalid response, incompatible opaque/nonce too old') -- -- # Verify the digest -- digest = md5.new(key + self.privateKey).hexdigest() -- if digest != opaqueParts[0]: -- raise error.LoginFailed('Invalid response, invalid opaque value') -- -- return True -- - def getChallenge(self, peer): - """ - Generate the challenge for use in the WWW-Authenticate header -@@ -291,10 +319,15 @@ - """ - - c = self.generateNonce() -- o = self.generateOpaque(c, peer.host) -+ -+ # Make sure it is not a duplicate -+ if self.db.has_key(c): -+ raise AssertionError("nonce value already cached in credentials database: %s" % (c,)) - -+ # The database record is a tuple of (client ip, nonce-count, timestamp) -+ self.db.set(c, (peer.host, 0, time.time())) -+ - return {'nonce': c, -- 'opaque': o, - 'qop': 'auth', - 'algorithm': self.algorithm, - 'realm': self.realm} -@@ -331,18 +364,96 @@ - if not username: - raise error.LoginFailed('Invalid response, no username given.') - -- if 'opaque' not in auth: -- raise error.LoginFailed('Invalid response, no opaque given.') -- - if 'nonce' not in auth: - raise error.LoginFailed('Invalid response, no nonce given.') - -- # Now verify the nonce/opaque values for this client -- if self.verifyOpaque(auth.get('opaque'), -- auth.get('nonce'), -- request.remoteAddr.host): -+ # Now verify the nonce/cnonce values for this client -+ if self.validate(auth, request): - - return DigestedCredentials(username, - request.method, - self.realm, - auth) -+ else: -+ raise error.LoginFailed('Invalid nonce/cnonce values') -+ -+ def validate(self, auth, request): -+ """ -+ Check that the parameters in the response represent a valid set of credentials that -+ may be being re-used. -+ -+ @param auth: the response parameters. -+ @type auth: C{dict} -+ @param request: the request being processed. -+ @type request: L{twisted.web2.server.Request} -+ -+ @return: C{True} if validated. -+ @raise LoginFailed: if validation fails. -+ """ -+ -+ nonce = auth.get('nonce') -+ clientip = request.remoteAddr.host -+ nonce_count = auth.get('nc') -+ -+ # First check we have this nonce -+ if not self.db.has_key(nonce): -+ raise error.LoginFailed('Invalid nonce value: %s' % (nonce,)) -+ db_clientip, db_nonce_count, db_timestamp = self.db.get(nonce) -+ -+ # Next check client ip -+ if db_clientip != clientip: -+ self.invalidate(nonce) -+ raise error.LoginFailed('Client IPs do not match: %s and %s' % (clientip, db_clientip,)) -+ -+ # cnonce and nonce-count MUST be present if qop is present -+ if auth.get('qop') is not None: -+ if auth.get('cnonce') is None: -+ self.invalidate(nonce) -+ raise error.LoginFailed('cnonce is required when qop is specified') -+ if nonce_count is None: -+ self.invalidate(nonce) -+ raise error.LoginFailed('nonce-count is required when qop is specified') -+ -+ # Next check the nonce-count is one greater than the previous one and update it in the DB -+ try: -+ nonce_count = int(nonce_count, 16) -+ except ValueError: -+ self.invalidate(nonce) -+ raise error.LoginFailed('nonce-count is not a valid hex string: %s' % (auth.get('nonce-count'),)) -+ if nonce_count != db_nonce_count + 1: -+ self.invalidate(nonce) -+ raise error.LoginFailed('nonce-count value out of sequence: %s should be one more than %s' % (nonce_count, db_nonce_count,)) -+ self.db.set(nonce, (db_clientip, nonce_count, db_timestamp)) -+ else: -+ # When not using qop the stored nonce-count must always be zero. -+ # i.e. we can't allow a qop auth then a non-qop auth with the same nonce -+ if db_nonce_count != 0: -+ self.invalidate(nonce) -+ raise error.LoginFailed('nonce-count was sent with this nonce: %s' % (nonce,)) -+ -+ # Now check timestamp -+ if db_timestamp + DigestCredentialFactory.CHALLENGE_LIFETIME_SECS <= time.time(): -+ self.invalidate(nonce) -+ raise error.LoginFailed('Digest credentials expired') -+ -+ return True -+ -+ def invalidate(self, nonce): -+ """ -+ Invalidate cached credentials for the specified nonce value. -+ -+ @param nonce: the nonce for the record to invalidate. -+ @type nonce: C{str} -+ """ -+ self.db.delete(nonce) -+ -+ def cleanup(self): -+ """ -+ This should be called at regular intervals to remove expired credentials from the cache. -+ """ -+ keys = self.db.keys() -+ oldest_allowed = time.time() - DigestCredentialFactory.CHALLENGE_LIFETIME_SECS -+ for key in keys: -+ ignore_clientip, ignore_cnonce, db_timestamp = self.db.get(key) -+ if db_timestamp <= oldest_allowed: -+ self.invalidate(key) Deleted: CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.test.test_httpauth.patch =================================================================== --- CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.test.test_httpauth.patch 2007-05-08 08:26:19 UTC (rev 1516) +++ CalendarServer/branches/users/cdaboo/digest-1510/lib-patches/Twisted/twisted.web2.test.test_httpauth.patch 2007-05-08 16:59:23 UTC (rev 1517) @@ -1,294 +0,0 @@ -Index: twisted/web2/test/test_httpauth.py -=================================================================== ---- twisted/web2/test/test_httpauth.py (revision 19773) -+++ twisted/web2/test/test_httpauth.py (working copy) -@@ -20,21 +20,13 @@ - def __init__(self, *args, **kwargs): - super(FakeDigestCredentialFactory, self).__init__(*args, **kwargs) - -- self.privateKey = "0" -- - def generateNonce(self): - """ - Generate a static nonce - """ - return '178288758716122392881254770685' - -- def _getTime(self): -- """ -- Return a stable time -- """ -- return 0 - -- - class BasicAuthTestCase(unittest.TestCase): - def setUp(self): - self.credentialFactory = basic.BasicCredentialFactory('foo') -@@ -86,18 +78,17 @@ - challengeResponse = ('digest', - {'nonce': challengeNonce, - 'qop': 'auth', 'realm': 'test realm', -- 'algorithm': 'md5', -- 'opaque': challengeOpaque}) -+ 'algorithm': 'md5',}) - - cnonce = "29fc54aa1641c6fa0e151419361c8f23" - - authRequest1 = ('username="username", realm="test realm", nonce="%s", ' -- 'uri="/write/", response="%s", opaque="%s", algorithm="md5", ' -+ 'uri="/write/", response="%s", algorithm="md5", ' - 'cnonce="29fc54aa1641c6fa0e151419361c8f23", nc=00000001, ' - 'qop="auth"') - - authRequest2 = ('username="username", realm="test realm", nonce="%s", ' -- 'uri="/write/", response="%s", opaque="%s", algorithm="md5", ' -+ 'uri="/write/", response="%s", algorithm="md5", ' - 'cnonce="29fc54aa1641c6fa0e151419361c8f23", nc=00000002, ' - 'qop="auth"') - -@@ -147,7 +138,6 @@ - self.assertEquals(challenge['realm'], 'test realm') - self.assertEquals(challenge['algorithm'], 'md5') - self.assertTrue(challenge.has_key("nonce")) -- self.assertTrue(challenge.has_key("opaque")) - - def test_response(self): - """ -@@ -159,7 +149,7 @@ - clientResponse = authRequest1 % ( - challenge['nonce'], - self.getDigestResponse(challenge, "00000001"), -- challenge['opaque']) -+ ) - - creds = self.credentialFactory.decode(clientResponse, _trivial_GET) - self.failUnless(creds.checkPassword('password')) -@@ -175,7 +165,7 @@ - clientResponse = authRequest1 % ( - challenge['nonce'], - self.getDigestResponse(challenge, "00000001"), -- challenge['opaque']) -+ ) - - creds = self.credentialFactory.decode(clientResponse, _trivial_GET) - self.failUnless(creds.checkPassword('password')) -@@ -183,7 +173,7 @@ - clientResponse = authRequest2 % ( - challenge['nonce'], - self.getDigestResponse(challenge, "00000002"), -- challenge['opaque']) -+ ) - - creds = self.credentialFactory.decode(clientResponse, _trivial_GET) - self.failUnless(creds.checkPassword('password')) -@@ -199,7 +189,7 @@ - clientResponse = authRequest1 % ( - challenge['nonce'], - self.getDigestResponse(challenge, "00000001"), -- challenge['opaque']) -+ ) - - creds = self.credentialFactory.decode(clientResponse, - SimpleRequest(None, 'POST', '/')) -@@ -236,17 +226,6 @@ - _trivial_GET) - self.assertEquals(str(e), "Invalid response, no nonce given.") - -- def test_noOpaque(self): -- """ -- Test that login fails when our response does not contain a nonce -- """ -- -- e = self.assertRaises(error.LoginFailed, -- self.credentialFactory.decode, -- 'realm="Test",username="Foo"', -- _trivial_GET) -- self.assertEquals(str(e), "Invalid response, no opaque given.") -- - def test_checkHash(self): - """ - Check that given a hash of the form 'username:realm:password' -@@ -258,7 +237,7 @@ - clientResponse = authRequest1 % ( - challenge['nonce'], - self.getDigestResponse(challenge, "00000001"), -- challenge['opaque']) -+ ) - - creds = self.credentialFactory.decode(clientResponse, _trivial_GET) - -@@ -268,41 +247,36 @@ - self.failIf(creds.checkHash( - md5.md5('username:test realm:bogus').hexdigest())) - -- def test_invalidOpaque(self): -+ def test_invalidNonceCount(self): - """ -- Test that login fails when the opaque does not contain all the required -- parts. -+ Test that login fails when the nonce-count is repeated. - """ - - credentialFactory = FakeDigestCredentialFactory('md5', 'test realm') - - challenge = credentialFactory.getChallenge(clientAddress) - -- self.assertRaises( -- error.LoginFailed, -- credentialFactory.verifyOpaque, -- 'badOpaque', -+ clientResponse1 = authRequest1 % ( - challenge['nonce'], -- clientAddress.host) -+ self.getDigestResponse(challenge, "00000001"), -+ ) - -- badOpaque = ('foo-%s' % ( -- 'nonce,clientip'.encode('base64').strip('\n'),)) -- -- self.assertRaises( -- error.LoginFailed, -- credentialFactory.verifyOpaque, -- badOpaque, -+ clientResponse2 = authRequest2 % ( - challenge['nonce'], -- clientAddress.host) -+ self.getDigestResponse(challenge, "00000002"), -+ ) - -+ credentialFactory.decode(clientResponse1, _trivial_GET) -+ credentialFactory.decode(clientResponse2, _trivial_GET) -+ - self.assertRaises( - error.LoginFailed, -- credentialFactory.verifyOpaque, -- '', -- challenge['nonce'], -- clientAddress.host) -+ self.credentialFactory.decode, -+ clientResponse2, -+ _trivial_GET -+ ) - -- def test_incompatibleNonce(self): -+ def test_invalidNonce(self): - """ - Test that login fails when the given nonce from the response, does not - match the nonce encoded in the opaque. -@@ -311,24 +285,19 @@ - credentialFactory = FakeDigestCredentialFactory('md5', 'test realm') - - challenge = credentialFactory.getChallenge(clientAddress) -+ challenge['nonce'] = "noNoncense" - -- badNonceOpaque = credentialFactory.generateOpaque( -- '1234567890', -- clientAddress.host) -- -- self.assertRaises( -- error.LoginFailed, -- credentialFactory.verifyOpaque, -- badNonceOpaque, -+ clientResponse = authRequest1 % ( - challenge['nonce'], -- clientAddress.host) -+ self.getDigestResponse(challenge, "00000001"), -+ ) - - self.assertRaises( - error.LoginFailed, -- credentialFactory.verifyOpaque, -- badNonceOpaque, -- '', -- clientAddress.host) -+ credentialFactory.decode, -+ clientResponse, -+ _trivial_GET -+ ) - - def test_incompatibleClientIp(self): - """ -@@ -338,18 +307,19 @@ - - credentialFactory = FakeDigestCredentialFactory('md5', 'test realm') - -- challenge = credentialFactory.getChallenge(clientAddress) -+ challenge = credentialFactory.getChallenge(address.IPv4Address('TCP', '127.0.0.2', 80)) - -- badNonceOpaque = credentialFactory.generateOpaque( -+ clientResponse = authRequest1 % ( - challenge['nonce'], -- '10.0.0.1') -+ self.getDigestResponse(challenge, "00000001"), -+ ) - - self.assertRaises( - error.LoginFailed, -- credentialFactory.verifyOpaque, -- badNonceOpaque, -- challenge['nonce'], -- clientAddress.host) -+ credentialFactory.decode, -+ clientResponse, -+ _trivial_GET -+ ) - - def test_oldNonce(self): - """ -@@ -360,47 +330,20 @@ - credentialFactory = FakeDigestCredentialFactory('md5', 'test realm') - - challenge = credentialFactory.getChallenge(clientAddress) -+ clientip, nonce_count, timestamp = credentialFactory.db.get(challenge['nonce']) -+ credentialFactory.db.set(challenge['nonce'], (clientip, nonce_count, timestamp - 2 * digest.DigestCredentialFactory.CHALLENGE_LIFETIME_SECS)) - -- key = '%s,%s,%s' % (challenge['nonce'], -- clientAddress.host, -- '-137876876') -- digest = md5.new(key + credentialFactory.privateKey).hexdigest() -- ekey = key.encode('base64') -- -- oldNonceOpaque = '%s-%s' % (digest, ekey.strip('\n')) -- -- self.assertRaises( -- error.LoginFailed, -- credentialFactory.verifyOpaque, -- oldNonceOpaque, -+ clientResponse = authRequest1 % ( - challenge['nonce'], -- clientAddress.host) -+ self.getDigestResponse(challenge, "00000001"), -+ ) - -- def test_mismatchedOpaqueChecksum(self): -- """ -- Test that login fails when the opaque checksum fails verification -- """ -- -- credentialFactory = FakeDigestCredentialFactory('md5', 'test realm') -- -- challenge = credentialFactory.getChallenge(clientAddress) -- -- -- key = '%s,%s,%s' % (challenge['nonce'], -- clientAddress.host, -- '0') -- -- digest = md5.new(key + 'this is not the right pkey').hexdigest() -- -- badChecksum = '%s-%s' % (digest, -- key.encode('base64').strip('\n')) -- - self.assertRaises( - error.LoginFailed, -- credentialFactory.verifyOpaque, -- badChecksum, -- challenge['nonce'], -- clientAddress.host) -+ credentialFactory.decode, -+ clientResponse, -+ _trivial_GET -+ ) - - def test_incompatibleCalcHA1Options(self): - """ Modified: CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/digest.py =================================================================== --- CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/digest.py 2007-05-08 08:26:19 UTC (rev 1516) +++ CalendarServer/branches/users/cdaboo/digest-1510/twistedcaldav/directory/digest.py 2007-05-08 16:59:23 UTC (rev 1517) @@ -18,11 +18,14 @@ from twistedcaldav.sql import AbstractSQLDatabase -from twisted.web2.auth.digest import DigestCredentialFactory, IDigestCredentialsDatabase +from twisted.web2.auth.digest import DigestCredentialFactory -from zope.interface.declarations import implements +from zope.interface import implements, Interface import cPickle as pickle +from twisted.cred import error +from twisted.web2.auth.digest import DigestedCredentials +import time import os """ @@ -31,6 +34,104 @@ """ +class IDigestCredentialsDatabase(Interface): + """ + An interface to a digest credentials database that is used to hold per-client digest credentials so that fast + re-authentication can be done with replay attacks etc prevented. + """ + + def has_key(self, key): + """ + See whether the matching key exists. + + @param key: the key to check. + @type key: C{str}. + + @return: C{True} if the key exists, C{False} otherwise. + """ + pass + + def set(self, key, value): + """ + Store per-client credential information the first time a nonce is generated and used. + + @param key: the key for the data to store. + @type key: C{str} + @param value: the data to store. + @type value: any. + """ + pass + + def get(self, key): + """ + Validate client supplied credentials by comparing with the cached values. If valid, store the new + cnonce value in the database so that it can be used on the next validate. + + @param key: the key to check. + @type key: C{str}. + + @return: the value for the corresponding key, or C{None} if the key is not found. + """ + pass + + def delete(self, key): + """ + Remove the record associated with the supplied key. + + @param key: the key to remove. + @type key: C{str} + """ + pass + + def keys(self): + """ + Return all the keys currently available. + + @return: a C{list} of C{str} for each key currently in the database. + """ + pass + +class DigestCredentialsMap: + + implements(IDigestCredentialsDatabase) + + def __init__(self, *args): + self.db = {} + + def has_key(self, key): + """ + See IDigestCredentialsDatabase. + """ + return self.db.has_key(key) + + def set(self, key, value): + """ + See IDigestCredentialsDatabase. + """ + self.db[key] = value + + def get(self, key): + """ + See IDigestCredentialsDatabase. + """ + if self.db.has_key(key): + return self.db[key] + else: + return None + + def delete(self, key): + """ + See IDigestCredentialsDatabase. + """ + if self.db.has_key(key): + del self.db[key] + + def keys(self): + """ + See IDigestCredentialsDatabase. + """ + return self.db.keys() + class DigestCredentialsDB(AbstractSQLDatabase): implements(IDigestCredentialsDatabase) @@ -177,16 +278,36 @@ @type db_path: C{str} @param db_path: path where the credentials cache is to be stored """ - super(QopDigestCredentialFactory, self).__init__(algorithm, realm, mapper=DigestCredentialsDB, mapperargs=[db_path]) + super(QopDigestCredentialFactory, self).__init__(algorithm, realm) self.qop = qop + self.db = DigestCredentialsDB(db_path) def getChallenge(self, peer): """ + Generate the challenge for use in the WWW-Authenticate header Do the default behavior but then strip out any 'qop' from the challenge fields if no qop was specified. + + @param peer: The L{IAddress} of the requesting client. + + @return: The C{dict} that can be used to generate a WWW-Authenticate + header. """ - challenge = super(QopDigestCredentialFactory, self).getChallenge(peer) + c = self.generateNonce() + + # Make sure it is not a duplicate + if self.db.has_key(c): + raise AssertionError("nonce value already cached in credentials database: %s" % (c,)) + + # The database record is a tuple of (client ip, nonce-count, timestamp) + self.db.set(c, (peer.host, 0, time.time())) + + challenge = {'nonce': c, + 'qop': 'auth', + 'algorithm': self.algorithm, + 'realm': self.realm} + if self.qop: challenge['qop'] = self.qop else: @@ -200,7 +321,130 @@ if no qop was specified. """ - credentials = super(QopDigestCredentialFactory, self).decode(response, request) - if not self.qop and credentials.fields.has_key('qop'): - del credentials.fields['qop'] - return credentials + """ + Decode the given response and attempt to generate a + L{DigestedCredentials} from it. + + @type response: C{str} + @param response: A string of comma seperated key=value pairs + + @type request: L{twisted.web2.server.Request} + @param request: the request being processed + + @return: L{DigestedCredentials} + + @raise: L{error.LoginFailed} if the response does not contain a + username, a nonce, an opaque, or if the opaque is invalid. + """ + def unq(s): + if s[0] == s[-1] == '"': + return s[1:-1] + return s + response = ' '.join(response.splitlines()) + parts = response.split(',') + + auth = {} + + for (k, v) in [p.split('=', 1) for p in parts]: + auth[k.strip()] = unq(v.strip()) + + username = auth.get('username') + if not username: + raise error.LoginFailed('Invalid response, no username given.') + + if 'nonce' not in auth: + raise error.LoginFailed('Invalid response, no nonce given.') + + # Now verify the nonce/cnonce values for this client + if self.validate(auth, request): + + credentials = DigestedCredentials(username, + request.method, + self.realm, + auth) + if not self.qop and credentials.fields.has_key('qop'): + del credentials.fields['qop'] + return credentials + else: + raise error.LoginFailed('Invalid nonce/cnonce values') + + def validate(self, auth, request): + """ + Check that the parameters in the response represent a valid set of credentials that + may be being re-used. + + @param auth: the response parameters. + @type auth: C{dict} + @param request: the request being processed. + @type request: L{twisted.web2.server.Request} + + @return: C{True} if validated. + @raise LoginFailed: if validation fails. + """ + + nonce = auth.get('nonce') + clientip = request.remoteAddr.host + nonce_count = auth.get('nc') + + # First check we have this nonce + if not self.db.has_key(nonce): + raise error.LoginFailed('Invalid nonce value: %s' % (nonce,)) + db_clientip, db_nonce_count, db_timestamp = self.db.get(nonce) + + # Next check client ip + if db_clientip != clientip: + self.invalidate(nonce) + raise error.LoginFailed('Client IPs do not match: %s and %s' % (clientip, db_clientip,)) + + # cnonce and nonce-count MUST be present if qop is present + if auth.get('qop') is not None: + if auth.get('cnonce') is None: + self.invalidate(nonce) + raise error.LoginFailed('cnonce is required when qop is specified') + if nonce_count is None: + self.invalidate(nonce) + raise error.LoginFailed('nonce-count is required when qop is specified') + + # Next check the nonce-count is one greater than the previous one and update it in the DB + try: + nonce_count = int(nonce_count, 16) + except ValueError: + self.invalidate(nonce) + raise error.LoginFailed('nonce-count is not a valid hex string: %s' % (auth.get('nonce-count'),)) + if nonce_count != db_nonce_count + 1: + self.invalidate(nonce) + raise error.LoginFailed('nonce-count value out of sequence: %s should be one more than %s' % (nonce_count, db_nonce_count,)) + self.db.set(nonce, (db_clientip, nonce_count, db_timestamp)) + else: + # When not using qop the stored nonce-count must always be zero. + # i.e. we can't allow a qop auth then a non-qop auth with the same nonce + if db_nonce_count != 0: + self.invalidate(nonce) + raise error.LoginFailed('nonce-count was sent with this nonce: %s' % (nonce,)) + + # Now check timestamp + if db_timestamp + DigestCredentialFactory.CHALLENGE_LIFETIME_SECS <= time.time(): + self.invalidate(nonce) + raise error.LoginFailed('Digest credentials expired') + + return True + + def invalidate(self, nonce): + """ + Invalidate cached credentials for the specified nonce value. + + @param nonce: the nonce for the record to invalidate. + @type nonce: C{str} + """ + self.db.delete(nonce) + + def cleanup(self): + """ + This should be called at regular intervals to remove expired credentials from the cache. + """ + keys = self.db.keys() + oldest_allowed = time.time() - DigestCredentialFactory.CHALLENGE_LIFETIME_SECS + for key in keys: + ignore_clientip, ignore_cnonce, db_timestamp = self.db.get(key) + if db_timestamp <= oldest_allowed: + self.invalidate(key)
participants (1)
-
source_changes@macosforge.org