[CalendarServer-changes] [11403] CalendarServer/trunk

source_changes at macosforge.org source_changes at macosforge.org
Tue Jun 25 18:07:36 PDT 2013


Revision: 11403
          http://trac.calendarserver.org//changeset/11403
Author:   sagen at apple.com
Date:     2013-06-25 18:07:36 -0700 (Tue, 25 Jun 2013)
Log Message:
-----------
Server adminstration agent tests and authentication

Modified Paths:
--------------
    CalendarServer/trunk/calendarserver/tools/agent.py
    CalendarServer/trunk/contrib/launchd/agent.plist

Added Paths:
-----------
    CalendarServer/trunk/calendarserver/tools/test/test_agent.py

Modified: CalendarServer/trunk/calendarserver/tools/agent.py
===================================================================
--- CalendarServer/trunk/calendarserver/tools/agent.py	2013-06-25 18:48:16 UTC (rev 11402)
+++ CalendarServer/trunk/calendarserver/tools/agent.py	2013-06-26 01:07:36 UTC (rev 11403)
@@ -19,17 +19,30 @@
 
 import cStringIO
 import socket
+
+from calendarserver.tap.util import getRootResource
 from twext.python.plistlib import readPlistFromString, writePlistToString
-from calendarserver.tap.util import getRootResource
 from twisted.application.internet import StreamServerEndpointService
+from twisted.cred.checkers import ICredentialsChecker
+from twisted.cred.credentials import IUsernameHashedPassword
+from twisted.cred.error import UnauthorizedLogin 
+from twisted.cred.portal import IRealm, Portal
+from twisted.internet.defer import inlineCallbacks, returnValue, succeed, fail
 from twisted.internet.endpoints import AdoptedStreamServerEndpoint
+from twisted.internet.protocol import Factory
 from twisted.protocols import amp
-from twisted.internet.protocol import Factory
-from twisted.internet.defer import inlineCallbacks, returnValue
+from twisted.web.guard import HTTPAuthSessionWrapper, DigestCredentialFactory
+from twisted.web.resource import IResource, Resource, ForbiddenResource
+from twisted.web.server import Site, NOT_DONE_YET
+from zope.interface import implements
 
+
 # TODO, implement this:
 # from launchd import getLaunchdSocketFds
 
+def getLaunchdSocketFds():
+    pass
+
 # For the sample client, below:
 from twisted.internet import reactor
 from twisted.internet.protocol import ClientCreator
@@ -37,13 +50,219 @@
 from twext.python.log import Logger
 log = Logger()
 
+
 """
 A service spawned on-demand by launchd, meant to handle configuration requests
 from Server.app.  When a request comes in on the socket specified in the launchd
 agent.plist, launchd will run "caldavd -t Agent" which ends up creating this
-service.  AMP protocol commands sent to this socket are passed to gateway.Runner.
+service.  Requests are made using HTTP POSTS to /gateway, and are authenticated
+by OpenDirectory.
 """
 
