[CalendarServer-changes] [1517] CalendarServer/branches/users/cdaboo/digest-1510

source_changes at macosforge.org source_changes at macosforge.org
Tue May 8 09:59:23 PDT 2007


Revision: 1517
          http://trac.macosforge.org/projects/calendarserver/changeset/1517
Author:   cdaboo at 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)

-------------- next part --------------
An HTML attachment was scrubbed...
URL: http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20070508/eb75e7ca/attachment.html


More information about the calendarserver-changes mailing list