+class DirectoryServiceChecker:
+    """
+    A checker that knows how to ask OpenDirectory to authenticate via Digest
+    """
+    implements(ICredentialsChecker)
+
+    credentialInterfaces = (IUsernameHashedPassword,)
+
+    from calendarserver.platform.darwin.od import opendirectory
+    directoryModule = opendirectory
+
+    def __init__(self, node):
+        """
+        @param node: the name of the OpenDirectory node to use, e.g. /Local/Default
+        """
+        self.node = node
+        self.directory = self.directoryModule.odInit(node)
+
+    def requestAvatarId(self, credentials):
+        record = self.directoryModule.getUserRecord(self.directory, credentials.username)
+
+        if record is not None:
+            try:
+                if "algorithm" not in credentials.fields:
+                    credentials.fields["algorithm"] = "md5"
+
+                challenge = 'Digest realm="%(realm)s", nonce="%(nonce)s", algorithm=%(algorithm)s' % credentials.fields
+
+                response = (
+                    'Digest username="%(username)s", '
+                    'realm="%(realm)s", '
+                    'nonce="%(nonce)s", '
+                    'uri="%(uri)s", '
+                    'response="%(response)s",'
+                    'algorithm=%(algorithm)s'
+                ) % credentials.fields
+
+            except KeyError as e:
+                log.error(
+                    "OpenDirectory (node=%s) error while performing digest authentication for user %s: "
+                    "missing digest response field: %s in: %s"
+                    % (self.node, credentials.username, e, credentials.fields)
+                )
+                return fail(UnauthorizedLogin())
+
+            try:
+                if self.directoryModule.authenticateUserDigest(self.directory,
+                    self.node,
+                    credentials.username,
+                    challenge,
+                    response,
+                    credentials.method
+                ):
+                    return succeed(credentials.username)
+                else:
+                    log.error("Failed digest auth with response: %s" % (response,))
+                    return fail(UnauthorizedLogin())
+            except self.directoryModule.ODNSerror as e:
+                log.error(
+                    "OpenDirectory error while performing digest authentication for user %s: %s"
+                    % (credentials.username, e)
+                )
+                return fail(UnauthorizedLogin())
+
+        else:
+            return fail(UnauthorizedLogin())
+
+
+class CustomDigestCredentialFactory(DigestCredentialFactory):
+    """
+    DigestCredentialFactory without qop, to interop with OD.
+    """
+
+    def getChallenge(self, address):
+        result = DigestCredentialFactory.getChallenge(self, address)
+        del result["qop"]
+        return result
+
+
+class AgentRealm(object):
+    """
+    Only allow a specified list of avatar IDs to access the site
+    """
+    implements(IRealm)
+
+    def __init__(self, root, allowedAvatarIds):
+        """
+        @param root: The root resource of the site
+        @param allowedAvatarIds: The list of IDs to allow access to
+        """
+        self.root = root
+        self.allowedAvatarIds = allowedAvatarIds
+
+    def requestAvatar(self, avatarId, mind, *interfaces):
+        if IResource in interfaces:
+            if avatarId in self.allowedAvatarIds:
+                return (IResource, self.root, lambda: None)
+            else:
+                return (IResource, ForbiddenResource(), lambda: None)
+
+        raise NotImplementedError()
+
+
+
+class AgentGatewayResource(Resource):
+    """
+    The gateway resource which forwards incoming requests through gateway.Runner.
+    """
+    isLeaf = True
+
+    def __init__(self, store, davRootResource, directory):
+        """
+        @param store: an already opened store
+        @param davRootResource: the root resource, required for principal
+            operations
+        @param directory: a directory service
+        """
+        Resource.__init__(self)
+        self.store = store
+        self.davRootResource = davRootResource
+        self.directory = directory
+
+    def render_POST(self, request):
+        """
+        Take the body of the POST request and feed it to gateway.Runner();
+        return the result as the response body.
+        """
+
+        def onSuccess(result, output):
+            txt = output.getvalue()
+            output.close()
+            request.write(txt)
+            request.finish()
+
+        def onError(failure):
+            message = failure.getErrorMessage()
+            tbStringIO = cStringIO.StringIO()
+            failure.printTraceback(file=tbStringIO)
+            tbString = tbStringIO.getvalue()
+            tbStringIO.close()
+            error = {
+                "Error" : message,
+                "Traceback" : tbString,
+            }
+            log.error("command failed %s" % (failure,))
+            request.write(writePlistToString(error))
+            request.finish()
+
+        from calendarserver.tools.gateway import Runner
+        body = request.content.read()
+        command = readPlistFromString(body)
+        output = cStringIO.StringIO()
+        runner = Runner(self.davRootResource, self.directory, self.store,
+            [command], output=output)
+        d = runner.run()
+        d.addCallback(onSuccess, output)
+        d.addErrback(onError)
+        return NOT_DONE_YET
+
+
+
+
+
+def makeAgentService(store):
+    """
+    Returns a service which will process GatewayAMPCommands, using a socket
+    file descripter acquired by launchd
+
+    @param store: an already opened store
+    @returns: service
+    """
+    from twisted.internet import reactor
+
+    sockets = getLaunchdSocketFds() 
+    fd = sockets["AgentSocket"][0]
+    
+    family = socket.AF_INET
+    endpoint = AdoptedStreamServerEndpoint(reactor, fd, family)
+
+    from twistedcaldav.config import config
+    davRootResource = getRootResource(config, store)
+    directory = davRootResource.getDirectory()
+
+    root = Resource()
+    root.putChild("gateway", AgentGatewayResource(store,
+        davRootResource, directory))
+
+    realmName = "/Local/Default"
+    portal = Portal(AgentRealm(root, ["com.apple.calendarserver"]),
+        [DirectoryServiceChecker(realmName)])
+    credentialFactory = CustomDigestCredentialFactory("md5", realmName)
+    wrapper = HTTPAuthSessionWrapper(portal, [credentialFactory])
+
+    site = Site(wrapper)
+
+    return StreamServerEndpointService(endpoint, site)
+
+
+
+
+#
+# Alternate implementation using AMP instead of HTTP
+#
+
 class GatewayAMPCommand(amp.Command):
     """
     A command to be executed by gateway.Runner 
@@ -119,33 +338,9 @@
             self.directory)
 
 
-def makeAgentService(store):
-    """
-    Returns a service which will process GatewayAMPCommands, using a socket
-    file descripter acquired by launchd
 
-    @param store: an already opened store
-    @returns: service
-    """
-    from twisted.internet import reactor
-
-    # TODO: remove this
-    def getLaunchdSocketFds():
-        return {}
-
-    # TODO: implement this
-    sockets = getLaunchdSocketFds() 
-    fd = sockets["AgentSocket"][0]
-    
-    # TODO: use UNIX socket
-    family = socket.AF_INET
-    endpoint = AdoptedStreamServerEndpoint(reactor, fd, family)
-    return StreamServerEndpointService(endpoint, GatewayAMPFactory(store))
-
-
-
 #
-# A test client
+# A test AMP client
 #
 
 command = """<?xml version="1.0" encoding="UTF-8"?>
@@ -159,8 +354,7 @@
 
 def getList():
     creator = ClientCreator(reactor, amp.AMP)
-    # TODO: use UNIX socket
-    d = creator.connectTCP('127.0.0.1', 12345)
+    d = creator.connectTCP('sagen.apple.com', 62308)
 
     def connected(ampProto):
         return ampProto.callRemote(GatewayAMPCommand, command=command)

Added: CalendarServer/trunk/calendarserver/tools/test/test_agent.py
===================================================================
--- CalendarServer/trunk/calendarserver/tools/test/test_agent.py	                        (rev 0)
+++ CalendarServer/trunk/calendarserver/tools/test/test_agent.py	2013-06-26 01:07:36 UTC (rev 11403)
@@ -0,0 +1,147 @@
+##
+# Copyright (c) 2013 Apple Inc. All rights reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+##
+
+from calendarserver.tools.agent import AgentRealm
+from calendarserver.tools.agent import CustomDigestCredentialFactory
+from calendarserver.tools.agent import DirectoryServiceChecker
+from twistedcaldav.test.util import TestCase
+from twisted.internet.defer import inlineCallbacks
+from twisted.cred.error import UnauthorizedLogin 
+from twisted.web.resource import IResource
+from twisted.web.resource import ForbiddenResource
+
+
+
+
+class AgentTestCase(TestCase):
+
+    def test_CustomDigestCredentialFactory(self):
+        f = CustomDigestCredentialFactory("md5", "/Local/Default")
+        challenge = f.getChallenge(FakeRequest())
+        self.assertTrue("qop" not in challenge)
+        self.assertEquals(challenge["algorithm"], "md5")
+        self.assertEquals(challenge["realm"], "/Local/Default")
+
+    @inlineCallbacks
+    def test_DirectoryServiceChecker(self):
+        c = DirectoryServiceChecker("/Local/Default")
+        fakeOpenDirectory = FakeOpenDirectory()
+        c.directoryModule = fakeOpenDirectory
+
+        fields = {
+            "username" : "foo",
+            "realm" : "/Local/Default",
+            "nonce" : 1,
+            "uri" : "/gateway",
+            "response" : "abc",
+            "algorithm" : "md5",
+        }
+        creds = FakeCredentials("foo", fields)
+
+        # Record does not exist:
+        fakeOpenDirectory.returnThisRecord(None)
+        try:
+            yield c.requestAvatarId(creds)
+        except UnauthorizedLogin:
+            pass
+        else:
+            self.fail("Didn't raise UnauthorizedLogin")
+
+
+        # Record exists, but invalid credentials
+        fakeOpenDirectory.returnThisRecord("fooRecord")
+        fakeOpenDirectory.returnThisAuthResponse(False)
+        try:
+            yield c.requestAvatarId(creds)
+        except UnauthorizedLogin:
+            pass
+        else:
+            self.fail("Didn't raise UnauthorizedLogin")
+
+
+        # Record exists, valid credentials
+        fakeOpenDirectory.returnThisRecord("fooRecord")
+        fakeOpenDirectory.returnThisAuthResponse(True)
+        avatar = (yield c.requestAvatarId(creds))
+        self.assertEquals(avatar, "foo")
+
+
+        # Record exists, but missing fields in credentials
+        del creds.fields["nonce"]
+        fakeOpenDirectory.returnThisRecord("fooRecord")
+        fakeOpenDirectory.returnThisAuthResponse(False)
+        try:
+            yield c.requestAvatarId(creds)
+        except UnauthorizedLogin:
+            pass
+        else:
+            self.fail("Didn't raise UnauthorizedLogin")
+
+
+    def test_AgentRealm(self):
+        realm = AgentRealm("root", ["abc"])
+
+        # Valid avatar
+        interface, resource, ignored = realm.requestAvatar("abc", None, IResource)
+        self.assertEquals(resource, "root")
+
+        # Not allowed avatar
+        interface, resource, ignored = realm.requestAvatar("def", None, IResource)
+        self.assertTrue(isinstance(resource, ForbiddenResource))
+
+        # Interface unhandled
+        try:
+            realm.requestAvatar("def", None, None)
+        except NotImplementedError:
+            pass
+        else:
+            self.fail("Didn't raise NotImplementedError")
+
+
+
+
+class FakeRequest(object):
+
+    def getClientIP(self):
+        return "127.0.0.1"
+
+
+
+class FakeOpenDirectory(object):
+
+    def returnThisRecord(self, response):
+        self.recordResponse = response
+
+    def getUserRecord(self, ignored, username):
+        return self.recordResponse
+
+    def returnThisAuthResponse(self, response):
+        self.authResponse = response
+
+    def authenticateUserDigest(self, ignored, node, username, challenge, response,
+        method):
+        return self.authResponse
+
+    ODNSerror = "Error"
+
+
+
+class FakeCredentials(object):
+
+    def __init__(self, username, fields):
+        self.username = username
+        self.fields = fields
+        self.method = "POST"

Modified: CalendarServer/trunk/contrib/launchd/agent.plist
===================================================================
--- CalendarServer/trunk/contrib/launchd/agent.plist	2013-06-25 18:48:16 UTC (rev 11402)
+++ CalendarServer/trunk/contrib/launchd/agent.plist	2013-06-26 01:07:36 UTC (rev 11403)
@@ -46,19 +46,12 @@
           <key>AgentSocket</key>
           <dict>
                   <key>SockServiceName</key>
-                  <string>12345</string>
+                  <string>62308</string>
+                  <key>SockNodeName</key>
+                  <string>127.0.0.1</string>
                   <key>SockFamily</key>
                   <string>IPv4</string>
           </dict>
-          <!--
-          <key>AgentSocket</key>
-          <dict>
-                  <key>SockFamily</key>
-                  <string>UNIX</string>
-                  <key>SockPathName</key>
-                  <string>/some/path/org.calendarserver.agent.sock</string>
-          </dict>
-          -->
   </dict>
 
   <key>HardResourceLimits</key>
@@ -73,9 +66,6 @@
     <integer>12000</integer>
   </dict>
 
-  <key>PreventsSleep</key>
-  <true/>
-
   <key>StandardOutPath</key>
   <string>/var/log/caldavd/agent.log</string>
 
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://lists.macosforge.org/pipermail/calendarserver-changes/attachments/20130625/15c57062/attachment-0001.html>


More information about the calendarserver-changes mailing list